跨域解决方案总结
总结一下跨域问题的解决方案,篇幅有点长
先说什么是跨域
一句话说“跨域”就是,一个域下的脚本或者文档试图去获取另一个域下的资源。
造成跨域问题的根本原因是浏览器的同源策略导致的。这也说明,后端是不存在跨域问题的,只有和浏览器有关的部分才会涉及到跨域问题。
同源策略是浏览器安全策略的基础,所谓同源,指的是“协议+域名+端口”都相同,值得注意的是,即便不同的域名指向同一个 ip 也认为是不同源,还有域名和域名对应 ip 也认为是不同域的。如果缺少同源策略,那么很容易获取不同域下的隐私信息,这是很危险的。
为了安全,浏览器的同源策略限制了以下行为:
- Cookie、LocalStorage 和 IndexDB 无法读取。
- DOM 无法获得。
- AJAX 请求不能发送。
常见的跨域场景
处理表格第一行所示的情况没有跨域外,其他都存在跨域问题,可见,浏览器的同源策略还是很严格的。
跨域问题的几种解决方案
- iframe + document.domain
- iframe + location.hash
- iframe + window.name
- window.postMessage
- JSONP
- CORS(跨域资源共享)
- WebSocket 协议
- 跨域代理(nginx/node)
iframe + document.domain
先来看一个例子:
父页面 a.html
1 2 3 4 5 6 7 8 9 10 11 12 13
| <!DOCTYPE html> <html> <head> <title>iframe + domain</title> </head> <body> <iframe id="iframe" src="http://child.domain.com:7001/public/domain/b.html"></iframe> <script> document.domain = 'domain.com'; var data = 'data'; </script> </body> </html>
|
子页面 b.html
1 2 3 4 5 6 7 8 9 10 11 12 13
| <!DOCTYPE html> <html> <head> <title>iframe + domain</title> </head> <body> <script> document.domain = 'domain.com'; console.log('get js data from parent ---> ' + window.parent.data); </script> </body> </html>
|
子页面和父页面都使用 js 将各自的基础主域 document.domain 设置为相同的值 “domain.com” 这样就实现子页面和父页面的同域。
值得注意的是,该方法只能实现主域相同,子域不同的跨域场景,document.domain 设置成共同的主域。所以,应用场景比较局限。
iframe + location.hash
URL 的 hash 值改变之后,页面不会刷新。
要实现父页面 a.html 向嵌入 iframe 的子页面 b.html 传入数据,可以通过父页面的 js 修改子页面 src 的 hash 值,子页面设置 window.onhashchange 事件监听 hash 值的变化。
同时,如果子页面 b.html 想向父页面 a.html 传入数据,可以使用同样的方式,在 b.html 中使用 iframe 嵌入一个和 a.html 同域的子页面 c.html,同时在 c.html 设置 window.onhashchange 事件监听 hash 值的变化,当 b.html 修改了 iframe 的 src 的 hash 值,则 c.html 能监听到该变化,从而获取 hash 传递过来的数据,此时,因为c.html 和 a.html 同域,所以,c.html 中可以直接调用 a.html 事先设置的回调函数,从而将数据传递给 a.html。
a.html 修改iframe src 的 hash —-> b.html window.onhashchange 监听 hash 变化; b.html 修改iframe src 的 hash —-> c.html(与 a.html 同域) window.onhashchange 监听 hash 变化; 调用 a.html 中的 callback —-> 数据传递给 a.html。
举个例子
父页面 a.html
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
| <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <title>Page Title</title> <meta name="viewport" content="width=device-width, initial-scale=1"> </head> <body> <div>a.html</div> <iframe id="iframe" src="http://x.stuq.com:7001/public/hash/b.html"></iframe> <script> var iframe = document.getElementById('iframe');
// a.html 中通过向 b.html 的 src中写入 hash 来传递值 setTimeout(function(){ iframe.src = iframe.src + "#userdata=xxx"; },1000); function onCallback(res) { console.log('data from c.html ---> ' + res); } </script> </body> </html>
|
子页面 b.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> <div>b.html</div> <iframe id="iframe" src="http://y.stuq.com:7001/public/hash/c.html"></iframe> <script> var iframe = document.getElementById('iframe');
// 监听a.html传来的hash值,再传给c.html window.onhashchange = function () { console.log("in b.html: " + location.hash); iframe.src = iframe.src + location.hash; }; </script> </body> </html>
|
b.html 的子页面 c.html, 和 a.html 同域
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> <div>c.html</div> <script> // 监听b.html传来的hash值 window.onhashchange = function () { console.log("in c.html: " + location.hash); // 再通过操作同域a.html的js回调,将结果传回 window.parent.parent.onCallback(location.hash); }; </script> </body> </html>
|
当数据量比较小的时候,想从 b.html 传递数据到 a.html 也可以通过修改 parent.location.href 给父页面添加 hash 值,然后,在 a.html 中添加 window.onhashchange 的监听。
比如,我们可以通过在页面中添加 iframe 子页面的方式来发起与子页面同域的 http 请求,然后将请求结果通过 hash 传递给父页面。一个例子:
父页面 js(y.stuq.com 域)
1 2 3 4 5 6 7
| var iframe = document.createElement('iframe') iframe.src = 'http://x.stuq.com:7001/public/hash.html'; document.body.appendChild(iframe)
window.onhashchange = function () { console.log(location.hash) }
|
hash.html(x.stuq.com 域)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <!DOCTYPE html> <html> <head> </head> <body> <script> var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { var res = JSON.parse(xhr.responseText) parent.location.href = `http://y.stuq.com:7001/public/3.html#msg=${res.msg}`; //请求结果传递给父域 } } xhr.open('GET', 'http://x.stuq.com:7001/json', true); xhr.send(null); </script> </body> </html>
|
iframe + window.name
浏览器的窗口有 window.name 属性,这个属性的特点是,无论是否同源,前一个页面设置的值,后面一个页面就可以读取;并且这个属性有一个很大的优点就是容量很大(2MB)。
一个例子:
a.html
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 45 46 47 48
| <!DOCTYPE html> <html> <head> <title>iframe + window.name</title> </head> <body> <div class="container"></div> <script> var proxy = function(url, callback) { var state = 0; var iframe = document.createElement('iframe');
iframe.src = url;
iframe.onload = function() { if (state === 1) { callback(iframe.contentWindow.name); destoryFrame();
} else if (state === 0) { iframe.src = 'http://y.stuq.com:7001/public/name/proxy.html'; state = 1; } };
document.body.appendChild(iframe);
function destoryFrame() { iframe.contentWindow.document.write(''); iframe.contentWindow.close(); document.body.removeChild(iframe); } };
proxy('http://x.stuq.com:7001/public/name/b.html', function(data){ console.log(data); }); </script> </body> </html>
|
b.html
1 2 3 4 5 6 7 8 9 10 11 12
| <!DOCTYPE html> <html> <head> <title>iframe + window.name</title> </head> <body> <div class="container">b.html</div> <script> window.name = 'This is data from b!';// 改变 window.name 的值来传递数据 </script> </body> </html>
|
proxy.html (空页面,和 a.html 同域)
1 2 3 4 5 6 7 8 9 10 11 12
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> <div class="container">proxy.html</div> </body> </html>
|
a.html 中封装了 proxy 方法,该方法设置了 iframe.onload 事件,以此来监听 iframe 的 src 的变化。
首次调用 proxy,加载跨域页面 b.html,b.html 中修改了该 iframe 窗口的 window.name 属性。
b.html 加载之后,修改 iframe.src 的值,加载和 a.html 同域的 proxy.html 文件回到 a.html 域中,同时又会再次触发 iframe.onload 事件,事件回调中可以读取 iframe 中 window.name 的值,即可获取数据。
和 hash 一样,我们可以通过 iframe 加载 name.html 页面并在该页面中发起和该页面同域的 http 请求,然后将获取返回的数据写入到 iframe 窗口的 window.name 中,再修改 iframe 的 src 为和主页面同域的 index.html;这样在主页面就可以获取到 iframe 的 contentWindow.name 的值。
主页面 (y.stuq.com 域)
设置 iframe.onload 监听事件
1 2 3 4 5 6 7 8 9 10
| var iframe = document.createElement('iframe') iframe.src = 'http://x.stuq.com:7001/public/name.html' document.body.appendChild(iframe)
var times = 0 iframe.onload = function () { if (++times === 2) { console.log(JSON.parse(iframe.contentWindow.name)) } }
|
name.html (x.stuq.com 域)
发起同域的请求,并修改 iframe 的 src 的值为 y.stuq.com 域下的 index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <!DOCTYPE html> <html> <head> </head> <body> <script> var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { window.name = xhr.responseText location.href = 'http://y.stuq.com:7001/public/index.html' } } xhr.open('GET', 'http: xhr.send(null) </script> </body> </html>
|
index.html(y.stuq.com 域)
内容可为空
1 2 3 4 5 6 7 8 9 10
| <!DOCTYPE> <html> <head> <title>同源</title> </head> <body> <div class="container"> </div> </body> </html>
|
需要注意的是,该方法必须监听子窗口 window.name 属性的变化,影响网页性能。
window.postMessage
postMessage 方法是 HTML5 中 API 为 window 对象提供的新的方法,该方法可以安全的实现跨域通信,无论是否同源。
基本用法: postMessage(data, targetOrigin)
data: 表示要传递的数据/消息
targetOrigin:接收消息的窗口的源,协议+主机+端口号,也可以设置为”*”,表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为”/“。
一个例子:
父页面 a.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <!DOCTYPE html> <html> <head> </head> <body> <iframe id="iframe" src="http://x.stuq.com:7001/public/postMessage/b.html"></iframe> <script> var iframe = document.getElementById('iframe'); iframe.onload = function() { var data = { name: 'aym' }; // 向 domain x 传送跨域数据 iframe.contentWindow.postMessage(JSON.stringify(data), 'http://x.stuq.com:7001/'); };
// 接受domain x返回数据 window.addEventListener('message', function(e) { console.log(e); console.log('data from domain x ---> ' + e.data); }, false); </script> </body> </html>
|
子页面 b.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <!DOCTYPE html> <html> <head> </head> <body> <script> // 接收domain1的数据 window.addEventListener('message', function(e) { console.log(e); console.log('data from domain y ---> ' + e.data);
var data = JSON.parse(e.data); if (data) { data.number = 16;
// 处理后再发回domain y window.parent.postMessage(JSON.stringify(data), 'http://y.stuq.com:7001/'); } }, false); </script> </body> </html>
|
父窗口和子窗口都设置了 message 事件监听,当调用彼此的 postMessage 方法之后,触发对应的事件,message 事件的事件对象包含以下属性:
- event.source:发送消息的窗口
- event.origin: 消息发向的网址
- event.data: 消息内容
其中 event.data 就是我们需要传递的数据。
同样,我们可以用这种方式跨域获取 http 请求数据。一个例子:
父页面 (y.stuq.com:7001 域)
1 2 3 4 5 6 7 8 9
| var iframe = document.createElement('iframe') iframe.src = 'http://x.stuq.com:7001/public/post.html' document.body.appendChild(iframe)
window.addEventListener('message', function(e) { console.log(e); console.log(JSON.parse(e.data)) }, false);
|
子页面 post.html (x.stuq.com:7001 域)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <!DOCTYPE html> <html> <head> </head> <body> <script> var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { //调用父域的 postMessage, 传递数据 parent.postMessage(xhr.responseText, 'http://y.stuq.com:7001/') } } //发起同域 http 请求 xhr.open('GET', 'http: xhr.send(null) </script> </body> </html>
|
需要说明的是,要将数据传递给哪个域,就需要使用该域下的某个 window 对象。当 postMessage 的第二个参数设置为 ‘/‘ 的时候,表示的是应该传递给当前域下的所有窗口,此时如果 postMessage 的调用者所处的不是当前域,则会报错。
JSONP
JSONP 是一种比较常用的客户端跨域请求服务器端资源的方法。它兼容性很好,几乎没有浏览器的限制。
其基本原理是:浏览器运行 html 通过相应的标签(img, script 等)获取不同域下的资源。
基于以上原理,JSONP 的做法如下:
- html 中动态添加 script 标签
- 设置其 src 属性为需要请求数据的地址,并传递一个 callback 参数
- 设置 callback 参数的值为本地方法, 如,function foo(data){},在该方法中接收请求到的数据
- 服务器端接收到请求之后,读取callback的值后,将返回的结果封装成类似以下的形式:
typeof foo === ‘function’ && foo({msg: “hello world”});
- 请求返回后,浏览器读取到一段 js 代码之后就会执行这段代码,从而将数据丢给之前设置的方法处理
一个简单的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13
| window.foo = function (value) { console.log(value); }
var script = document.createElement('script'); script.src = 'http://x.stuq.com:7001/json?callback=foo'; document.body.appendChild(script);
require(['http://x.stuq.com:7001/json?callback=define'], function (value) { console.log(value) })
|
方法二中,直接使用了 require 来实现的,在 require 中直接写匿名回调。常用的还有 jQuery 的 jsonp。
值得注意的是,jsonp 的方式只能解决 GET 请求的跨域问题,不能解决其他请求的跨域问题,同时,也是要后台配合的。
CORS(跨域资源共享)
跨域资源共享(Cross-Origin Resource Sharing),是 W3C 提出的,跨域问题的根本解决方案,它允许任何类型的请求,且具有较好的浏览器兼容性。
CORS 的整个过程都是浏览器自动完成的,不需要用户的参与。用户发起跨域的 ajax 请求和发起同域的 ajax 请求是一样的,没有任何区别。那么,CORS 是怎么实现的呢?
简单的说,就是,服务器端会告诉浏览器允许哪些源的请求,浏览器据此判断该请求是否要发出。
因此,服务器端设置是 CORS 的关键。一个简单的例子。
前端
1 2 3 4 5 6 7 8 9
| var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { console.log(JSON.parse(xhr.responseText).msg) } } xhr.withCredentials = true xhr.open('GET', 'http://x.stuq.com:7001/cros') xhr.send(null)
|
后台
1 2 3 4 5 6 7 8 9 10
| module.exports = app => { class CrosController extends app.Controller { * index(req) { this.ctx.set('Access-Control-Allow-Origin', '*') this.ctx.body = { msg: 'hello world' } } } return CrosController }
|
上面是一个简单的 GET 请求的例子。浏览器在发出请求前会自动添加一个名叫 Origin 的请求头,值为当前的域,如,http://y.stuq.com:7001;后台响应请求的时候,需要手动设置一个名叫 Access-Control-Allow-origin 的响应头,该响应头的值为服务器允许哪些域的请求;如果请求头 Origin 的值在响应头 Access-Control-Allow-origin 值的范围内,则表示该跨域请求是被允许的,则正常返回请求数据;否则,请求也会正常响应(200,不返回数据),但是浏览器端会报错(Console 报错),同时,xhr.onerror 事件会捕获到该错误。
CORS 默认不发送 cookie 和 HTTP 认证信息,如果需要将 cookie 发送到服务器端,需要在前端显示设置 xhr.withCredentials = true; 同时,服务器端需要添加 Access-Control-Allow-Credentials 响应头为 true,且,如果需要传递 cookie,Access-Control-Allow-origin 的值不能为 *,只能为指定值。
预检请求
上面的例子是一个简单请求,浏览器端就直接发送该简单请求。所谓简单请求就是同时满足以下条件的请求:
- 请求只能为 HEAD, GET, POST 之中的一个
- HTTP 头不超过以下几个字段:Accept,Accept-Language,Content-Language,Last-Event-ID,Content-Type
- Content-Type 的只为以下其中一个:application/x-www-form-urlencoded,multipart/form-data,text/plain
不同时满足简单请求条件的请求都是非简单请求。那么对于非简单请求,CORS 又做了什么呢? – 预检请求
预检请求是在发出正式请求之前,浏览器发出的一个 OPTIONS 请求,服务器端对预检请求做出响应,告诉浏览器允许哪些域的请求,支持哪些方法,和头部信息。所以,服务器端在对预检请求做出响应时需要添加以下几个头部信息:
Access-Control-Allow-Origin,Access-Control-Allow-Headers,Access-Control-Allow-Methods
下面是一个简单的例子:
1 2 3
| res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Headers", "X-Requested-With,Content-Type"); res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS");
|
浏览器读取到预检请求的响应后,根据以上的头部信息判断是否发出正式请求。如果,预检请求被浏览器否定掉,则浏览器会报错,同时,xhr.onerror 会捕捉到错误;否则,浏览器发出正式请求。
预检请求多发出了一次请求,所以在性能上有一定的损耗,且在 Chrome 的 network 下是看不到预检请求信息的。
WebSocket 协议
WebSocket 是 html5 的协议,以 ws:// 和 wss:// 作为前缀,它实现了服务器端和客户端的全双工通信,同时不受跨域的限制。
一个简单的例子(使用了 socket.io)
前端页面(y.stuq.com:7001 域;或者直接用浏览器打开该静态文件)
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
| <!DOCTYPE html> <html> <head> </head> <body> <div>user input:<input type="text"></div> <script src="https://cdn.bootcss.com/socket.io/2.1.0/socket.io.dev.js"></script> <script> var socket = io('http://localhost:8080'); socket.on('connect', function() { socket.on('message', function(msg) { console.log('data from server: ---> ' + msg); }); socket.on('disconnect', function() { console.log('Server socket has closed.'); }); }); document.getElementsByTagName('input')[0].onblur = function() { socket.send(this.value); }; </script> </body> </html>
|
服务器端(localhost:8080 域)
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
| var http = require('http'); var socket = require('socket.io');
var server = http.createServer(function(req, res) { res.writeHead(200, { 'Content-type': 'text/html' }); res.end(); });
server.listen('8080'); console.log('Server is running at port 8080...');
socket.listen(server).on('connection', function(client) { client.on('message', function(msg) { client.send('hello:' + msg); console.log('data from client: ---> ' + msg); });
client.on('disconnect', function() { console.log('Client socket has closed.'); }); });
|
从上面的例子可以看出,webSocket 需要服务器端和客户端同时支持。
不同域下的客户端可以主动向服务器端发起 webSocket 的连接,服务器端设置了连接监听;连接成功之后服务器端和客户端双方可以通过 message 事件监听来接收对方发的消息;同时可以使用 send 方法给对方发消息。
跨域代理(nginx/node)
代理是万能的,但是这部分没研究过,不太清楚细节怎么去做,暂略。
参考文档