之所以会出现跨域问题,主要是因为 浏览器的同源策略 所引起的,简单来说就是
同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互,这是一个用于隔离潜在恶意文件的重要安全机制
- 受到同源限制
- 无法读取不同源的
Cookie、LocalStorage和IndexDB - 无法获得不同源的
DOM - 不能向不同源的服务器发送
Ajax请求
- 无法读取不同源的
- 不受同源限制
- 在浏览器中
<script>,<img>,<iframe>,<link>等标签都可以跨域加载资源,而不受同源策略的限制
- 在浏览器中
什么是跨域
我们先来看看一个域名的组成,比如
1 | http://www.aaa.com:8080/script/index.js |
一般由 协议(http://),子域名(www),主域名(aaa.com),端口号(8080),请求资源地址(script/index.js)组成
- 协议,网络协议遍及
OSI通信模型(OSI 七层模型,常用协议有TCP/IP、HTTP、FTP协议等) - 域名,
Domain Name,网域,是由一串用点分隔的名字组成的Internet上某一台计算机或计算机组的名称,用于在数据传输时标识计算机的电子方位(有时也指地理位置) - 端口,是设备与外界通讯交流的出口,分为物理端口和虚拟端口(比如常见的
80端口)
当协议,子域名,主域名,端口号中任意一个不相同的时候,都算作不同域,不同域之间相互请求资源,就算作跨域,比如 http://www.aaa.com/index.html 请求 http://bbb.com/index.php,JavaScript 出于安全方面的考虑,不允许跨域调用其他页面的对象,简单的理解就是因为 JavaScript 同源策略的限制,a.com 域名下的 JavaScript 无法操作 b.com 或是 c.a.com 域名下的对象
所谓同源策略,即同域名(
IP),同端口,同协议
CORS
CORS 全称 Cross-Origin Resource Sharing,是 W3C 的一个标准,它定义如何跨域访问资源,浏览器将 CORS 请求分成两类,简单请求(simple request)和非简单请求(not-so-simple request),如下
- 请求方法是以下三种方法之一,
HEAD、GET、POST HTTP的头信息不超出以下几种字段AcceptAccept-LanguageContent-LanguageLast-Event-IDContent-Type,只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain
只要同时满足以下两大条件,就属于简单请求,不同时满足上面两个条件,就属于非简单请求
简单请求
浏览器会带上 Origin 的请求头发送到服务器,服务器根据 Origin 判断是否许可,如果许可就会带上 CORS 相关响应头,如果不在许可范围内就不会带上 CORS 相关的响应头,浏览器再根据响应头中是否有相关的 CORS 响应头,来判断拦截响应 body 和抛出错误
无论你是否需要用 JavaScript 通过 CORS 跨域请求资源,你都要了解 CORS 的原理,最新的浏览器全面支持,在引用外域资源时,除了 JavaScript 和 CSS 外,都要验证 CORS,例如当你引用了某个第三方 CDN 上的字体文件时
1 | @font-face { |
如果该 CDN 服务商未正确设置 Access-Control-Allow-Origin,那么浏览器无法加载字体资源
非简单请求
对于 PUT、DELETE 以及其他类型如 application/json 的 POST 请求,在发送 Ajax 请求之前,浏览器会先发送一个 OPTIONS 请求(带着 Origin、Access-Control-Request-Method、Access-Control-Request-Headers 等 CORS 相关的请求头的预检请求)到这个 URL 上,询问目标服务器是否接受
1 | OPTIONS /path/to/resource HTTP/1.1 |
服务器必须响应并明确指出允许的 Method
1 | HTTP/1.1 200 OK |
浏览器确认服务器响应的 Access-Control-Allow-Methods 头确实包含将要发送的 Ajax 请求的 Method,才会继续发送 Ajax,否则抛出一个错误(可见下方实例),由于以 POST、PUT 方式传送 JSON 格式的数据在 REST 中很常见,所以要跨域正确处理 POST 和 PUT 请求,服务器端必须正确响应 OPTIONS 请求,更多关于 CORS 的信息可以查阅 跨域资源共享 CORS 详解
处理跨域的方法
处理跨域的方法有很多,比如之前比较常见的 JSONP,亦或者现在比较常用的 CORS,所以我们就在这里小小的总结了一下解决跨域的相关方法,下面我们就一个一个来看
CORS
也算是目前使用较多的一种方式,针对于普通跨域请求(简单请求),只服务端设置 Access-Control-Allow-Origin 即可,前端无须设置,Origin 表示本域,也就是浏览器当前页面的域
当 JavaScript 向外域(如 sina.com)发起请求后,浏览器收到响应后,首先检查 Access-Control-Allow-Origin 是否包含本域,如果是,则此次跨域请求成功,如果不是,则请求失败,JavaScript 将无法获取到响应的任何数据,假设本域是 my.com,外域是 sina.com,只要响应头 Access-Control-Allow-Origin 为 http://my.com,或者是 *,本次请求就可以成功,可见跨域能否成功,取决于对方服务器是否愿意给你设置一个正确的 Access-Control-Allow-Origin,决定权始终在对方(服务器)手中

不过有一个需要注意的地方,那就是跨域请求默认不会携带 Cookie 信息,如果需要携带,可以采取如下操作
1 | 'Access-Control-Allow-Credentials': true |
或者在请求当中配置
1 | http.post(url, data, { withCredentials: true }) |
如果是非简单请求,前台则需要添加额外的 Headers 来触发非简单请求,比如下面的示例,前台采用 Ajax
1 | var xhr = new XMLHttpRequest() |
后台采用 Node.js
1 | var http = require('http') |
JSONP
JSONP(JSON With Padding)是 JSON 的一种使用模式,可用于解决主流浏览器的跨域数据访问的问题,主要原理是借助 <script> 等标签的 src 属性可以请求不同域名下的资源,即 <script> 请求不受浏览器同源策略影响,实现过程主要通过网页客户端动态添加 <script> 标签内的 src 属性,向服务端发送请求(不受同源策略束缚),当服务器收到请求后,将数据放在一个指定名字的回调函数里(作为参数)传回来,前台代码如下示例
1 | var script = document.createElement('script') |
上面的 <script> 标签会向本地服务器发送请求,这个请求的后面带了个 callback 参数,是用来告诉服务器回调方法的方法名的,因为服务器收到请求后,会把相应数据写进回调函数的参数位置,后端响应代码如下
1 | app.get('/jsonp', (req, res) => { |
这样浏览器通过 <script> 下载的资源就是上面的脚本了,当 <script> 下载完成就会立即执行,也就是说这个请求返回后就会立即执行上面的脚本代码,而这个脚本代码就是调用回调方法和拿到 JSON 数据,但是有一个需要注意的地方,JSONP 只支持 GET 请求方式,因为本质上 <script> 加载资源就是 GET,但是如果我们需要发送 POST 请求那该怎么办呢?
iframe + form
如果想要发送 POST 请求,可以采用这种方式,主要原理是利用 iframe 标签的跨域能力,我们先来看看前台代码
1 | const requestPost = ({ url, data }) => { |
后台来接收并处理数据
1 | // 处理成功失败返回格式的工具 |
这样一来我们就可以发送 POST 请求了
document.domain + iframe
这种方法有些局限性,仅限主域相同,子域不同的跨域应用场景,实现原理就是让两个页面都通过 JavaScript 强制设置 document.domain 为同一域名,这样一来就实现了同域,这里有两种场景
第一种场景是在父页面调用内嵌的
iframe当中的元素,如下- 我们的父窗口是
http://www.aaa.com/a.html - 子窗口(内嵌的
iframe)是http://www.aaa.com/b.html
- 我们的父窗口是
这时候如果想在 a 页面里获取 b 页面里的 DOM 进行操作,就会发现你不能获得 b 的 DOM,比如使用 document.getElementById('myIFrame').contentWindow.document 或者 window.parent.document.body 都获取不到,都将因为两个窗口不同源而报错,在这个时候只需要在 a 页面里和 b 页面里把 document.domain 设置成相同的值就可以在两个页面里操作 DOM 了
1 | <!-- 父窗口当中内嵌子页面 --> |
- 第二种场景是共享
Cookie引起的问题
在 a 页面里写入了 document.cookie = 'test1=hello',但是在 b 页面当中是获取不到这个 Cookie 的,Cookie 是服务器写入浏览器的一小段信息,只有『同源』的网页才能共享,但是两个网页一级域名相同,只是二级域名不同,在这种情况下浏览器允许通过设置 document.domain 来共享 Cookie
另外,服务器也可以在设置 Cookie 的时候,指定 Cookie 的所属域名为一级域名,这样的话,二级域名或者三级域名不用做任何设置,都可以读取这个 Cookie,但是这里有一些需要注意的地方
document.domain也是有限制的,虽然可读写,但只能设置成自身或者是高一级的父域且主域必须相同,所以只能解决一级域名相同二级域名不同的跨域问题document.domain只适用于Cookie和iframe窗口,LocalStorage和IndexDB无法通过这种方法跨域
window.name + iframe
window 对象有个 name 属性,该属性有个特征,即在一个窗口(window)的生命周期内,窗口载入的所有的页面都是共享一个 window.name 的,每个页面对 window.name 都有读写的权限,window.name 是持久存在一个窗口载入过的所有页面中的,并不会因新页面的载入而进行重置,并且可以支持非常长的 name 值(2MB),这里可以分为下面几种情况
- 第一种情况是在同一个浏览器标签页里打开了不同域名下的页面
比如先在浏览器的一个标签页里打开了 http://www.aaa.com/a.html 页面,你通过 location.href = http://www.bbb.com/b.html 在同一个浏览器标签页里打开了不同域名下的页面,这时候这两个页面可以使用 window.name 来传递参数,因为 window.name 指的是浏览器窗口的名字,只要浏览器窗口相同,那么无论在哪个网页里访问值都是一样的
- 第二种情况和上面的
document.domain + iframe当中的第一种场景类似,但是不同之处就是两个页面的一级域名也不相同,这时候document.domain就解决不了了
这个时候就可以使用 window.name 来解决,比如你在 b 页面里设定 window.name='hello',你再返回到 a 页面,在 a 页面里访问 window.name,可以得到 hello
- 第三种情况比较少见,动态创建
iframe,利用window.name来传递数据,成功后再切换到同域代理页面,如下,这里分为三个页面- 父窗口,
http://www.aaa.com/a.html - 中间代理页面,
http://www.aaa.com/proxy.html,中间代理页,与a.html同域,内容为空即可 - 子窗口(内嵌的
iframe),http://www.bbb.com/b.html(一级域名也不相同)
- 父窗口,
1 | var proxy = function (url, callback) { |
使用的话,在 b 页面当中直接设置 window.name 即可
1 | // b 页面 |
通过 iframe 的 src 属性由外域转向本地域,跨域数据即由 iframe 的 window.name 从外域传递到本地域,这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作
location.hash
location.hash 就是指 url 的 # 号后面的部分,这种情况一般使用在父窗口和 iframe 的子窗口之间通讯或者是 window.open 打开的子窗口之间的通讯,如果是两个不同域的页面 a 和 b 之间需要相互通信,则需要通过借助中间页 c 来实现,实现原理如下
1 | a.html(A 域) ==> b.html(B 域) ==> c.html(A 域) |
a 与 b 不同域只能通过 Hash 值单向通信,b 与 c 也不同域也只能单向通信,但 c 与 a 同域,所以 c 可通过 parent.parent 访问 a 页面所有对象,实例如下,三个测试页面如下
A域下的a.html,地址为http://www.aaa.com/a.htmlB域下的b.html,地址为http://www.bbb.com/b.htmlA域下的c.html,地址为http://www.ccc.com/c.html
1 | <!-- A 域下的 a.html(内嵌 B 域下的 b.html) --> |
postMessage
postMessage 是 HTML5 XMLHttpRequest Level 2 中的 API,且是为数不多可以跨域操作的 window 属性之一,它可用于解决以下方面的问题
- 页面和其打开的新窗口的数据传递
- 多窗口之间消息传递
- 页面与嵌套的
iframe消息传递 - 上面三个场景的跨域数据传递
语法如下
1 | window.postMessage(message, targetOrigin, [transfer]) |
有三个参数
data,向目标窗口发送的数据,任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用JSON.stringify()序列化origin,协议 + 主机 + 端口号,也可以设置为*,表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为'/'transfer,可选参数,是一串和message同时传递的Transferable对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权
另外消息的接收方必须有监听事件,否则发送消息时就会报错,如下所示
1 | The target origin provided ('http://localhost:3000') does not match the recipient window's origin ('http://localhost:3001'). |
接收消息可以直接监听 window 对象的 message 事件即可
1 | window.addEventListener('message', callback) |
callback 接收到的 message 事件包含三个属性
data,从其他window中传递过来的数据origin,调用postMessage时消息发送方窗口的origin- 需要注意的是,这个
origin不能保证是该窗口的当前或未来origin - 因为
postMessage被调用后可能被导航到不同的位置
- 需要注意的是,这个
source,对发送消息的窗口对象的引用,可以使用此来在具有不同origin的两个窗口之间建立双向通信
简单来说,就是一个页面发送数据,另一个页面接收数据,下面来看一个实例
1 | <!-- 父页面(内嵌一个 iframe,内容为页面 b) --> |
Nginx 代理
基本原理是我们请求的时候还是使用的前端域名,但是 Nginx 会帮我们把这个请求转发到真正的后端域名上,这样就可以避免跨域问题,Nginx 配置如下
1 | server{ |
在请求的时候,还是跟往常一样正常请求即可
1 | // 请求的时候直接使用 http://localhost:3000 |
Node.js 中间件代理跨域
中间件实现跨域代理,原理大致与 Nginx 相同,都是通过启一个代理服务器,实现数据的转发,也可以通过设置 cookieDomainRewrite 参数修改响应头中 Cookie 中域名,实现当前域的 Cookie 写入,方便接口登录认证,利用 node + express + http-proxy-middleware 搭建一个 proxy 服务器
前台代码如下
1 | var xhr = new XMLHttpRequest() |
中间件服务器代码如下
1 | var express = require('express') |
后台代码如下
1 | var http = require('http') |
如果使用的是 Webpack 构建的项目,可以使用 webpack-dev-server 代理接口跨域,在开发环境下,由于渲染服务和接口代理服务都是 webpack-dev-server 同一个,所以页面与代理接口之间不再跨域,无须设置 Headers 跨域信息了,webpack.config.js 部分配置如下
1 | module.exports = { |
WebSocket 协议跨域
WebSocket protocol 是 HTML5 一种新的协议,它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是 server push 技术的一种很好的实现,原生 WebSocket API 使用起来不太方便,我们可以选择使用 Socket.io,它很好地封装了 WebSocket 接口,提供了更简单、灵活的接口,也对不支持 WebSocket 的浏览器提供了向下兼容
前台代码如下
1 | <div>user input:<input type="text"></div> |
后台代码如下
1 | var http = require('http') |
总结
处理跨域的方法有许多种,现在比较流行的还是使用 CORS 的方式,也就是服务端来进行设置,从而一劳永逸,不过多了解一些其他的方式也是不错的,还是那句老话,根据实际使用场景来进行选择