前言
上接《再谈风骚的跨源/域方案(昔日篇)》,本篇聊聊现代标准(HTML5之后)的跨源方案。
基础概念都在昔日篇中,初学者请务必先看完昔日篇。
配套的演示案例传送门。
本人个人能力有限,欢迎批评指正。
PostMessage
该方案使用了 HTML5 新的 window.postMessage
接口,该方法是专门为不同源页面通信设计的,是一个经典的“订阅-通知”模型。
原理
该方案原理与昔日篇的“子域代理”很相似,都是主页面用 iframe 内非同源子页面作为代理去跟服务端交互获取数据。不同之处在于,“子域代理”需要通过修改 document.domain
使主页面获取子页面 document 操作权限,而 window.postMessage
已经原生提供了主页面与子页面通信的办法,故仅需要主页面通过 window.postMessage
向子页面下命令,子页面请求完成后再以此通知主页面即可实现跨源通信,换句话说子页面变成了一个类似转发服务的存在。
不需要修改 document.domain
也意味着摆脱了“子域代理”严格的域限制,可以更加自由的应用在第三方 API 上。 window.postMessage
是少有的不受同源限制的浏览器 API,准确来说是没有调用权限的限制而已,它对发送和接收的目标还是有严格限制的,这也是它安全性的体现。举个例子:
// 假设在 iframe 内页面进行订阅。
window.addEventListener('message', event => {
// 验证发送者,发送者不符合是可以不理会的。
if (event.origin !== 'http://demo.com') return
// 这就是发送过来的信息。
const data = event.data
// 这是发送者的 window 实例,可以调用上面的 postMessage 回传信息。
const source = event.source
})
// 主页面通知。
// 第二个参数是接收者的源,需要源完全匹配的页面才会接收到信息。(“源”的定义见昔日篇)
// 设置为 * 可以实现广播,不过一般不推荐。
iframe.contentWindow.postMessage('hello there!', 'http://demo.com')
流程
- API 所在的域部署一个代理页,设置好对 message 事件的监听,包含发送 Ajax 并将响应结果 postMessage 回主页面的功能;
- 主页面也设置对 message 事件的监听,并进行内容分发;
- 主页面新建 iframe 标签链接到代理页;
- 当 iframe 内的代理页就绪时,主页面就可以使用
iframe.contentWindow.postMessage
发送请求给代理页; - 代理页接收到请求,后发起 Ajax 到服务端 API;
- 服务端处理并响应,代理接收到响应后再通过
event.source.postMessage
传递给主页面。
错误处理
- 通过 iframe 的 load 事件可以检查代理页是否被加载(非同源需要 hack 方法),以此间接判断是否有网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;
- iframe 的 error 事件在大部分浏览器是无效的(默认),发送 Ajax 是在 iframe 中完成,如果发生错误只能通过 postMessage 转发给主页面,因此建议不要在 iframe 内处理错误,应统一交给主页面处理。
实践提示
前端
- 加载代理页是需要耗时的,因此要注意发起请求的时机,免在代理页还未加载完的时候请求;
- 并不需要每次请求都加载新的代理页,强烈建议只保留一个,多个请求共享;
- 如果遵从上一条的建议,还需考虑代理页加载失败的情况,避免一次失败后后续均不可以;
- 可以使用预加载的方式提前加载代理页,以免增加请求的时间;
- 无论是接收方还是发送方,都应该设置和验证 postMessage 的目标(targetOrigin),以确保安全性;
- 没必要每次请求都去监听 message 事件,可以在初始化时设置一个统一事件处理器进行内容分发,用一个对象将每次请求的回调保存起来,分配唯一的 id ,通过统一的事件处理器按 id 调用回调;
- 如果遵从上一条的建议,全局对象内回调函数需要及时清理。
服务端
- 代理页的域必须与 API 的域是一致的;
- 代理页一般无需经常更新,可以进行长期缓存;
- 代理页应尽量精简,Ajax 请求的结果无论成功或失败都应 postMessage 给主页面。
共享 iframe 的设计思路请参考昔日篇的“子域代理”。
前端“统一事件处理器”的设计思路:
function initMessageListener() {
// 保存回调对象的对象。
const cbStore = {}
// 设置监听,只需一个。
window.addEventListener('message', function (event) {
// 验证发送域。
if (event.origin !== targetOrigin) {
return
}
// ...
try {
// 运行失败分支。
if (...) {
cbStore[msgId].reject(new Error(...))
return
}
// 运行成功分支。
cbStore[msgId].resolve(...)
} finally {
// 执行清理。
delete cbStore[msgId]
}
})
// 这里形成了一个闭包,只能用特定方法操作 cbStore。
return {
// 设置回调对象的方法。
set: function (msgId, resolve, reject) {
// 回调对象包含成功和失败两个分支函数。
cbStore[msgId] = {
resolve,
reject
}
},
// 删除回调对象的方法。
del: function (msgId) {
delete cbStore[msgId]
}
}
}
// 初始化,每次请求都调用其 set 方法设置回调对象。
const messageListener = initMessageListener()
配合上面的“统一事件处理器”,msgId 其实没必要传递到服务端,在代理页处理即可:
window.addEventListener('message', event => {
// 验证发送域。
if (event.origin !== targetOrigin) {
return
}
// 这是主页面 postMessage 的数据。
// 其中 msgId 与“统一事件处理器”有关,其他参数与 Ajax 有关,按实际需要传递即可。
const { msgId, method, url, data } = event.data
// 发送 Ajax。
xhr(...).then(res => {
// 将 msgId 加入回传数据,其余保留原样。
res.response.data = {
...res.response.data,
msgId
}
// 回传给主页面。
event.source.postMessage(res, targetOrigin)
})
})
具体代码请参考演示案例 PostMessage 部分源码。
总结
优点
- 可以发送任意类型的请求;
- 可以使用标准的 API 规范;
- 能提供与正常 Ajax 请求无差别的体验;
- 错误捕获方便准确(除了 iframe 的网络错误);
- 对域无要求,可用于第三方 API。
缺点
- iframe 对浏览器性能影响较大;
- 实际测试, PostMessage 接口的转发有小延迟;
- 仅能用于现代浏览器。
CORS(跨源资源分享)
CORS 全称 Cross-origin resource sharing ,是 W3C 组织制订的标准跨源方案(传送门),也可以说是跨源的官方终极解决方案,它让现代的 web 开发方便不少。
原理
简单来说 CORS 是一套服务端与浏览器的协商机制,通过报文头实现,浏览器告知服务端来源(origin)和希望允许的方法,服务端返回“白名单”(也是一组报文头),浏览器依据“白名单”判断是否允许这次请求,可应用与 Ajax、canvas 等的跨源情况。
CORS 分为 简单请求(simple) 和 复杂请求(complex),他们最主要的区别就是需不需要预检(preflight)。
简单请求需要满足如下条件(只挑重点):
方法(method)为如下之一
- GET
- POST
- HEAD
只允许设置如下报文头(header)
- Accept
- Accept-Language
- Content-Language
Content-Type (只允许三个)
text/plain
multipart/form-data
application/x-www-form-urlencoded
- DPR
- Downlink
- Save-Data
- Viewport-Width
- Width
不满足上面条件的都会被判定为复杂请求,就实际使用而言 form 发出的请求基本都是允许的,如果要使用 json 格式传递数据(即 Content-Type: application/json
),那必定是复杂请求。
复杂请求会先发出预检请求,也就是先问问看服务端,如果返回的“白名单”符合要求再会发起正式的请求。
预检请求是方法(method)为 OPTION 的请求,它不需要携带任何业务数据,仅依照需要发送 CORS 相关请求报文头给服务端,服务端也不需要响应任何业务数据,仅返回“白名单”,完成协商即可。
CORS 相关请求报文头
- Origin:发起请求页面的源,由浏览器自动添加,不允许手动设置;
- Access-Control-Request-Method:希望服务端允许的方法,浏览器预检时依据正式请求的需要自动添加,不允许手动设置;
- Access-Control-Request-Headers:希望服务端允许的请求报文头,浏览器预检时依据正式请求的需要自动添加,不允许手动设置。
CORS 相关响应报文头(即“白名单”)
- Access-Control-Allow-Origin:允许访问该资源的域,这是开启 CORS 必定会返回的响应报文头,填写为 则表示允许来自所有域的请求,如果指定了非 的源,需要将源作为缓存判断依据,因此添加
Vary: Origin
以免当 API 给不同源页面返回不同数据时,被缓存搞混; - Access-Control-Expose-Headers:在跨域的情况下, XMLHttpRequest 对象的 getResponseHeader() 方法只能拿到一些最基本的响应头,如果要获取而外头部,需要进行指定;
- Access-Control-Max-Age:本次预检的最长有效期(秒),在这段时间内浏览器将不需要再次预检,而是直接发送正式请求;
- Access-Control-Allow-Credentials:是否允许携带 cookie ,默认为 false,当设置为 true 时,不允许 Access-Control-Allow-Origin 设为 * ;
- Access-Control-Allow-Methods:允许使用的请求方法;
- Access-Control-Allow-Headers:允许使用的请求报文头,常用于添加自定义报文头。
- Access-Control-Allow-Origin:允许访问该资源的域,这是开启 CORS 必定会返回的响应报文头,填写为 则表示允许来自所有域的请求,如果指定了非 的源,需要将源作为缓存判断依据,因此添加
流程
简单请求与一般的 Ajax 流程完全相同,仅需浏览器发送 Origin 请求报文头,服务端返回 Access-Control-Allow-Origin 响应报文头即可。
下面详讲复杂请求的情况。
假设现在网页源为 http://demo.com
,服务端 API 源为 http://api.demo.com
,需求请求的方法为 POST ,数据类型是 json,自定义报文头 token 。
- 浏览器检查到,将发起 Ajax 请求的 API 源与当前页面源不同,则进入 CORS 协商;
- 数据类型是 json,而已还要自定义报文头,判定本次是复杂请求;
发送预检 OPTION 请求,有关 CORS 的报文头设置如下:
- 读取当前页面的源写入
Origin: http://demo.com
; - 由于需要 POST 请求,则
Access-Control-Request-Method: POST
; - 由于需要数据类型是 json,也就是默认三种 content-type 不符合要求,还有自定义报文头 token 则
Access-Control-Request-Headers: content-type, token
;
- 读取当前页面的源写入
服务器接收到预检请求进行响应,有关 CORS 的报文头设置如下:
- 写入允许的域
Access-Control-Allow-Origin: http://demo.com
; - 写入允许的方法
Access-Control-Allow-Methods: POST, GET, OPTIONS
; - 写入允许的报文头
Access-Control-Allow-Headers: Content-Type, token
; - 写入
Vary: Origin
(上面有说明,它不属于 CORS 报文头,但必须)
- 写入允许的域
- 浏览器接收到响应,验证 CORS 响应报文头,验证通过则紧接着发送正式 POST 请求,仅需添加
Origin: http://demo.com
,其余与正常请求一致; - 服务器接收正式请求,处理后进行响应,仅需添加
Access-Control-Allow-Origin: http://demo.com
和Vary: Origin
,其余与正常响应一致; - 浏览器接收到响应,验证 CORS 响应报文头,验证通过则完成请求。
错误处理
- 服务器错误可以像一般请求那样捕获,获得准确的状态码;
- 当发生跨源相关的错误时,可在 XMLHttpRequest 对象的 error 事件捕获到;
跨源相关的错误总体分两类。
- 拦截响应的错误:比如简单请求的时候,接收到响应数据,但响应报文头验证未通过,这时候虽然从抓包上看已经完成请求,但浏览器依然会报错;
- 限制请求的错误:比如复杂请求的时候,预检返回的响应报文头验证未通过,则浏览器不会发起正式的请求,而是直接报错,这时候抓包是看不到正式请求的。
实践提示
前端
- 该方案对前端的影响是十分小的,几乎是浏览器自动完成,像一般请求那样发起即可;
- 错误处理部分有提到两类跨源相关的错误,这是在调试时需要注意的点。
服务端
不建议无脑添加 CORS 相关响应报文头,要按需添加,以免造成头部冗余,参考上面的流程,可以大致可分为两组。
- 简单请求头部:Access-Control-Allow-Origin 和 Vary 两个即可;
- 预检请求头部:按需选择 CORS 的头部,外加 Vary。
- Access-Control-Max-Age 是一个有效的优化手段,它可以减少频繁的预检请求,节约资源。
- 除非是公共的第三方 API,不建议将 Access-Control-Allow-Origin 设为 * 号。
- 为了安全性,最好验证 Origin 请求报文头,而不是忽略它,当不符合要求时,可以返回 403 状态码。
具体代码请参考演示案例 CORS 部分源码。
总结
优点
- 可以发送任意类型的请求;
- 可以使用标准的 API 规范;
- 能提供与正常 Ajax 请求无差别的体验;
- 错误捕获方便准确;
- 对域无要求,可用于第三方 API。
缺点
- 仅能用于现代浏览器。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。