目录
- 一、跨域和同源策略
- 1. 什么是跨域?
- 2. 同源策略
- 3. 同源策略带来的问题
- 二、跨域解决方案
- 1. iframe通信类
- (1). 修改document.domain
- (2). 设置location.hash
- (3). 设置window.name
- (4). postMessage
- 2. 跨域请求类
- (1). JSONP
- (2). 跨域资源共享(CORS)
- (3). nginx代理
- (4). nodejs中间件
- (5). WebSocket
一、跨域和同源策略
1. 什么是跨域?
跨域指的是某个域下的文档或脚本试图访问其他域下的接口或资源。
对于一个网站来说,“协议+ip/域名+端口号”就是它所在的域,比如百度的www服务所在的域就是“https://www.baidu.com”(端口号是默认的443),以它开头的所有资源都属于这个域。
假设你现在搭建了一个web站点,所在的域是https://10.10.66.88:8080
,然后你在某个页面引入了百度的logo:
<img src="https://www.baidu.com/img/baidu_jgylogo3.gif"
因为这张图片不在你的服务器上,所以你的页面在加载时需要去百度的服务器上下载这张图片,这就是在进行跨域访问。
常见的跨域访问包括:
- 资源跳转,如通过a标签的跳转、重定向等
- 资源嵌入,如通过<link>、<script>、<iframe>、@font-face()等嵌入的其他网站的资源
- 脚本请求,如通过ajax调用其他域下的接口
2. 同源策略
由于跨域访问非静态资源很容易给网站带来安全威胁(如XSS、CSFR攻击等),因此早在web诞生之初,就由Netscape公司提出了同源策略,来限制网站的跨域访问。
同源策略限制以下几种跨域访问:
- 读取其他域下的cookie、localStorage、sessionStorage和indexDB数据
- 获取其他域下的DOM或JS对象
- 通过ajax请求其他域下的接口
同源策略不限制静态资源的访问(静态资源是指HTML文件、CSS样式文件和脚本文件),也就是说,你可以在你的页面内通过<iframe>,<link>,<script>嵌入不属于你自己网站的页面、样式和脚本。
需要特别强调的是,同源策略并不是http(s)协议所要求的,它只是浏览器为了保证web安全而制定的策略(也就是说,如果你不使用浏览器作为http代理去访问服务器,那么同源策略是无效的)。
3. 同源策略带来的问题
一方面,同源策略是浏览器为web安全提供的最核心的功能之一,另一方面,它也是对开发者的一个相当大的限制。由于现在大多数网站的架构都比较复杂,需要依靠多个域下的服务来支撑,而同源策略会导致大多数的跨域访问不可用,这严重影响了网站的开发。
同源策略的限制主要表现在两方面,一是前端的页面通信,二是跨域请求。前端的页面通信指的是,父页面与跨域的iframe之间不能相互访问对方的DOM对象、js对象、变量、方法等。在跨域请求方面的表现是,浏览器将拦截跨域的ajax请求,导致跨域请求失败。
目前主要存在下面9种跨域解决方案,我把他们分为两类,分别是与iframe通信相关的和与跨域请求相关的。
二、跨域解决方案
1. iframe通信类
(1). 修改document.domain
这个方案的前提是,两个页面必须位于同一个基础主域,也就是两者的一级域名和二级域名必须相同。举个例子,https://www.baidu.com
和https://image.baidu.com
就是具有相同基础主域的两个域。这时可以在两个页面内将自己的域设置为基础主域,这样它们就变成了同域的页面,如:
父窗口:(https://www.baidu.com/index.html)
document.domain = 'baidu.com'; //修改所在的域
var name = "夕山雨";
iframe子窗口:(https://image.baidu.com/index.html)
document.domain = 'baidu.com'; //现在该iframe与父页面在同一个域
console.log(window.parent.name); //可以访问父页面的变量了
修改了domain之后,父子页面就变成了同域的,不再受同源策略限制。需要注意的是,domain不能修改一、二级域名,因此一、二级域名不同的域不能使用该方案。
(2). 设置location.hash
通过hash值,父页面可以直接向子页面传递少量参数。比如:
父页面:
<iframe id="iframe" src="">
<script>
var name = "Carter";
document.getElementById("iframe").src =
"https://www.baidu.com#name=Carter"
</script>
这样在子页面就能通过window.onhashchange
监听到hash值变化,并通过window.location.hash
得到传入的name参数。如果需要传入其他参数,只需要重新拼接hash值,并给src赋值即可。
不过仅仅这样,无法实现子页面向父页面通信。为此,需要在子页面内再嵌入一个iframe,并且这个iframe与父页面同域。如:
父页面a.html:(https://www.parent.com/a.html)
<iframe id="iframe" src="https://www.child.com/b.html#name=123">
<script>
function getMessageFromB(msg){
console.log(msg);
}
</script>
子页面b.html:(https://www.child.com/b.html)
<iframe id="b" src="">
<script>
window.onhashchange = function(){
console.log(location.hash); // "#name=123"
document.getElementById("b").src =
"https://www.parent.com/c.html#age=24";
}
</script>
c.html:(https://www.parent.com/c.html)
<script>
window.onhashchange = function(){
window.parent.parent.getMessageFromB(location.hash);
}
</script>
b页面作为a页面的子页面,无法直接向其发送数据,因此它在内部嵌入了一个与a页面同域的c页面,然后把需要发送的数据通过hash值发送到c页面。由于c页面与a页面是同域的,因此它可以通过window.parent.parent.getMessageFromB
调用a页面的方法,将数据发送给a页面,间接实现b.html向a.html的通信。大致过程如下:
(3). 设置window.name
每个iframe内部都有一个window对象。一般来说,当修改iframe的src属性时,页面会重新加载,window上所有的属性值都会重置,但name属性是例外。
window.name被设置后,只要不被手动修改,值是不会变化的。因此可以将子页面需要传递给父页面的参数写入window.name中,然后将子页面的src设置为与父页面同域的页面,然后从iframe内window.name中提取参数。如:
a.html:(http://www.domain1.com/a.html)
<script>
var state = 0;
var iframe = document.createElement('iframe');
// 加载跨域页面
iframe.src = 'http://www.domain2.com/b.html';
// onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
iframe.onload = function() {
if (state === 0) {
// 第1次onload(跨域页)成功后,切换到同域代理页面
iframe.contentWindow.location = 'http://www.domain1.com/proxy.html';
state = 1;
} else if (state === 1) {
// 第2次onload(同域proxy页)成功后,读取同域window.name中数据
callback(iframe.contentWindow.name);
}
};
document.body.appendChild(iframe);
</script>
proxy.html:(http://www.domain1.com/proxy.html)
只是作为代理,内容为空。
b.html:(http://www.domain2.com/b.html)
<script>
window.name = 'b页面向a页面发送的数据';
</script>
主要过程为,先在a页面内通过iframe加载b页面,b页面会将需要传递的参数写到window.name中。随后修改iframe的src,使其加载与a页面同域的proxy.html。这时a页面就可以直接从该iframe的window.name属性中读取刚才b页面写入的值。
(4). postMessage
这是前端跨域访问的官方解决方案,也是目前最为简洁和可靠的方案。postMessage使用起来非常方便,直接调用window.postMessage即可向window发送消息,它是window对象上少数几个不受同源策略限制的方法之一。如:
a.html:(http://www.domain1.com/a.html)
<iframe id="iframe" src="https://www.child.com/b.html">
<script>
let child = document.getElementById("iframe");
child.contentWindow.postMessage("来自父页面的消息"); //向子页面发送消息
//监听子页面发送来的消息
window.onmessage = function(data){
console.log(data); //来自子页面的消息
}
</script>
b.html:(http://www.domain2.com/b.html)
<script>
window.parent.postMessage("来自子页面的消息");
//监听其他页面发送来的消息
window.onmessage = function(data){
console.log(data); //来自父页面的消息
}
</script>
postMessage不仅可以实现父子页面通信,借助父页面,还可以实现任意两个iframe之间的通信。并且无论是否跨域,都可以使用postMessage进行通信。因此它是前端页面通信中比较可靠和通用的方案。
2. 跨域请求类
(1). JSONP
这是最古老的的一种跨域请求解决方案,是早期web开发者智慧的结晶。它的理论依据是同源策略不会限制跨域脚本的加载,所以只要把响应头的contentType参数设置为“text/javascript”,它就会被浏览器接受和执行(浏览器会认为该响应是个脚本文件,而脚本文件被视为静态资源)。而前端需要生成一个临时的script标签来进行请求。如:
let script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://www.other.com/login.do?name=123&callback=handle";
document.body.appendChild(script);
function handle(res){
console.log(res);
}
后台的login.do设置response的contentType为“text/javascript”,返回的值为字符串:
'handle({status: "ok"})'
这等价于返回了下面这样一个脚本文件:
handle({status: "ok"});
由于是个脚本文件,浏览器将其视为静态文件,因此不会进行拦截。前台收到这个文件后会自动执行脚本,也就是调用handle({status: "ok"})
,这样,后端想要传递的数据{status: "ok"}
就被传入了handle方法中(注意,在发送请求时我们携带了参数&callback=handle
)。
虽然各个框架实现JSONP的写法不一样,但都是基于这个原理,即将url封装进一个script标签去请求脚本,后端返回“text/javascript”类型的响应作为响应脚本,前端收到响应后像普通脚本一样立即执行。
(2). 跨域资源共享(CORS)
这是目前跨域请求最常用的解决方案,也是官方给出的跨域解决方案。它本质上是对同源策略的一种补充。
从根本上来说,同源策略是为了保证服务端数据和资源的安全。而服务端向外提供的接口并不一定会威胁到服务端安全(比如获取天气信息的接口)。同时,即使跨域调用某个接口可能威胁到服务端安全,但如果某个特定的域是受信任的,那么浏览器也不应该限制该域的跨域调用。
因此web标准工作组提出了跨域资源共享策略(CORS),它允许服务端人为指定资源是否可以被跨域访问,以及可以被哪些域跨域访问,这是通过响应头中新增的几个字段(包括Access-Control-Allow-Origin、Access-Control-Allow-Credentials等)实现的。通过CORS,服务端拥有了配置跨域访问权限的能力。
此时浏览器收到跨域调用的返回结果时,不会像之前一样直接拦截,而是先检查这几个参数,如果符合安全条件,该请求就不会被拦截,前端可以正确得到相应结果,反之会提示跨域报错信息。
下面是一个例子:
前端页面:
$.ajax({
url: "", //跨域接口地址
...
xhrFields: {
withCredentials: true; //设置携带cookie
},
crossDomain: true, //发送跨域请求
...
})
后端java:
...
//设置受信任的域,如果允许任何域调用,可以设置为*
response.setHeader("Access-Control-Allow-Origin", "http://www.domain.com");
//允许前端携带cookie,启用此项后,上一项不可设置为*
response.setHeader("Access-Control-Allow-Credentials", "true");
// 提示OPTIONS预检时,后端需要设置的两个常用自定义头
response.setHeader("Access-Control-Allow-Headers", "Content-Type,X-Requested-With");
...
这样,http://www.domain.com
这个域下的页面就可以跨域调用该接口了。
(3). nginx代理
这是目前非常流行的一种跨域解决方案。我们之前说到,同源策略是针对于浏览器的,与http(s)协议无关。这就是说,两台服务器之间通过http(s)协议进行通信永远不会受到同源策略的限制。
基于这个原理,我们在前后端之间增加一台nginx代理服务器,现在客户端的请求全部发送给该代理服务器,然后由代理服务器转发到服务端。那么我们只要保证访问nginx服务器时不存在跨域,浏览器的同源策略就不会起作用。
(4). nodejs中间件
原理与nginx大致相同,也是配置一台代理服务器,进行请求转发,不过这个服务器是用nodejs编写的真正意义上的服务器。
因为使用CORS策略需要对服务端代码进行改动,这对于庞大而稳定的项目来说工程量是很大的,而且很容易导致不可预知的bug。所以我们把处理跨域的代码迁移到新创建的服务器上,用nodejs中间件来实现。当前端请求到达nodejs服务器时,由nodejs向服务端发送请求(上面我们说到,服务端的通信不受同源策略限制),然后由nodejs来封装响应头,设置Access-Control-Allow-Origin
等参数。
大致原理如下:
使用nodejs作为中间件,不止可以处理跨域,还可以进行接口适配、处理并发、请求预处理等,感兴趣的可以去学习一下。
(5). WebSocket
从我的角度来看,这并不算是一种跨域解决方案。因为同源策略是针对http(s)协议制定的,它对WebSocket所使用的的ws协议本身就不生效,所以自然不存在跨域问题。
不过你也可以把它看做一种跨域的解决方案,因为当你使用http(s)协议无法与服务器进行通信时,将协议切换为ws协议也不失为一个方法(不过这种情况很少见,因为ws协议有自己专门的用途,它并不是用来替代http(s)协议的)。
关于WebSocket的实现不是本文的重点,这里就不再详述了。
总的来说,对于跨域问题,如果是前端页面通信方面的,一般使用postMessage来解决;如果是前后端通信相关的,一般是采用CORS,或通过配置代理服务器来实现。