什么是跨域
之所以有跨域(Cross-Origin, 也译作跨源)这种操作方式,是为了解决浏览器同源策略。同源,即协议相同、域名相同及端口相同。同源策略最早被网景引入是为了保证用户信息的安全,防止恶意的网站窃取数据。最初非同源限制是cookie,现在包括dom和xhr及fetch请求都不能跨域访问。
不受同源策略限制的情况也有很多,如:
<script src="..."></script>标签嵌入跨域脚本;<link rel="stylesheet" href="...">标签嵌入CSS;<img>嵌入图片;<video>和<audio>嵌入多媒体资源;<object>,<embed>和<applet>的插件;@font-face引入的字体;<frame>和<iframe>载入的任何资源.
通过这些“例外”情况,就给了程序员们可乘之机,涌现出了许多奇技淫巧,这类跨域的解决方案姑且称之为hack流。
与此同时,w3c听取了程序员们对于跨域请求的渴望,推出了cors标准,通过设置服务器以达到ajax的跨域请求,html5也适时推出了postMessage api以满足跨域通信。这种官方开门允许跨域的方法,我们姑且称之为正统流。
跨域访问的解决方案
如上文所言,从原理上,跨域的解决方案可以大概分为正统流和hack流两大类。下面具体分别来介绍各种解决方法的实现。
分类
Hack流
IMG Ping——利用图片的跨域特性
我们每天浏览很多的网页,有时候可能会发现某些图片(<img>)是来自其他网站的,其实你看到的就是一种跨域,由于图片不受“同源策略”限制,我们就可以利用图片进行跨域了。我们将图片的src属性指向请求的地址,通过监听load和error事件,就能知道响应什么时候接受了,响应的数据可以是任意内容,但通常是204响应(No content 没有响应体)。图像ping的例子如下:1
2
3
4
5
6
7
8var btn = document.querySelector("#start-ping");
btn.onclick = function(){
var img = new Image();
img.onload = img.onerror = function(obj){
document.querySelector("#result").innerHTML = "finished";
};
img.src = "http://localhost:3000/img?r="+Math.random();
};
服务器端代码:1
2
3router.get('/img', function(req, res, next) {
res.send('success!');
});
然而在实际使用中会发现,无法获取相应文本。
优点:兼容性好;缺点:只支持Get请求且无法获取相应。
JSONP——利用script标签的跨域特性
JSONP是JSON with padding(填充式JSON)的简写,是应用JSON的一种方法,看起来和JSON差不多,只不过是被包含在函数调用中的JSON。例如:1
callback({"key":"val"});
JSONP由两部分组成:回调函数和数据,回调函数是响应到来时应该在页面中调用的函数,而数据是传入回调函数中的JSON数据(服务器填充的)。下面就是一个典型的JSONP请求:1
http://example.com/jsonp?callback=handleResponse
JSONP也是不受“同源策略”限制的,原因和图片ping是一样的,<script>标签也可以跨域,因此我们可以通过利用JONP来动态创建<script>,并将其src指向一个跨域的URL,就可以完成和跨域得服务器之间的通信了。下面就来看一个例子:1
2
3
4
5
6
7
8
9var btn2 = document.querySelector("#start-jsonp");
btn2.onclick = function(){
var script = document.createElement("script");
script.src = "http://localhost:3000/jsonp";
document.body.insertBefore(script, document.body.firstChild);
};
function pagefunc(num){
document.querySelector("#result2").innerHTML = "我从服务器获得了一个随机数:"+num;
}
服务器代码:1
2
3router.get('/jsonp', function(req, res, next) {
res.send('pagefunc(' + Math.random() + ')');
});
JSONP是非常简单易用的,与图像ping相比,优点就是能直接访问响应文本,能够在服务器与客户端建立双向通信。但是JSONP也是有缺点的:JSONP直接从其他域加载代码执行,如果其他域不安全,可能会在响应中夹带一些恶意代码。其次,要确定JSONP请求是否失败并不容易,HTML5为<script>增加了onerror方法,但是目前支持度还不是很好。
优点:简单易用,可以获取响应文本;缺点:只能get,不易获取错误。
iframe实现跨域通信
iframe的src可以指向不同域的地址,如果我们新建一个iframe元素将其指向所需跨域请求的地址是否就可以实现跨域通信了呢?然而事实是,iframe跨域的话将会取不到其dom元素。幸好前辈程序员们孜孜不倦的追求中,从iframe的一些特性中找到了空子可钻。
端口协议主域相同、子域不同
对于主域相同而子域不同的例子,可以通过设置document.domain的办法来解决。具体的做法是可以在http://www.a.com/a.html和http://script.a.com/b.html两个文件中分别加上document.domain = ‘a.com’;然后通过a.html文件中创建一个iframe,去控制iframe的contentDocument,这样两个js文件之间就可以“交互”了。当然这种办法只能解决主域相同而二级域名不同的情况,如果你异想天开的把script.a.com的domian设为alibaba.com的话,就会报错了。
www.a.com上的a.html:1
2
3
4
5
6
7
8
9
10document.domain = 'a.com';
var ifr = document.createElement('iframe');
ifr.src = 'http://script.a.com/b.html';
ifr.style.display = 'none';
document.body.appendChild(ifr);
ifr.onload = function(){
var doc = ifr.contentDocument || ifr.contentWindow.document;
// 在这里操纵b.html
alert(doc.getElementsByTagName("h1")[0].childNodes[0].nodeValue);
};
script.a.com上的b.html:1
document.domain = 'a.com';
子域也不同
常用的有两种方法:利用location.hash改变页面不刷新的特性或者window.name在src改变的情况下不发生改变的特性进行跨域通信。
location.hash
假设域名a.com下的文件a.html要和b.com域名下的b.html传递信息。
- a.html首先创建自动创建一个隐藏的iframe,iframe的src指向b.com域名下的b.html页面;
- b.html响应请求后再将通过修改a.html的hash值来传递数据。由于两个页面不在同一个域下IE、Chrome不允许修改parent.location.hash的值,所以要借助于a.com域名下的一个代理iframe;
- 同时在a.html上加一个定时器(支持onhashchange事件的话可以监听该事件),隔一段时间来判断location.hash的值有没有变化,一旦有变化则获取获取hash值。
a.com/a.html:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16function startRequest(){
var ifr = document.createElement('iframe');
ifr.style.display = 'none';
ifr.src = 'http://www.b.com/b.html#do';
document.body.appendChild(ifr);
}
startRequest();
function checkHash() {
try {
var data = location.hash ? location.hash.substring(1) : '';
if (console.log) {
console.log('data: '+data);
}
} catch(e) {};
}
setInterval(checkHash, 2000);
b.com/b.html:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22//模拟一个简单的参数处理操作
switch(location.hash){
case '#do':
callBack();
break;
case '#did':
//do something……
break;
}
function callBack(){
try {
parent.location.hash = 'somedata';
} catch (e) {
// ie、chrome的安全机制无法修改parent.location.hash,
// 所以要利用一个中间的b.com域下的代理iframe
var ifrproxy = document.createElement('iframe');
ifrproxy.style.display = 'none';
ifrproxy.src = 'http://a.com/c.html#somedata'; // 注意该文件在"a.com"域下
document.body.appendChild(ifrproxy);
}
}
a.com/c.html:1
parent.parent.location.hash = self.location.hash.substring(1);
优点:1.可以解决域名完全不同的跨域。2.可以实现双向通讯;
缺点:location.hash会直接暴露在URL里,并且在一些浏览器里会产生历史记录,数据安全性不高也影响用户体验。另外由于URL大小的限制,支持传递的数据量也不大。有些浏览器不支持hashchange事件,需要轮询来获知URL的变化。
window.name
window对象有个name属性,该属性有个特征:即在一个窗口(window)的生命周期内,窗口载入的所有的页面都是共享一个window.name的,每个页面对window.name都有读写的权限,window.name是持久存在一个窗口载入过的所有页面中的。window.name属性的神奇之处在于name 值在不同的页面(甚至不同域名)加载后依旧存在(如果没修改则值不会变化),并且可以支持非常长的 name 值(2MB)。
跨域解决方案似乎可以呼之欲出了,假设a.com域下的a.html页面请求远端服务器的数据,我们在该页面下新建一个iframe标签,该iframe的src属性指向b.com/b.com(利用iframe标签的跨域能力),b.com/b.html文件里设置好window.name的值(也就是该iframe的contentWindow的name值),然后在a.com/a.html里读取该iframe的window.name值,一切似乎水到渠成,代码如下:
a.com/a.html:1
2
3
4
5
6iframe = document.createElement('iframe'),
iframe.src = 'http://b.com/b.html';
document.body.appendChild(iframe);
iframe.onload = function() {
console.log(iframe.contentWindow.name)
};
b.com/b.html:1
window.name = "{'key':'val'}";
然而实际使用的时候发现,会报错:Protocals, domains and ports must match. 所以还是跨域了……
为什么会这样,因为如最开始所说,如果页面与iframe的src不同源,则无法取该框架的信息。于是我们想到,既然window.name有不变性,那么我们只要载入跨域src且设置window.name信息之后换个src去指定,问题也许迎刃而解?
新建空白页面a.com/c.html,并且修改a.com/a.html:1
2
3
4
5
6
7iframe = document.createElement('iframe'),
iframe.src = 'b.com/b.html';
document.body.appendChild(iframe);
iframe.onload = function() {
iframe.src = 'http://a.com/c.html';
console.log(iframe.contentWindow.name)
};
实际使用过程中,确实可以取到window.name的值,然而iframe的load事件绑定了修改src的方法,于是load事件就被持续触发了,aka 整个iframe不断的刷新……继续修改a.com/a.html的代码以期完美:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19iframe = document.createElement('iframe');
iframe.style.display = 'none';
var state = 0;
iframe.onload = function() {
if(state === 1) {
var data = JSON.parse(iframe.contentWindow.name);
console.log(data);
iframe.contentWindow.document.write('');
iframe.contentWindow.close();
document.body.removeChild(iframe);
} else if(state === 0) {
state = 1;
iframe.contentWindow.location = 'http://a.com/c.html';
}
};
iframe.src = 'http://b.com/b.html';
document.body.appendChild(iframe);
已经不复存在环境的方法——ie6的bug, window.navigator
IE6的bug,父页面和子页面都可以访问window.navigator这个对象,在navigator上添加属性或方法可以共享。因为现在没有IE6环境,此处不再赘述。
正统流
原本为保证用户安全而引入的同源策略,却限制了开发人员本应有的跨域需求。cors和postMessage适时推出,一个从服务端一个从前端提出了各自的跨域请求解决思路。
CORS——跨域资源共享
CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。
因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。
有了CORS,没有跨域通信能力的XHR及Fetch终于也可以跨域了。从前端实现来看,和同源ajax请求并无差别。
跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站有权限访问哪些资源。另外,规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。
接下来的内容将讨论相关场景,并剖析该机制所涉及的 HTTP 首部字段。
这里,我们使用三个场景来解释跨域资源共享机制的工作原理。这些例子都使用 XMLHttpRequest 对象。
简单请求

某些请求不会触发 CORS 预检请求。称之为“简单请求”,请注意,该术语并不属于 Fetch (其中定义了 CORS)规范。若请求满足所有下述条件,则该请求可视为“简单请求”:
- 使用下列方法之一:
- GET
- HEAD
- POST
- Content-Type :
- //注:仅当POST方法的Content-Type值等于下列之一才算作简单请求
- text/plain
- multipart/form-data
- application/x-www-form-urlencoded
- Fetch 规范定义了对 CORS 安全的首部字段集合,不得人为设置该集合之外的其他首部字段。该集合为:
- Accept
- Accept-Language
- Content-Language
- Content-Type (需要注意额外的限制)
- DPR
- Downlink
- Save-Data
- Viewport-Width
- Width
对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。
下面是一个例子,浏览器发现这次跨源AJAX请求是简单请求,就自动在头信息之中,添加一个Origin字段。
1 | GET /cors HTTP/1.1 |
上面的头信息中,Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。
如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequest的onerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。
如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。
1 | Access-Control-Allow-Origin: http://a.com |
上面的头信息之中,有三个与CORS请求相关的字段,都以 Access-Control- 开头:
| 字段名 | 是否必须 | 类型 | 含义 |
|---|---|---|---|
| Access-Control-Allow-Origin | 是 | 请求时Origin字段的值/* | 接受特定来源的请求,*为任意来源 |
| Access-Control-Allow-Credentials | 否 | 布尔值 | 是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。 |
| Access-Control-Expose-Headers | 否 | - | CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。 |
由于CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定Access-Control-Allow-Credentials字段。1
Access-Control-Allow-Credentials: true
另一方面,开发者必须在AJAX请求中打开withCredentials属性。1
2var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。
但是,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,开发者可以在请求中显式关闭withCredentials。
需要注意的是,如果要发送Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传。
预检请求

与前述简单请求不同,“需预检的请求”要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。”预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。
当请求满足下述任一条件时,即应首先发送预检请求:
- 使用了下面任一 HTTP 方法:
- PUT
- DELETE
- CONNECT
- OPTIONS
- TRACE
- PATCH
- 人为设置了对 CORS 安全的首部字段集合之外的其他首部字段。该集合为:
- Accept
- Accept-Language
- Content-Language
- Content-Type (but note the additional requirements below)
- DPR
- Downlink
- Save-Data
- Viewport-Width
- Width
- Content-Type 的值不属于下列之一:
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。
下面是一个预检请求的HTTP头信息示例:1
2
3
4
5
6
7
8OPTIONS /cors HTTP/1.1
Origin: http://a.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: b.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
除了Origin字段,”预检”请求的头信息包括两个特殊字段:
| 字段名 | 是否必须 | 含义 |
|---|---|---|
| Access-Control-Request-Method | 是 | 列出浏览器的CORS请求会用到哪些HTTP方法 |
| Access-Control-Request-Headers | 否 | 该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段 |
服务器收到”预检”请求以后,检查了Origin、Access-Control-Request-Method和Access-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应:1
2
3
4
5
6
7
8
9
10
11
12HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://a.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
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
而如果服务器否定了”预检”请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。报错信息如下:1
2XMLHttpRequest cannot load http://b.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.
一旦服务器通过了”预检”请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。
CORS与JSONP的使用目的相同,但是比JSONP更强大。
优点:JSONP只支持GET请求,CORS支持所有类型的HTTP请求。
缺点:JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据。
postMessage——跨文档通信
在hack流中我们了解到,iframe的src如果和父页面不同源,那么父页面将取不到该框架的信息。HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。这个API为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。Internet Explorer 8+, chrome,Firefox , Opera 和 Safari 都支持这个功能。但是Internet Explorer 8和9以及Firefox 6.0和更低版本仅支持字符串作为postMessage的消息。
postMessage方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即”协议 + 域名 + 端口”。也可以设为*,表示不限制域名,向所有窗口发送。
父窗口和子窗口都可以通过message事件,监听对方的消息。message事件的事件对象event,提供以下三个属性:
- event.source: 发送消息的窗口;
- event.origin: 消息发向的网址;
- event.data: 消息内容.
一个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15var onmessage = function (event) {
var data = event.data;//消息
var origin = event.origin;//消息来源地址
var source = event.source;//源Window对象
if(origin == "http://www.aaa.com"){
console.log(data);//hello world!
}
source.postMessage('Nice to see you!', '*');
};
if (typeof window.addEventListener != 'undefined') {
window.addEventListener('message', onmessage, false);
} else if (typeof window.attachEvent != 'undefined') {
//ie
window.attachEvent('onmessage', onmessage);
}
其他
时代眼泪——XDomainRequest
XDomainRequest是在IE8和IE9上的HTTP access control (CORS) 的实现,在IE10中被 包含CORS的XMLHttpRequest 取代了,该接口可以发送GET和POST请求。
语法1
var xdr = new XDomainRequest();
返回XDomainRequest的实例,该实例可以被用来生成或管理请求。
XDomainRequest对象构成
| 构成 | 名称 | 含义 |
|---|---|---|
| 属性 | timeout | 获取或设置请求的过期时间 |
| responseText | 以字符串形式获取响应体 | |
| 方法 | open() | 根据指定的方法(GET或POST)和URL, 打开请求 |
| send() | 发送请求, POST的数据会在该方法中被指定 | |
| abort() | 中止请求 | |
| 事件处理程序 | onprogress | 当请求中发送方法和onload事件中有进展时的处理程序 |
| ontimeout | 当请求超时时的事件处理程序 | |
| onerror | 当请求发生错误时的处理程序 | |
| onload | 当服务器端的响应被完整接收时的处理程序 |
例子1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25if(window.XDomainRequest){
var xdr = new XDomainRequest();
xdr.open("get", "http://example.com/api/method");
xdr.onprogress = function () {
//Progress
};
xdr.ontimeout = function () {
//Timeout
};
xdr.onerror = function () {
//Error Occured
};
xdr.onload = function() {
//success(xdr.responseText);
}
setTimeout(function () {
xdr.send();
}, 0);
}
安全
XDomainRequest为了确保安全构建,采用了多种方法。
- 安全协议源必须匹配请求的URL。(http到http,https到https)。如果不匹配,请求会报“拒绝访问”的错误。
- 被请求的URL的服务器必须带有 设置为(“*”)或包含了请求方的Access-Control-Allow-Origin的头部。
开发专用——nginx反向代理
nginx反向代理的一个典型应用是均衡负载,然而其特性让我们在开发的时候可以方便的进行跨域设置。开发过程中一个典型的场景是,api已经上线,但是并没有设置cors,或者小作坊开发的时候,api服务在别人的电脑上也是开发状态,均不允许跨域调用。我们的应用上线之后发起的请求是同源的,然而在开发的时候是跨域的,apparently。这个时候也可以设置nginx反向代理实现跨域请求。
如果我们在a.com/a.html里ajax请求b.com/b?param=1, 必然会遇到跨域问题,如果我们打开nginx.conf配置文件:1
2
3
4
5
6
7
8
9
10
11
12server{
listen 8000;
location / {
root html;
index index.html index.htm;
}
location /b {
rewrite ^/b/?$ /$1 break;
includeuwsgi_params;
proxy_pass http://b.com/b
}
}