九种跨域解决方案

什么是跨域

由于浏览器的同源策略,所谓同源策略,指的是浏览器对不同源的脚本或者文本的访问方式进行的限制。比如源 a 的 js 不能读取或设置引入的源 b 的元素属性。

产生跨域原因:

  • 浏览器限制
  • 请求是跨域的
  • 请求是XHR(XMLHttpRequest)请求

什么是同源?

所谓同源,就是指两个页面具有相同的协议,主机(也常说域名),端口,三个要素缺一不可。例如:

URL1 URL2 说明 是否允许通信
http://www.foo.com/js/a.js http://www.foo.com/js/b.js 协议、域名、端口都相同 允许
http://www.foo.com/js/a.js http://www.foo.com:8888/js/b.js 协议、域名相同,端口不同 不允许
https://www.foo.com/js/a.js http://www.foo.com/js/b.js 主机、域名相同,协议不同 不允许
http://www.foo.com/js/a.js http://www.bar.com/js/b.js 协议、端口相同,域名不同 不允许
http://www.foo.com/js/a.js http://foo.com/js/b.js 协议、端口相同,主域名相同,子域名不同 不允许

同源策略限制了不同源之间的交互,但是有人也许会有疑问,我们以前在写代码的时候也常常会引用其他域名的 js 文件,样式文件,图片文件什么的,没看到限制啊,这个定义是不是错了。其实不然,同源策略限制的不同源之间的交互主要针对的是 js 中的 XMLHttpRequest 等请求,下面这些情况是完全不受同源策略限制的:

  • 页面中的链接,重定向以及表单提交是不会受到同源策略限制的
  • 跨域资源嵌入是允许的,例如 <img src=XXX>,当然,浏览器限制了 Javascript 不能读写加载的内容。

同源策略限制内容有:

  • Cookie、LocalStorage、IndexedDB 等存储性内容
  • DOM 节点
  • AJAX 请求发送后,结果被浏览器拦截了

跨域解决方案

1.CORS

跨域资源共享(CORS) 是一种机制,它使用额外的 HTTP 头来告诉浏览器 让运行在一个 origin (domain) 上的 Web 应用被准许访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器「不同的域、协议或端口」请求一个资源时,资源会发起一个「跨域 HTTP 请求」。

而在 cors 中会有简单请求复杂请求的概念。

简单请求是满足以下所有条件的请求:

  • 只允许 GET、POST 和 HEAD 方法
  • 不能自定义请求头,除了代理自动设置的请求头外(Connection,User-Agent等),只允许 CORS 安全列出的请求头,它们是:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type
    • Last-Event-ID
    • 其它名称是不区分字节大小写的匹配(DPR、Downdlink、Save-Data、Viewport-Width、Width等,没试过~)
  • Content-Type:只限于三个值
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • 于XMLHttpRequestUpload:
    • 请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器
    • XMLHttpRequestUpload 对象可以使用XMLHttpRequest.upload 属性访问
  • 请求中没有使用 ReadableStream 对象

除了简单请求情况之外的就是复杂请求。

CORS 预检请求首先通过 OPTIONS 方法向另一个域上的资源发送 HTTP 请求,用来确定实际请求是否跨域安全的发送。预检请求通过后才会发送真实请求。

Node 中的解决方案

原生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.use(async (ctx, next) => {
  ctx.set("Access-Control-Allow-Origin", ctx.headers.origin);
  ctx.set("Access-Control-Allow-Credentials"true);
  ctx.set("Access-Control-Request-Method""PUT,POST,GET,DELETE,OPTIONS");
  ctx.set(
    "Access-Control-Allow-Headers",
    "Origin, X-Requested-With, Content-Type, Accept, cc"
  );
  if (ctx.method === "OPTIONS") {
    ctx.status = 204;
    return;
  }
  await next();
});

中间件:

1
2
3
// koa 用 koa-cors
const cors = require('cors') // 快速处理跨域
app.use(cors())

关于 cors 的 cookie 问题

想要传递 cookie 需要满足 3 个条件:

  1. web 请求设置 withCredentials

这里默认情况下在跨域请求,浏览器是不带 cookie 的。但是我们可以通过设置 withCredentials 来进行传递 cookie.

1
2
3
4
5
// 原生 xml 的设置方式
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
// axios 设置方式
axios.defaults.withCredentials = true;
  1. Access-Control-Allow-Credentials 为 true

  2. Access-Control-Allow-Origin 为非 *

这里请求的方式,在 chrome 中是能看到返回值的,但是只要不满足以上其一,浏览器会报错,获取不到返回值。

2.Node 正向代理

代理的思路为,利用服务端请求不会跨域的特性,让接口和当前站点同域。

Webpack (4.x)

1
2
3
4
5
6
7
8
devServer: {
port: 8080,
proxy: {
"/api": {
target: "http://localhost:8888"
}
}
},

利用 node 作为中间件代理(两次跨域)

实现原理:同源策略是浏览器需要遵循的标准,而如果是服务器向服务器请求就无需遵循同源策略。

代理服务器,需要做以下几个步骤:

  • 接受客户端请求
  • 将请求转发给服务器。
  • 拿到服务器响应数据。
  • 将响应转发给客户端。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
var app = express()

var apiRoutes = express.Router()

apiRoutes.get('/getDiscList', function (req, res) {
var url = 'https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg'
axios.get(url, {
headers: {
referer: 'https://c.y.qq.com/',
host: 'c.y.qq.com'
},
params: req.query
}).then((response) => {
res.json(response.data)
}).catch((e) => {
console.log(e)
})
})

apiRoutes.get('/lyric', function (req, res) {
var url = 'https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg'

axios.get(url, {
headers: {
referer: 'https://c.y.qq.com/',
host: 'c.y.qq.com'
},
params: req.query
}).then((response) => {
var ret = response.data
if (typeof ret === 'string') {
var reg = /^\w+\(({[^()]+})\)$/
var matches = ret.match(reg)
if (matches) {
ret = JSON.parse(matches[1])
}
}
res.json(ret)
}).catch((e) => {
console.log(e)
})
})

app.use('/api', apiRoutes)

这里用 node 重写了2个接口,利用 node 去请求真实的服务器 https://c.y.qq.com,带上需要的 headers 请求头。

再看一个完整的简单案例:

本地文件 index.html 文件,通过代理服务器 http://localhost:3000 向目标服务器 http://localhost:4000 请求数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/ index.html(http://127.0.0.1:5500)
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script>
$.ajax({
url: 'http://localhost:3000',
type: 'post',
data: { name: 'xiamen', password: '123456' },
contentType: 'application/json;charset=utf-8',
success: function(result) {
console.log(result) // {"title":"fontend","password":"123456"}
},
error: function(msg) {
console.log(msg)
}
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// server1.js 代理服务器(http://localhost:3000)
const http = require('http')
// 第一步:接受客户端请求
const server = http.createServer((request, response) => {
// 代理服务器,直接和浏览器直接交互,需要设置CORS 的首部字段
response.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': 'Content-Type'
})
// 第二步:将请求转发给服务器
const proxyRequest = http
.request(
{
host: '127.0.0.1',
port: 4000,
url: '/',
method: request.method,
headers: request.headers
},
serverResponse => {
// 第三步:收到服务器的响应
var body = ''
serverResponse.on('data', chunk => {
body += chunk
})
serverResponse.on('end', () => {
console.log('The data is ' + body)
// 第四步:将响应结果转发给浏览器
response.end(body)
})
}
)
.end()
})
server.listen(3000, () => {
console.log('The proxyServer is running at http://localhost:3000')
})
1
2
3
4
5
6
7
8
9
10
11
// server2.js(http://localhost:4000)
const http = require('http')
const data = { title: 'fontend', password: '123456' }
const server = http.createServer((request, response) => {
if (request.url === '/') {
response.end(JSON.stringify(data))
}
})
server.listen(4000, () => {
console.log('The server is running at http://localhost:4000')
})

上述代码经过两次跨域,值得注意的是浏览器向代理服务器发送请求,也遵循同源策略,最后在 index.html 文件打印出 {"title":"fontend","password":"123456"}

3.Nginx 反向代理

实现原理类似于 Node 中间件代理,需要你搭建一个中转 nginx 服务器,用于转发请求。

使用nginx反向代理实现跨域,是最简单的跨域方式。只需要修改 nginx 的配置即可解决跨域问题,支持所有浏览器,支持 session,不需要修改任何代码,并且不会影响服务器性能。

实现思路:通过 nginx 配置一个代理服务器(域名与 domain1 相同,端口不同)做跳板机,反向代理访问 domain2 接口,并且可以顺便修改 cookie 中 domain 信息,方便当前域 cookie 写入,实现跨域登录。

将 nginx 目录下的 nginx.conf 修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// proxy服务器
server {
listen 80;
server_name www.domain1.com;
location / {
proxy_pass http://www.domain2.com:8080; #反向代理
proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名
index index.html index.htm;

# 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
add_header Access-Control-Allow-Origin http://www.domain1.com; #当前端只跨域不带cookie时,可为*
add_header Access-Control-Allow-Credentials true;
}
}

nginx 使用配置大全请看nginx 最全操作总结

4.JSONP

利用 <script> 标签没有跨域限制的漏洞,网页可以得到从其他来源动态产生的 JSON 数据。 JSONP 请求一定需要对方的服务器做支持才可以。

JSONP 优点是简单兼容性好,可用于解决主流浏览器的跨域数据访问的问题。缺点是仅支持 get 方法具有局限性,不安全可能会遭受 XSS 攻击。

jsonp 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function jsonp ({url, params, callback}) {
return new Promise((resolve, reject) => {
// 创建 script 标签
let script = document.createElement('script')
// 将函数挂在 window 上
window[callback] = function (data) {
resolve(data)
// 代码执行后,删除 script 标签
document.body.removeChild(script)
}
// 回调函数加在请求地址上
params = {...params, callback} // wb=b&callback=show
let arrs = []
for (let key in params) {
array.push(`${key}=${params[key]}`)
}
script.src = `${url}?${arrs.join('&')}`
document.body.appendChild(script)
})
}

使用

1
2
3
4
5
6
7
8
9
10
11
12
function show(data) {
console.log(data)
}
jsonp({
url: 'http://localhost:3000/say',
params:{
wd: 'I love you'
},
callback: 'show'
}).then(data => {
console.log(data)
})

上面这段代码相当于向 http://localhost:3000/say?wd=I love you&callback=show 这个地址请求数据,然后后台返回 show('I love you too'),最后会运行 show() 这个函数,打印出’I love you too’

1
2
3
4
5
6
7
8
9
10
// server.js
let express = require('express')
let app = express()
app.get('/say', function(req, res) {
let { wd, callback } = req.query
console.log(wd) // I love you
console.log(callback) // show
res.end(`${callback}('I love you too')`)
})
app.listen(3000)

5.Websocket

Websocket 是 HTML5 的一个持久化的协议,它实现了浏览器与服务器的全双工通信,同时也是跨域的一种解决方案。WebSocket 和 HTTP 都是应用层协议,都基于 TCP 协议。但是 WebSocket 是一种双向通信协议,在建立连接之后,WebSocket 的 server 与 client 都能主动向对方发送或接收数据。同时,WebSocket 在建立连接时需要借助 HTTP 协议,连接建立好了之后 client 与 server 之间的双向通信就与 HTTP 无关了,因此也没有跨域的限制。

1
2
3
4
5
6
7
8
9
10
// socket.html
<script>
let socket = new WebSocket('ws://localhost:3000');
socket.onopen = function () {
socket.send('我爱你');// 向服务器发送数据
}
socket.onmessage = function (e) {
console.log(e.data);// 接收服务器返回的数据
}
</script>
1
2
3
4
5
6
7
8
9
10
11
// server.js
let express = require('express');
let app = express();
let WebSocket = require('ws');// 记得安装ws
let wss = new WebSocket.Server({port:3000});
wss.on('connection',function(ws) {
ws.on('message', function (data) {
console.log(data);
ws.send('我也爱你')
});
})

6.window.postMessage

postMessage 是 HTML5 XMLHttpRequest Level 2 中的 API,且是为数不多可以跨域操作的 window 属性之一,它可用于解决以下方面的问题:

  • 页面和其打开的新窗口的数据传递
  • 多窗口之间消息传递
  • 页面与嵌套的iframe消息传递
  • 上面三个场景的跨域数据传递
  • postMessage() 方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递。
1
otherWindow.postMessage(message, targetOrigin, [transfer]);
  • message: 将要发送到其他 window的数据。
  • targetOrigin:通过窗口的origin属性来指定哪些窗口能接收到消息事件,其值可以是字符串”*”(表示无限制)或者一个URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。
  • transfer(可选):是一串和 message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。
  • 接下来我们看个例子: http://localhost:3000/a.html 页面向 http://localhost:4000/b.html 传递“我爱你”,然后后者传回”我不爱你”。
1
2
3
4
5
6
7
8
9
10
11
12
// a.html
<iframe src="http://localhost:4000/b.html" frameborder="0" id="frame" onload="load()"></iframe> //等它加载完触发一个事件
//内嵌在http://localhost:3000/a.html
<script>
function load() {
let frame = document.getElementById('frame')
frame.contentWindow.postMessage('我爱你', 'http://localhost:4000') //发送数据
window.onmessage = function(e) { //接受返回数据
console.log(e.data) //我不爱你
}
}
</script>
1
2
3
4
5
// b.html
window.onmessage = function(e) {
console.log(e.data) //我爱你
e.source.postMessage('我不爱你', e.origin)
}

7.window.name + iframe

window.name 属性的独特之处:name 值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2MB)。

其中 a.html 和 b.html 是同域的,都是 http://localhost:3000;而 c.html 是 http://localhost:4000

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// a.html(http://localhost:3000/b.html)
<iframe src="http://localhost:4000/c.html" frameborder="0" onload="load()" id="iframe"></iframe>
<script>
let first = true
// onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
function load() {
if(first){
// 第1次onload(跨域页)成功后,切换到同域代理页面
let iframe = document.getElementById('iframe');
iframe.src = 'http://localhost:3000/b.html';
first = false;
}else{
// 第2次onload(同域b.html页)成功后,读取同域window.name中数据
console.log(iframe.contentWindow.name);
}
}
</script>
1
2
3
4
// c.html(http://localhost:4000/c.html)
<script>
window.name = '我不爱你'
</script>

总结:通过 iframe 的 src 属性由外域转向本地域,跨域数据即由 iframe 的 window.name 从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。

8.location.hash + iframe

实现原理: a.html 欲与 c.html 跨域相互通信,通过中间页 b.html 来实现。 三个页面,不同域之间利用 iframe 的 location.hash 传值,相同域之间直接 js 访问来通信。

具体实现步骤:一开始 a.html 给 c.html 传一个 hash 值,然后 c.html 收到 hash 值后,再把 hash 值传递给 b.html,最后 b.html 将结果放到 a.html 的 hash 值中。
同样的,a.html 和 b.html 是同域的,都是 http://localhost:3000;而 c.html 是 http://localhost:4000

1
2
3
4
5
6
7
 // a.html
<iframe src="http://localhost:4000/c.html#iloveyou"></iframe>
<script>
window.onhashchange = function () { //检测hash的变化
console.log(location.hash);
}
</script>
1
2
3
4
5
 // b.html
<script>
window.parent.parent.location.hash = location.hash
//b.html将结果放到a.html的hash值中,b.html可通过parent.parent访问a.html页面
</script>
1
2
3
4
5
6
7
// c.html
<script>
console.log(location.hash);
let iframe = document.createElement('iframe');
iframe.src = 'http://localhost:3000/b.html#idontloveyou';
document.body.appendChild(iframe);
</script>

9.document.domain + iframe

该方式只能用于二级域名相同的情况下,比如 a.test.com 和 b.test.com 适用于该方式。
只需要给页面添加 document.domain =’test.com’ 表示二级域名都相同就可以实现跨域。

实现原理:两个页面都通过js强制设置document.domain为基础主域,就实现了同域。

我们看个例子:页面 a.zf1.cn:3000/a.html 获取页面 b.zf1.cn:3000/b.html 中 a 的值

1
2
3
4
5
6
7
8
9
10
11
// a.html
<body>
helloa
<iframe src="http://b.zf1.cn:3000/b.html" frameborder="0" onload="load()" id="frame"></iframe>
<script>
document.domain = 'zf1.cn'
function load() {
console.log(frame.contentWindow.a);
}
</script>
</body>
1
2
3
4
5
6
7
8
// b.html
<body>
hellob
<script>
document.domain = 'zf1.cn'
var a = 100;
</script>
</body>

总结

  • CORS 支持所有类型的 HTTP 请求,是跨域 HTTP 请求的根本解决方案

  • JSONP 只支持 GET 请求,JSONP 的优势在于支持老式浏览器,以及可以向不支持 CORS 的网站请求数据。

  • 不管是Node中间件代理还是 nginx 反向代理,主要是通过同源策略对服务器不加限制。

  • 日常工作中,用得比较多的跨域方案是 cors 和 nginx 反向代理

参考链接:

九种跨域方式实现原理(完整版)