2
头图

前言

上接《再谈风骚的跨源/域方案(昔日篇)》,本篇聊聊现代标准(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')

流程

  1. API 所在的域部署一个代理页,设置好对 message 事件的监听,包含发送 Ajax 并将响应结果 postMessage 回主页面的功能;
  2. 主页面也设置对 message 事件的监听,并进行内容分发;
  3. 主页面新建 iframe 标签链接到代理页;
  4. 当 iframe 内的代理页就绪时,主页面就可以使用 iframe.contentWindow.postMessage 发送请求给代理页;
  5. 代理页接收到请求,后发起 Ajax 到服务端 API;
  6. 服务端处理并响应,代理接收到响应后再通过 event.source.postMessage 传递给主页面。

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:允许使用的请求报文头,常用于添加自定义报文头。

流程

简单请求与一般的 Ajax 流程完全相同,仅需浏览器发送 Origin 请求报文头,服务端返回 Access-Control-Allow-Origin 响应报文头即可。
下面详讲复杂请求的情况。
假设现在网页源为 http://demo.com ,服务端 API 源为 http://api.demo.com ,需求请求的方法为 POST ,数据类型是 json,自定义报文头 token 。

  1. 浏览器检查到,将发起 Ajax 请求的 API 源与当前页面源不同,则进入 CORS 协商;
  2. 数据类型是 json,而已还要自定义报文头,判定本次是复杂请求;
  3. 发送预检 OPTION 请求,有关 CORS 的报文头设置如下:

    • 读取当前页面的源写入 Origin: http://demo.com
    • 由于需要 POST 请求,则 Access-Control-Request-Method: POST
    • 由于需要数据类型是 json,也就是默认三种 content-type 不符合要求,还有自定义报文头 token 则 Access-Control-Request-Headers: content-type, token
  4. 服务器接收到预检请求进行响应,有关 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 报文头,但必须)
  5. 浏览器接收到响应,验证 CORS 响应报文头,验证通过则紧接着发送正式 POST 请求,仅需添加 Origin: http://demo.com ,其余与正常请求一致;
  6. 服务器接收正式请求,处理后进行响应,仅需添加 Access-Control-Allow-Origin: http://demo.comVary: Origin ,其余与正常响应一致;
  7. 浏览器接收到响应,验证 CORS 响应报文头,验证通过则完成请求。

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。
  • 缺点

    • 仅能用于现代浏览器。

calimanco
1.4k 声望766 粉丝

老朽对真理的追求从北爱尔兰到契丹无人不知无人不晓。