本篇讲解常见的几种跨域方案:JSONPCORS、图像Ping、document.domainwindow.name

开始之前,要先清楚一件事:

跨域不一定是浏览器限制了发起跨站请求,也可能是跨站请求可以正常发起,但是返回结果被浏览器拦截了。最好的例子是 CSRF 跨站攻击原理,请求是发送到了后端服务器,无论是否跨域!注意:有些浏览器不允许从 HTTPS 的域跨域访问 HTTP,比如 Chrome 和 Firefox,这些浏览器在请求还未发出的时候就会拦截请求,这是一个特例。

1. JSONP

<link> 获取 CSS,<script> 获取 JS,<img> 获取图片,这些明明也是跨域获取资源,为什么不会被禁止呢?很简单,因为这些都不属于上述特定操作之一,这里请求资源压根没用到 AJAX 请求。再看看我们的需求,我们现在是要在 A 域中获取 B 域资源,那么我完全可以在 A 域中动态创建一个 script 并请求 B 域资源,然后,因为 A 域中的 js 和 scirpt 中的 js 是在同一个作用域中的,所以要在 A 域中展示 B 域的数据也完全不成问题。虽然说法比较简陋,但这就是 JSONP 的原理。下面我们来看看具体实现:

// 1.回调函数
function handleResponse(data){
    console.log(data);
}
// 2.动态创建 script 
var script = document.createElement('script');
script.src = 'http://test.com/test?callback=handleResponse';
document.body.insertBefore(script,document.body.firstChild);
  • 客户端:这段代码声明了一个用以接受数据的回调函数,之后动态创建了 script 并插入到 body 中,一旦执行遇到语句 <script src='http://test.com/test?callback=handleResponse'></script>,就会向服务器发起一次携带参数的请求;
  • 服务端:收到请求,拿到查询参数 callback 的值是 handleResponse,准备好数据 data,之后会生成一个对应的函数执行语句字符串,也就是 handleResponse(data),这个语句返回给了客户端这边,客户端直接执行(注意:当前作用域确实声明了这个 handleResponse 函数),打印相关数据 —— 这里客户端其实已经拿到服务端的数据了,所以算是完成了一次跨域请求。

JSONP 使用起来虽然很简单,但是有如下缺点:

  • 只能发送 GET 请求,无法发送 POST 请求
  • 安全问题。万一服务端那边夹带恶意代码返回过来,那么客户端这边是会直接执行的,因此有安全隐患
  • 无法监测 JSONP 请求是否成功或失败

2. CORS

CORS 即 Cross-origin resource sharing,跨域资源共享 ,是由 W3C 官方推广的允许通过 AJAX 技术跨域获取资源的规范 。

CORS 的关键在于服务端,也就是客户端这边发送请求,服务端那边做一些判断(请求方是否在自己的“白名单”里?),如果没问题就返回数据,否则拒绝。

http 的请求实际上是分成两类的:简单请求(simple request)和非简单请求(not-so-simple request)。根据请求类型的不同,在发出跨域请求的时候,CORS 也会做不同的处理。

怎么才算简单请求?

只要同时满足以下两大条件,就属于简单请求:

  • 请求方法是 HEADGETPOST 请求的其中一种;
  • HTTP 的头信息只限于以下字段:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type(只能为 application/x-www-form-urlencodedmultipart/form-datatext/plain 其中一种)

凡不同时满足以上两大条件的,都属于非简单请求。

下面我们看一下针对这两种请求,CORS 是怎么处理。

2.1 简单请求

首先是客户端的角度,发送请求时浏览器检测到这是一个简单请求,因此在请求头额外增加一个 Origin,它的值是请求代码所在的源,例如 http://test.com

GET /cors HTTP/1.1
Origin: http://test.com
Host: target.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0 ...

然后是服务端的角度,服务端收到请求,首先检测请求报头的 Origin 是否在自己的许可范围内,

如果确实是许可的域,那么待会响应的时候,响应头会额外增加如下字段:

  • Access-Control-Allow-Origin(必选) :这个字段用来告知客户端,服务端能够接受的发送 AJAX 请求的域,因为此次请求得到许可,所以这里返回与先前请求报头中 Origin 匹配的 http://test.com。当然,也可以返回 *,表示接受任何域的 AJAX 请求(* 是通配的意思)。
  • Access-Control-Allow-Credentials(可选):告知浏览器,是否允许客户端发送请求的时候携带 Cookie,true 表示允许,false 表示禁止,出于安全问题考虑(前面说过),CORS 默认不允许跨域 AJAX 请求携带 Cookie。
  • Access-Control-Expose-Headers(可选):该字段用来告知客户端暴露了哪些可以获取的响应头字段。默认情况下,xhr 的 getResponseHeader() 方法只能拿到 6 个基本响应头字段,如果还想额外拿到其它字段,那么前端要和后端商量好,让后端在 Access-Control-Expose-Headers 指定好前端可以通过该方法获取的额外响应头字段。

如果不是许可的域,那么这时候其实压根不会返回 Access-Control-Allow-Origin 这个响应头,而浏览器会捕获这次错误,如下图所示:

PS:虽然禁止跨域 AJAX 请求携带 Cookie 是为了安全考虑,但由于它在身份验证中的重要性,我们有时候还是得携带 Cookie 的。 具体方法是:

  • 客户端配置 withCredentials 属性:
var xhr = new XMLHttpRequest()
xhr.withCredentials = true
  • 服务端配置 Access-Control-Allow-Credential 为 true,配置 Access-Control-Allow-Origin 为指定的域(而不是 *),

2.2 非简单请求

非简单请求包括两次请求,第一次请求是 preflight request,也就是预检/查询请求,这次请求试探性地“询问”服务端,自己打算进行的非简单请求是否合法 —— 不管是否合法,服务端都会通过某种方式通知客户端,客户端基于这个结果,判断是否进行第二次真正的请求。

预检请求是这样的:

首先是客户端的角度,发送请求时浏览器检测到这是一个非简单请求,所以事先向服务端发送一个预检请求:

OPTIONS /cors HTTP/1.1
Origin: http://test.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Custom-Header1,Custom-Header2
Host: target.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
  • 注意,这里这个预检请求的请求方法是 OPTIONS

  • 像之前的简单请求一样,这里浏览器会追加一个 Origin,表示请求代码所在的源

  • 前面我们说过,非简单请求会多出额外的请求头字段,这里多出来的就是 Access-Control-Request-MethodAccess-Control-Request-Headers ,这其实是告诉服务端,“我待会要进行的真正请求,请求方法是这里 Access-Control-Request-Method 指定的方法,然后自定义请求头字段是这里 Access-Control-Request-Headers 指定的头字段,你看看行不行,给我个回应“。

好了,我们来看看服务器作何反应。来到服务端的角度,服务端收到这个请求,它会检测请求头中的信息,发现这个请求是合法的、没啥毛病,“好,我同意你的第二次请求”,不过光说不行,得在返回的响应头中告诉客户端这一点,此时响应头是这样的:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61(Unix)
Access-Control-Allow-Origin: http://test.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Custom-Header1,Custom-Header2
Access-Control-Max-Age: 1728000
Content-type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain
  • Access-Control-Allow-Origin:这里和之前一样,可以是 http://test.com 或者 *,也就是告诉客户端,“我给你的域下了许可证“
  • Access-Control-Allow-Methods:这里告诉客户端,服务端允许的跨域 AJAX 请求的类型,”虽然你刚才告诉我你准备进行的是 PUT 请求,不过你要进行 GET 或者 POST 请求,我也是允许的“
  • Access-Control-Allow-Headers:这里告诉客户端,服务端允许的发送请求时的自定义请求头
  • Access-Control-Max-Age: 这里告诉客户端预检请求的有效期,省去了多次的预检请求。也就是说,”我给你开个后门,1728000 秒内(20天内)你可以直接发送真正的 AJAX 请求,不用每次都来问我了“

再回到客户端这边,客户端收到响应,知道服务端允许了自己的请求,于是进行第二次真正的 AJAX 跨域请求。此后每次 CORS 请求都相当于一次简单请求了。

但是,如果发现客户端的请求是不合法的,那么服务端虽然会返回正常响应,但不会返回 CORS 相关的响应头,而客户端这边”心领神会“,知道被拒绝了,所以由 xhr 对象捕获这个错误,如下图所示:

我们可以来解读一下这个报错:

不管预检请求成功还是失败,服务端都会返回一个响应。客户端针对这个响应会进行一次 access control check,检查响应是否携带 Access-Control-Allow-Origin 头部字段,如果没有的话,则此次 check 失败,预检请求失败,抛出错误。

3. 图像 Ping

  • 图像 Ping 是与服务器进行简单、单向的跨域通信的一种方式,请求的数据是通过查询字符串形式发送的,而响应可以是任意内容,通常是像素图和 204 响应。浏览器虽然得不到任何具体数据,但由于可以监听 load 和 error 事件,所以能知道响应是什么时候接受到的。
  • 图像 Ping 最常用于跟踪用户点击页面或动态广告曝光次数
  • 缺点:单向通信,只支持 GET 请求;无法访问服务器的响应文本

4. document.domain

介绍 document.domain 跨域之前,先解释一下域名的一些概念。

document.domain 适用于主域相同、子域不同的两个域之间的跨域通信。假设我现在有一个A域为 http://www.test.com/a.html ,另一个B域为 http://test.com/b.html ,因为是不同源的(域名不相同),所以我不能在A域中拿到B域的东西,但是呢,我们注意到这两个域的主域是相同的,只是子域不同而已,所以我们可以用 document.domain 的方法实现跨域,具体来说,就是重新设置两个页面的 document.domain 为一个相同的值。

但要注意的是,document.domain 的设置是有限制的,我们只能把 document.domain 设置成自身或更高一级的父域,且主域必须始终保持相同。例如:a.b.test.com 中某个文档的 document.domain 可以设成a.b.test.com(自身)、b.test.com(上一级父域) 、test.com(上上一级父域)中的任意一个,但是不可以设成 c.a.b.test.com(下一级子域),因为这是当前域的子域,也不可以设成 baidu.com,因为主域已经不相同了,这里的主域必须始终保持为 test.com 不变。

来看代码:

A域 http://www.test.com/a.html

<iframe src=" http://test.com/b.html" id="myIframe" onload="test()">
<script>
    document.domain = 'test.com'; // 设置成主域(比自己高一级的父域)
    function test() {
        console.log(document.getElementById('myIframe').contentWindow);
    }
</script>

B域 http://test.com/b.html

<script>
    document.domain = 'test.com'; // 虽然本来就是 test.com,但还是要显式设置一次
</script>

之后,我们就可以在 A 域中拿到 B 域的东西了。注意,尽管这时候 document.domain 是一样的,但两个域之间只是可以交互而已,仍然不能发送 AJAX 请求。

5. window.name

首先要明白一件事 —— window 对象有个 name 属性,在一个窗口的生命周期内,window.name 会被该窗口的所有页面所共享、所读写,不管这些页面是同源还是不同源。

那么,我们岂不是可以把数据放在 window.name 里,然后通过页面跳转把这些数据拿到自己这边来?有道理,不过每次要拿数据就得跳转页面,好像有点麻烦,不妨我们把这个页面跳转的过程放在 iframe 里进行。假定请求数据的页面是 a.html,存放数据的页面是 c.html,那么我们在 a.html 中通过 iframe 加载 c.html,这时候数据已经存放在 iframe 这个窗口的 window.name 里了,之后我们让其跳转到与 a.html 同源的 b.html,根据前面说的,window.name 仍然是被保留的、可访问的,那么 window.name 由 c 传递到了 b,并且由于此时 a.html、b.html 同源,所以 window.name 又可经由 b 传递给 a。

下面说说代码实现:

// c.html
<script type="text/javascript">
    window.name = 我是要传递的 json 数据;
</script>
// b.html
<body>
	我只是一个中转站
</body>
// a.html

<p>hello world</p>
<script>
var p = document.getElementsByTagName('p')[0];
var isFirst = true;
var iframe = document.createElement('iframe');

iframe.src = 'http://localhost:3001/c.html'; 
iframe.style.display = 'none';
document.body.appendChild(iframe);

//监听 iframe 的两次加载(分别是加载 c.html 和 b.html 的时候)
iframe.onload = function () {
    if(isFirst){
        iframe.src = 'http://localhost:3000/b.html';
        isFirst = false;
    }else {
        p.innerHTML = iframe.contentWindow.name;
        // 销毁iframe
        iframe.contentWindow.close();
        document.body.removeChild(iframe);
        iframe.src = '';
        iframe = null;
    }
}
</script>
</body>
</html>

这里动态创建了 iframe,并指定第一次加载的 iframe 是 c.html,一旦加载好(很显然这时候 window.name 的值已经记录在这个窗口里了),就执行回调函数,通过修改 src 让页面跳转到 b.html(这时候 window.name 的值传递给了 b.html),第二次触发执行回调函数,将最初的数据传递给 a.html。

注意两个地方:

  • 由于整个过程是悄悄进行的,我们给 iframe 设置 display:none
  • 拿到数据后记得销毁 iframe,防止内存泄露

上面的写法不需要重写 onload 回调函数,只用一个 flag 标识第一和第二次加载;我们也可以采用下面的方法重写 onload 回调:

iframe.onload = function () {
    iframe.onload = function(){
        p.innerHTML = iframe.contentWindow.name;
        iframe.contentWindow.close();
        document.body.removeChild(iframe);
        iframe.src = '';
        iframe = null;
    }
    iframe.src = 'http://localhost:3000/b.html';
}

参考:

《JavaScript 高级程序设计》第三版
再也不学AJAX了!(三)跨域获取资源 ② - JSONP & CORS
js 中几种常用的跨域方法详解
cross-domain github demo