本文是笔者于 2008 年写的《当年那些风骚的跨域操作》的重制威力加强版。
古云温故而知新,重看当年的文章,还是感觉有颇多不足和疏漏,思考深度也还欠缺,故进行重制。
本人个人能力有限,欢迎批评指正。
开头先来个一鞠躬。
吐槽一下翻译,浏览各类 Wiki 和 RFC 里英文名都是“cross-origin”,origin 应该翻译成“源”、“来源”,合起来准确的翻译是“跨源”。但不知怎么搞的,在中文区以讹传讹地变成了“跨域”(cross-domain),这也造成了很大的误解,下面有讲到 domian 和 origin 的关系,这糟心的翻译让多少萌新混淆了这两个概念(包括我)。
因此,本文只会用”跨源“这个准确翻译,也为自己以前文章的错误致歉。
本次重制最重磅的一点是笔者实现了一套完整的演示案例,前后端代码都有,前端无第三方依赖,服务端基于 Express,源码细节一览无余,理论和实践完美结合。可在本地演示下述的所有跨源方案,有 NodeJS 环境就能玩,无需复杂配置、编译和容器。 传送门
首页截图:
1995年,同源策略由 Netscape 公司引入浏览器。目前,所有浏览器都实行这个安全策略。
本文要讲的“跨源”,正是要在确保安全的前提下绕过这个策略的限制。
同源策略的目的是确保不同源提供的文件(资源)之间是相互独立的,类似于沙盒的概念。换句话说,只有当不同的文件脚本是由相同的源提供时才没有限制。限制可以细分为两个方面:
对象访问限制
主要体现在 iframe,如果父子页面属于不同的源,那将有下面的限制:
document.cookie
不可能访问。对于 BOM 只能有少量权限,也就是说可以互相取得 window 对象,但全部方法和大部分属性都无法用(比如 window.localStorage
、window.name
等),只有少量属性可以有限访问,比如下面两种:
window.length
。window.location.href
。网络访问限制
主要体现在 Ajax 请求,如果发起的请求目标源与当前页面的源不同,浏览器就会有下面的限制:
origin 在 Web 领域是有严格定义的,包含三个部分:协议、域和端口。
origin = scheme + domain + port
也就是说这三者都完全相同,才能叫同源。
举个例子,假设现在有一个源为 http://example.com
的页面,向如下源发起请求,结果如下:
origin(URL) | result | reason |
---|---|---|
http://example.com | success | 协议、域和端口号均相同(浏览器默认 80 端口) |
http://example.com:8080 | fail | 端口不同 |
https://example.com | fail | 协议不同 |
http://sub.example.com | fail | 域名不同 |
同源策略提出的时代还是传统 MVC 架构(jsp,asp)盛行的年代,那时候的页面靠服务器渲染完成了大部分填充,开发者也不会维护独立的 API 服务,所以其实跨源的需求是比较少的。
新时代前后端的分离和第三方 JSSDK 的兴起,我们才开始发现这个策略虽然大大提高了浏览器的安全性,但有时很不方便,合理的用途也受到影响。比如:
于是乎,如何解决这些问题的跨源方案就被纷纷提出,可谓百家争鸣,其中不乏令人惊叹的骚操作,虽然现在已有标准的 CORS 方案,但对于深入理解浏览器与服务器的交互还是值得学习的。
JSON-P 是各类跨源方案中流行度较高的一个,现在在某些要兼容旧浏览器的环境下还会被使用,著名的 jQuery 也封装其方法。
请勿见名知义,名字中的 P 是 padding,“填充”的意思,这个方法在通信过程中使用的并不是普通的 json 格式文本,而是“自带填充功能的 JavaScript 脚本”。
如何理解“自带填充功能的 JavaScript 脚本”?看看下面的例子。
假设全局(Window)上有这个 getAnimal 函数,然后通过 script 标签的方式,引入一个调用该函数并传入数据的脚本,就可以实现跨源通信。
// 全局上有这个函数
function getAnimal(data){
// 取得数据
var animal = data.name
// do someting
}
另一个脚本:
// 调用函数
getAnimal({
name: 'cat'
})
也就是说利用浏览器引入 JavaScript 脚本时会自动运行的特点,就可以用来给全局函数传递数据。如果把这段调用函数的脚本作为服务端 API 的输出,就可以以此实现跨源通信。这就是 JSON-P 方法的核心原理,它填充的是全局函数的数据。
【PS】不只是 script 标签,所有可以使用 src 属性的标签都可以不受同源策略限制发起 GET 请求(CSP 未配置的情况),比如 img、object 等,但能自动运行 js 代码的只有 script 标签。
前端
服务端
前端“一个对象保存所有回调函数”的设计思路:
function initJSONPCallback() {
// 保存回调对象的对象
const cbStore = {}
// 这里形成了一个闭包,只能用特定方法操作 cbStore。
return {
// 统一执行器(函数)。
run: function (statusCode, data) {
const { callbackId, msg } = data
try {
// 运行失败分支。
if (...) {
cbStore[callbackId].reject(new Error(...))
return
}
// 运行成功分支。
cbStore[callbackId].resolve(...)
} finally {
// 执行清理。
delete cbStore[callbackId]
}
},
// 设置回调对象,发起请求时调用。
set: function (callbackId, resolve, reject) {
// 回调对象包含成功和失败两个分支函数。
cbStore[callbackId] = {
resolve,
reject
}
},
// 删除回调对象,清理时调用。
del: function (callbackId) {
delete cbStore[callbackId]
}
}
}
// 初始化
const JSONPCallback = initJSONPCallback()
// 全局暴露执行器,这也是 API 返回脚本调用的函数。
window.JSONPCb = JSONPCallback.run
具体代码请参考演示案例 JSONP 部分源码。
优点
缺点
子域名代理在特定的环境条件下是很实用跨源方案,它能提供与正常 Ajax 请求无差别的体验。
先搞清楚何为子域(domain)?域名的解析是从右往左的,我们去申请域名,就是申请最靠右的两段(以点为分段),而之后的部分是可以给所有者自定义的,你想多加几段都可以,这些衍生的域就是子域。举个例子,api.demo.com
就是 demo.com
的子域。
理论上例子里的两个算是不同的域,依照上面提到的 domain 是 origin 的一部分,因此也算是不同的源,但浏览器允许将页面 document 的域改为当前域的父级,也就是在 api.demo.com
的页面运行如下代码就可以改为 demo.com
,但这种修改只对 document 的权限有影响,对 Ajax 是无影响的。
// 在 api.demo.com 页面写如下代码
document.domain = 'demo.com'
【PS】document.domain
的特点:只能设置一次;只能更改域部分,不能修改页面的端口号和协议;会重置当前页面的端口为协议默认端口(即 80 或 433);仅对 document 起作用,不影响其他对象的同源策略。
因此,该方案的原理就是通过这种方法使父级页面拥有子域页面 document 的访问权限,子域恰好又是 API 的域,进而通过子域页面代理发起请求,实现跨源通信。
假设服务端 API 的域为 api.demo.com
,页面域为 demo.com
,共同运行在 http 协议,端口为 80。
demo.com
,并可以包含发起 Ajax 的工具(jQuery、Axios等);demo.com
;iframe.contentWindow
取得代理页的控制权,使用其发起 Ajax 请求。前端
document.domain
设置,即是当前域已经满足要求,也就是说当前页面虽然已经域是 xxx
,但还是得调用一遍 document.domain='xxx'
。服务端
document.domain=xxx
的 HTML 格式文件即可,因此可以尽量精简。共享 iframe 的设计思路:
// 将创建 iframe 用 promise 封装,并保存起来。
let initSubHostProxyPromise = null
// 每次请求之前都应先调用这个函数。
function initSubHostProxy() {
if (initSubHostProxyPromise != null) {
// 如果 promise 已经存在,则直接返回,由于这个 promise 已经 resolve,其实就相当于返回了已有的 iframe。
return initSubHostProxyPromise
}
// 没有则重新创建。
initSubHostProxyPromise = new Promise((resolve, reject) => {
const iframe = document.createElement('iframe')
// 填入代理页地址。
iframe.src = '...'
iframe.onload = function (event) {
// 这是一种 hack 的检测错误的方法,见演示案例 README 。
if (event.target.contentWindow.length === 0) {
// 失败分支
reject(new Error(...))
setTimeout(() => {
// 清理掉失败的 promise,这样下次就会重新创建。
initSubHostProxyPromise = null
// 这里还需移除 iframe。
document.body.removeChild(iframe)
})
return
}
// 成功分支,返回 iframe DOM 对象。
resolve(iframe)
}
document.body.appendChild(iframe)
})
return initSubHostProxyPromise
}
具体代码请参考演示案例 SubHostProxy 部分源码。
优点
缺点
网上一般称这种方案是“模拟表单”,但我觉得并不准确,使用表单发起请求并不是它的核心特征(后面也还有几种方案用到),它的核心应该是“自填充HTML”。
我将它称为 HTML-P 是借鉴了 JSON-P 的叫法,它的思路也与 JSON-P 方案很像,服务端 API 返回一个 js 脚本可以自动运行进行数据填充,那直接返回整个 HTML 页面不也可以。
但实际上 HTML 要实现数据填充还是有限制的,首先就是同源限制,父子页面如果不同源,就无法互相访问,解决办法自然是“子域代理”里提到的 document.domain
修改大法,但它的目的恰好与“子域代理”相反,通过修改 document 的域,使子页面获取主页面的访问权限,以此对主页面的数据填充,实现跨源通信。
// API 返回包含如下脚本的 HTML ,就可访问父级页面的全局函数进行数据填充。
document.domain = 'xxx'
window.parent.callbackFunction(...)
至于表单的作用,其实是利用了表单的 target 的属性,当表单 submit 的时候它会使指定 name 的 iframe 进行跳转,跳转其实就是发起请求,因此浏览器表单组件原生支持的请求方法都可以使用,也正因为使用了表单发起请求,服务端 API 必须返回一个 HTML 格式的文本。
假设服务端 API 的域为 api.demo.com
,页面域为 demo.com
,共同运行在 http 协议,端口为 80。
demo.com
;demo.com
;前端
document.domain
设置,即是当前域已经满足要求。服务端
具体代码请参考演示案例 MockForm 部分源码。
该方案可以说是“JSON-P”与“子域代理”的缝合版,优缺点均有继承。
优点
缺点
这是一个以 window.name 特性为核心的方案。
这方案利用了 window.name 的特性:一旦被赋值后,当窗口(iframe)被重定向到一个新的 url 时不会改变它的值。虽然 window.name 依然遵循同源策略,只有同源才能读取到值,但我们只要在非同源页面写入值,再重定向到同源页面读取值即可实现跨源通信。
发起请求的方法与“HTML-P”相同,都是用 form 触发 iframe 跳转实现。
// 通过 iframe 的 load 事件取得 window.name 的值。
iframe.onload = function (event) {
const res = event.target.contentWindow.name
}
前端
window.location.replace
,这样才不会产生 history ,会影响主页面的后退操作;服务端
具体代码请参考演示案例 WindowName 部分源码。
优点
缺点
这是一个以 url 上 hash 部分为核心的方案。
这个方案利用了 window.location.hash
的特性:不同域的页面,可以写不可读。而只改变哈希部分(井号后面)不会导致页面跳转。也就是可以让非同源的子页面写主页面 url 的 hash 部分,主页面通过监听 hash 变化,实现跨源通信。
发起请求的方法与“HTML-P”相同,都是用 form 触发 iframe 跳转实现。
// 现代浏览器有 hashchange 事件可以监听。
window.addEventListener('hashchange', function () {
// 读取 hash
const hash = window.location.hash
// 清理 hash
if (hash && hash !== '#') {
location.replace(url + '#')
} else {
return
}
})
// 降级方案,循环读取 hash 进行“监听”。
var listener = function(){
// 读取 hash
var hash = window.location.hash
// 清理 hash
if (hash && hash !== '#') {
location.replace(url + '#')
}
// 继续监听
setTimeout(listener, 100)
}
listener()
前端
window.location.replace
,这样才不会产生 history ,会影响主页面的后退操作;服务端
前端“统一事件处理器”的设计思路:
function initHashListener() {
// 保存回调对象的对象
const cbStore = {}
// 设置监听,只需一个。
window.addEventListener('hashchange', function () {
// 处理 hash。
...
try {
// 运行失败分支。
if (...) {
cbStore[callbackId].reject(new Error(...))
return
}
// 运行成功分支。
cbStore[callbackId].resolve(...)
} finally {
// 执行清理。
delete cbStore[callbackId]
}
})
// 这里形成了一个闭包,只能用特定方法操作 cbStore。
return {
// 设置回调对象的方法。
set: function (callbackId, resolve, reject) {
// 回调对象包含成功和失败两个分支函数。
cbStore[callbackId] = {
resolve,
reject
}
},
// 删除回调对象的方法。
del: function (callbackId) {
delete cbStore[callbackId]
}
}
}
// 初始化,每次请求都调用其 set 方法设置回调对象。
const hashListener = initHashListener()
具体代码请参考演示案例 WindowHash 部分源码。
优点
缺点
before/after伪类相当于在元素内部插入两个额外的标签,其最适合也是最推荐的应...
作者: Ahmad Shadeed 译者:前端小智 来源:ishadeed 有梦想,有干货,微信搜索...
前言 一探浏览器幕后的《三俩事》 上一篇的介绍让大家对浏览器的组成有了个模糊...
在修改一个网页时,会遇到要更换所有网页超链接网址的问题,有时候一个页面的超...
内容一览:随着公民法制意识的觉醒,越来越多的人选择拿起法律的武器捍卫自己的...
1 背景 之前在专栏中讲过“不推荐使用属性拷贝工具”,推荐直接定义转换类和方法...
通过前面两篇文章的简单介绍,大致对于Bootstrap有了初步的了解。由于自己也只是...
Redis主从复制的问题 Redis 主从复制可将主节点数据同步给从节点,从节点此时有...
我们都知道,前端开发里面的CSS中常用的定位方式有 普通定位,相对定位,绝对定...
!DOCTYPE htmlhtml lang=enhead meta charset=UTF-8 titleaudio/title/headbodya...