聊聊「跨域请求」这个事

Posted by Yeoman on 2017-02-05

这篇文章的目的就是讲清楚跨域网络请求这件事情。

浏览器的同源策略

浏览器的同源策略是引发跨域问题的根本原因,同源策略限制从一个 加载的文档或脚本如何与来自另一个 的资源进行交互。
那么如何来判断两个URL是否是属于同一个 呢?
假设我们当前的URL为:http://blog.yangmingming.me/ ,对应下面URL的同源检测结果如下

URL 结果 原因
http://blog.yangmingming.me/index.html 成功 同源
https://blog.yangmingming.me 失败 协议不同(https)
http://blog.yangmingming.me:3000 失败 不同端口
http://about.yangmingming.me 失败 不同主机

搭建测试环境

为了方便进行测试各种跨域的方法,我新建了两个Express工程,直接在localhost下进行测试。
backend模仿后端环境,对应3000端口;front模仿前端环境,对应3001端口。

跨源网络请求(cross-origin HTTP request)

对于前端而言,讲到HTTP请求,就必须得提到 XMLHttpRequest 对象,但是现在是2017年了,还得提一提 Fetch 对象,作为XHR的替代方案,Fetch的API友好了很多。而不管是 XMLHttpRequest 还是 Fetch 请求都必须遵守同源策略。上代码:

1
2
3
4
5
6
7
8
9
10
11
function cross_origin_request(url) {
if(window.fetch) {
fetch(url).then(response => response.json())
.then(data => console.log(data))
.catch(e => console.log("error", e))
} else {
alert('大兄弟,该换个现代浏览器了~')
}
}
var backend_url = 'http://' + window.location.hostname + ':3000'
cross_origin_request(backend_url)

注:本文会用Fetch对象和ES6语法来进行测试演示。

会看到浏览器打印如下信息:

可以看到,在localhost:3001去请求localhost:3000出现了跨域错误,验证了上面的同源策略。接下来会同样用代码的形式验证不同方式去进行跨域网络请求。

JSONP(JSON with Padding)

JSONP(JSON with Padding)是数据格式JSON的一种“使用模式”,可以让网页从别的网域要数据。另一个解决这个问题的新方法是跨来源资源共享。
这是维基百科对于JSONP的解释,而JSONP确实是一种解决跨域请求的方式。JSONP的实现原理就是利用<script>标签引入其他源的脚本的时候是不存在跨域问题这个特性。有聪明的开发者想到,可以添加一个script标签,<script src=”yangmingming.me/users?callback=jsonp”>,通过url参数把回调函数名传递给后端,后端再响应一个JSON数据+回调函数,如:jsonp({name: yeoman})。这样前端就会回调这个函数,并且拿到后端传递的JSON数据。从而达到跨域请求的目的。上代码:
前端部分:添加一个script标签用来请求后端API,同时传递callback=jsonp参数。

1
2
3
4
5
6
7
8
9
10
11
12
function appendScript(src) {
var script = document.createElement('script');
script.setAttribute("type","text/javascript");
script.src = src;
document.body.appendChild(script);
}
appendScript('http://' + window.location.hostname + '/users?callback=jsonp');
function jsonp(data) {
console.log(data);
};

后端部分:响应请求,并返回参数。这里的res.jsonp是Express的API,可以看这里

1
2
3
app.use('/users', function(req, res, next) {
res.jsonp({name: 'yeoman'})
});

查看浏览器console窗口,信息如下:

呀比~成功跨域请求到了后端的response。

  1. 不难发现,JSONP这种跨域请求的方式其实就是一种hack手段。从它的原理看,JSONP也只能支持GET请求。
  2. 除了JSONP之外,还有一些比较hack的做法可以实现跨域,比如利用 document.domain或者是iframe配合window.name, location.hash等等,但这些方法都有很大的局限性,随着Web技术的发展和W3C标准的普及,会慢慢退出历史舞台。


CORS(Cross-Origin Resource Sharing)

跨域资源共享 可以说是目前来讲解决跨域问题的终极杀器了,因为这种方式一步到位,无副作用。更重要的是,你要问W3C对CORS支持不支持,他肯定支持啊!
CORS是把跨域问题放到服务端端去解决,由服务端端来决定是否允许本次请求。

client端的CORS部分由浏览器自动完成,当下主流浏览器都已支持CORS。

HTTP 请求头 和 HTTP 响应头 以及 「预请求」

跨域资源共享 新增了一系列HTTP头用来让客户端和服务端约定可以发送哪些请求。但是对于某些对服务器可能产生破坏性的请求,比如PUT和DELETE请求,CORS标准要求必须先发送一个预请求(OPTIONS请求),在服务器确认允许之后才发送真正的请求。
不符合以下条件均会发送OPTIONS请求:

  • 只使用 GET, HEAD 或者 POST 请求方法。如果使用 POST 向服务器端传送数据,则数据类型(Content-Type)只能是 application/x-www-form-urlencoded, multipart/form-data 或 text/plain中的一种。
  • 不会使用自定义请求头(类似于 X-Modified 这种)。

HTTP 请求头

Origin: <origin>
// 用来告诉服务器请求来自于哪里,这个字段浏览器会自动设置
Access-Control-Request-Method: <method>
// 用来告诉服务器使用的请求方式
Access-Control-Request-Headers: <field-name>[, <field-name>]*
// 一些自定义的头信息

HTTP 响应头

Access-Control-Allow-Origin: <origin> | *
// *代表允许所有请求,可以指定一个域名
Access-Control-Expose-Headers: X-My-Custom-Header, X-Another-Custom-Header
// 设置浏览器允许访问的服务器的头信息的白名单
Access-Control-Allow-Methods: <method>
// 设置可以被允许的请求方式
Access-Control-Allow-Headers
// 设置可以被允许的自定义请求头
Access-Control-Max-Age: <delta-seconds>
// 设置预请求的有效期
Access-Control-Allow-Credentials: true | false
// 当请求参数带上{credentials: 'include'}的时候,设置响应是否可以被得到

关键概念就到这,上代码:

服务端:

1
2
3
4
5
6
7
8
9
app.all('*',function (req, res, next) {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
if (req.method == 'OPTIONS') {
res.send('OPTIONS PASSED');
} else {
next();
}
});

用户端还是发送之前那个返回跨域错误的GET请求。返回200,成功返回~

同时可以看到在Response Headers里有在服务端设置的两个HTTP头字段,Access-Control-Allow-Methods:PUT, POST, GET, DELETE, OPTIONS,Access-Control-Allow-Origin:*

接着我们发一个PUT请求

前端:

1
cross_origin_request(backend_url, {method: 'PUT'})

同时服务端设置预请求有效期:

1
res.header('Access-Control-Max-Age', 3600)

可以看到确实先发送了OPTIONS请求并多了Access-Control-Max-Age字段:

但是我在测试的时候发现第二次请求的时候还是会先发送OPTIONS请求,难道Access-Control-Max-Age字段无效?Google之后,发现也有朋友遇到这个问题,是因为勾选了Chrome的Disable cache 选项。启动缓存,在一个小时之内就不会发送OPTIONS请求了~


postMessage

而除了CORS之外,还有一种安全的跨域通信做法,就是postMessage。postMessage是HTML5标准内的一个API。用法如下:

1
otherWindow.postMessage(message, targetOrigin, [transfer]);

这里的otherWindow可以是iframe的contentWindow,window.open()返回的window对象,或者是window.parent这个父窗口。

postMessage的通信借助于window对象,因此用iframe来演示,上代码:

front:

HTML

1
<iframe id="iframe_test" src="http://localhost:3000"></iframe>

Javascript

1
2
3
4
5
6
7
8
9
var iframe_test = document.getElementById('iframe_test')
var window_iframe = iframe_test.contentWindow
window_iframe.postMessage({ha:'如果你要一定要问我支持不支持'}, 'http://' + window.location.hostname + ':3000');
onmessage = (e) => {
e = e || event;
setTimeout(() => {
alert(e.data)
}, 2000)
};

backend:

1
2
3
4
5
6
7
8
9
10
<script type="text/javascript">
window.onload = () => {
onmessage = (e) => {
e = e || event;
document.write(JSON.stringify(e.data));
var window_parent = window.parent;
window_parent.postMessage('我肯定是支持的', 'http://' + window.location.hostname + ':3001')
};
}
</script>

可以看下如下的输出,成功跨域通信:


WebSocket

WebSocket一种在单个 TCP 连接上进行全双工通讯的协议。 –《维基百科》

WebSocket的出现背景是因为目前实现服务器和客户端推送服务的解决方案都存在问题,因此HTML5定义了WebSocket协议。

WebSocket使用和HTTP相同的TCP端口,也就是默认是80端口,加上TLS是在443端口。

值得一提的是,WebSocket并不遵守浏览器的同源策略!这里我们用 socket.io来进行测试。

front:
引入socket.io。

1
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/1.7.2/socket.io.js"></script>

往backend发送消息。

1
2
3
4
5
var socket = io('http://localhost:3000');
socket.on('news', function (data) {
console.log(data);
socket.emit('my other event', { my: 'data' });
});

backend:
安装socket.io。

1
npm install socket.io --save

链接server,发送消息:

1
2
3
4
5
6
7
8
9
var app = require('../app');
var server = http.createServer(app);
var io = require('socket.io')(server);
io.on('connection', function (socket) {
socket.emit('news', { hello: 'world' });
socket.on('my other event', function (data) {
console.log(data);
});
});

查看调试工具,看看发生了什么:

front成功打印Object {hello: "world"},backend成功打印{ my: 'data' },成功跨域通信!

但是!!!当我再查看Network的时候,如图:


这,这,这这这,这不还是发送了HTTP请求么,说好的WebSocket呢???

我猜测是 socket.io 为了提升响应速度,在WebSocket成功建立链接之前,会先发送HTTP请求。于是我加上延时调用:

1
2
3
setTimeout(() => {
socket.emit('my other event', { my:'data' });
}, 2000)

果然不再发送刚才的HTTP请求,而是如下:

wx://建立连接之后,会一直保持,之后的请求都是在这个连接中完成。

101状态码表示客户端要求服务器根据请求转换HTTP协议版本,这里也就是Upgrade:websocket,升级为WebSocket连接。


服务端代理

服务端代理同样可以解决跨域的问题,这种方式则是完全在服务端解决,与浏览器端无关。
也就是先请求相同域下的API容器,然后这个API容器作为代理去请求真正的服务器端再返回给客户端。

Tips

本文的Demo代码放在了Github上