本篇讲解常见的几种跨域方案:JSONP
、CORS
、图像Ping、document.domain
、window.name
。
开始之前,要先清楚一件事:
跨域不一定是浏览器限制了发起跨站请求,而也可能是跨站请求可以正常发起,但是返回结果被浏览器拦截了。最好的例子是 CSRF 跨站攻击原理,请求是发送到了后端服务器无论是否跨域!注意:有些浏览器不允许从 HTTPS 的域跨域访问 HTTP,比如 Chrome 和 Firefox,这些浏览器在请求还未发出的时候就会拦截请求,这是一个特例。
<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/json?callback=handleResponse';
document.body.insertBefore(script,document.body.firstChild);
首先是客户端的角度,这段代码声明了一个用以接受数据的回调函数,之后动态创建了 script
,执行完毕之后来到 body
,这时候遇到语句 <script src='http://test.com/json?callback=handleResponse'></script>
,此时会向服务器发起一次资源请求;然后来到服务端的角度,服务端解析上述的 url,得到查询参数 callback 的值是 handleResponse,此时会生成一个对应的函数执行语句,也就是 handleResponse(data)
,这个语句返回给了客户端这边,客户端执行该语句(因为当前作用域确实声明了这个 handleResponse 函数),打印相关数据。这样就算完成一次跨域请求了。
JSONP 使用起来虽然很简单,但是有如下缺点:
CORS 即 Cross-origin resource sharing,跨域资源共享 ,是由 W3C 官方推广的允许通过 AJAX 技术跨域获取资源的规范 。
CORS 的关键在于服务端,也就是客户端这边发送请求,服务端那边做一些判断(请求方是否在自己的“白名单”里?),如果没问题就返回数据,否则拒绝。
浏览器将 CORS 请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
只要同时满足以下两大条件,就属于简单请求:
application/x-www-form-urlencoded
,multipart/form-data
和 text/plain
其中一种)凡不同时满足以上两大条件的,都属于非简单请求。
下面我们看一下针对这两种请求,CORS 是怎么处理。
首先是客户端的角度,发送请求时浏览器检测到这是一个简单请求,因此在请求头额外增加一个 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
是否在自己的许可范围内,
如果确实是许可的域,那么待会响应的时候,响应头会额外增加如下字段:
Origin
匹配的 http://test.com
。当然,也可以返回 *
,表示接受任何域的 AJAX 请求(*
是通配的意思)。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
为指定的域(而不是 *
),非简单请求包括两次请求,第一次请求是 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...
Origin
,表示请求代码所在的源Access-Control-Request-Method
和 Access-Control-Request-Headers
,这其实是告诉服务端,“我待会要进行的真正请求,类型是这里 Access-Control-Request-Headers
指定的类型,然后自定义请求头是这里 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
*
,也就是告诉客户端,“我给你的域下了许可证“再回到客户端这边,客户端收到响应,知道服务端允许了自己的请求,于是进行第二次真正的 AJAX 跨域请求。此后每次 CORS 请求都相当于一次简单请求了。
但是,如果发现客户端的请求是不合法的,那么服务端虽然会返回正常响应,但不会返回 CORS 相关的响应头,而客户端这边”心领神会“,知道被拒绝了,所以由 xhr 对象捕获这个错误,如下图所示:
我们可以来解读一下这个报错:上图的 Response to preflight request 就是服务端对于预检请求的响应,这个响应返回到客户端之后,客户端进行一次 access control check,也就是检查这个响应是否有标志着服务端同意的响应头,因为 No ‘Access-Control-Allow-Origin’ header is present on the requested resource,也就是说我客户端这边并没有检查到服务端本应提供的 Access-Control-Allow-Origin 响应头,所以最终 doesn’t pass access control check,也就是没有通过这次检查。
介绍 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>
<script>
document.domain = 'test.com'; // 虽然本来就是 test.com,但还是要显式设置一次
</script>
之后,我们就可以在 A 域中拿到 B 域的东西了。注意,尽管这时候 document.domain 是一样的,但两个域之间只是可以交互而已,仍然不能发送 AJAX 请求。
首先要明白一件事 —— 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 的两次加载
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