跨域解决方案总结

总结一下跨域问题的解决方案,篇幅有点长

先说什么是跨域

一句话说“跨域”就是,一个域下的脚本或者文档试图去获取另一个域下的资源。

同源策略(Same origin policy)

造成跨域问题的根本原因是浏览器的同源策略导致的。这也说明,后端是不存在跨域问题的,只有和浏览器有关的部分才会涉及到跨域问题。

同源策略是浏览器安全策略的基础,所谓同源,指的是“协议+域名+端口”都相同,值得注意的是,即便不同的域名指向同一个 ip 也认为是不同源,还有域名和域名对应 ip 也认为是不同域的。如果缺少同源策略,那么很容易获取不同域下的隐私信息,这是很危险的。

为了安全,浏览器的同源策略限制了以下行为:

  • Cookie、LocalStorage 和 IndexDB 无法读取。
  • DOM 无法获得。
  • AJAX 请求不能发送。
常见的跨域场景
url 是否跨域 说明
http://www.domain.com/a.js,http://www.domain.com/b.js,http://www.domain.com/lab/c.js 协议,域名和端口都相同,只是文件或者路径不同
http://www.domain.com:8000/a.js,http://www.domain.com/b.js 端口不同
http://www.domain.com/a.js,https://www.domain.com/b.js 协议不同
http://www.domain.com/a.js,http://192.168.4.12/b.js 域名和域名对应ip
http://www.domain.com/a.js,http://x.domain.com/b.js,http://domain.com/c.js 主域名相同,子域名不同
http://www.domain1.com/a.js,http://www.domain2.com/b.js 域名不同

处理表格第一行所示的情况没有跨域外,其他都存在跨域问题,可见,浏览器的同源策略还是很严格的。

跨域问题的几种解决方案

  • 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);

// 开放给同域c.html的回调方法
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);//发起同域下的http请求
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;

// onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
iframe.onload = function() {
if (state === 1) {
// 第2次onload(同域proxy页)成功后,读取同域window.name中数据
callback(iframe.contentWindow.name);
destoryFrame();

} else if (state === 0) {
// 第1次onload(跨域页)成功后,切换到同域代理页面
// iframe.contentWindow.location = 'http://y.stuq.com:7001/public/name/proxy.html';
//或
iframe.src = 'http://y.stuq.com:7001/public/name/proxy.html';
state = 1;
}
};

document.body.appendChild(iframe);

// 获取数据以后销毁这个iframe,释放内存;这也保证了安全(不被其他域frame js访问)
function destoryFrame() {
iframe.contentWindow.document.write('');
iframe.contentWindow.close();
document.body.removeChild(iframe);
}
};

// 跨域请求b页面数据
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://x.stuq.com:7001/json', true)
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)//iframe 加载跨域页面

//message 事件监听
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://x.stuq.com:7001/json', true)
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 //设置为 true 则表示携带 cookie
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) {
//给响应头添加 Access-Control-Allow-origin 值
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>

//socket 连接 localhost:8080 起的服务器
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');
//启http服务
var server = http.createServer(function(req, res) {
res.writeHead(200, {
'Content-type': 'text/html'
});
res.end();
});

// localhost:8080 端起服务器
server.listen('8080');
console.log('Server is running at port 8080...');

// 监听socket连接
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)

代理是万能的,但是这部分没研究过,不太清楚细节怎么去做,暂略。

参考文档

Loading comments box needs to over the wall