跨域问题一直是困扰很多人的一个现实,我其实对这方面的很多细节也不了解,很多具体的实现仍然停留在前端的范畴内,至于后端的同学到底怎么配置的,一无所知。不得不说即便是前端开发,我觉得还是一样需要有那么一段时间接触一下后端实践,比如抽个一年半年的时间从事全职的后端开发,这对整个 Web 开发的体系会有一个更深层次的理解和延展。

但是事实也是,我毕竟还是致力于成为专业的前端开发,从前端的角度入手理解一下跨域的问题也是极好的。

起航

第一次接触跨域是从事全职前端的第二个月,当时一个客户希望改造他们的网店系统,新增一个销售员分级系统。后端直接采用 Node,前端则还是基于原先的店铺进行二次开发,那么数据并不在原先的店铺域名下,因此需要跨域抓取数据,由于只是展示的目的,因此采用了最兼容的 JSONP

幼稚的我当时其实压根没想过 JSONP 后面的实现基础和原理,我只知道在当时的场景中,跨域非常好用,jQuery 封装好了简便的 API,我只需要调用就好了,反正就是把 JSON 包装起来传来传去罢了。

事实上,JSONPJSON 没有半毛钱关系,这让我想起一个比喻 —— JavaScript 是 Java 的蠢弟弟。望文生义往往是我致命的一个弱点,任何技术都是,在没有认真尝试和理解前,我都觉得要保持一种敬畏的态度。

JSONP 系列

跨域的第一个,也是最常用的,就是 JSONP 所采用的跨域原理,这里不只是 JSONP,而是一系列的跨域解决方案都是基于一个浏览器的“恩惠”:我允许你随便引进来某几种东西

我清楚地记得,在学习 Bootstrap 的时候,其中的 demo 代码我是可以直接复制到 HTML 中然后打开就可以复现了,我并没有下载他们的脚本和样式文件,因为每一个学习前端的人都知道,浏览器自己会跑到 Bootstrap 的网站引用这些(当然,现在我们知道这是 CDN,有其特殊的用处)—— 我很久以前,大学的时候就知道只要在网页中插入引用路径,不管是 JS,CSS,还是图片

因此可以给一个定义,凡是带有 src 这个属性的标签,其实都是不受同源策略的限制的

  • <script src="..."></script> 外部 JS 引用
  • <img src=“...”>,可以是 PNG,JPEG,GIF,BMP,SVG 等
  • <iframe> 页面嵌套
  • <link rel="stylesheet" href="..."> 外部 CSS 引用
  • <video><audio> 嵌入多媒体资源
  • <object>, <embed><applet>的插件
  • @font-face 一些浏览器允许跨域引入字体

基于这类的跨域原理,一般我们只考虑用前三个(<script<img><iframe)实现跨域技术:因为这三个可以兼容老旧的浏览器,至于现代浏览器么,交给 CORS 是更明智的选择。

基于 CSS 的跨域技术叫 CSST(CSS Text Transformation),与其他跨域技术一样对低级浏览器兼容不好(需要支持 CSS3)

最兼容最常用的 JSONP

JSONP 是利用资源跨域允许的最常见技术,我们知道,在 HTML 中,脚本的执行一般是顺序的,也就是说后面 <script> 标签一般是在前面 <script> 标签执行完后再执行的,同时这些脚本间共享全局变量,比如

1
2
3
4
5
6
7
8
<!-- 第一个脚本 -->
<script>
var name = 'hyifu'
</script>
<!-- 第二个脚本 -->
<script>
console.log(name) // 'hyifu'
</script>

第二个脚本可以获取到第一个脚本中的全局变量 name 并打印出它的值,就基于以上的规则,假如把第二个脚本换成外部脚本(里面的内容一致),那么实际上是会产生同样效果的。别忘了,外部脚本是不受同源限制的,这就是 JSONP 跨域的基本

那么具体是怎样实现的呢?考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 第一个外部脚本来自当前域名 www.srcA.com
function jsonp (callbackData) {
console.log(callbackData.name)
}
// 第二个外部脚本来自其他域名 www.srcB.com
var data = {
name: 'hyifu',
age: 27
job: 'FrontEnd Engineer'
}
jsonp(data)
// 毫无疑问,这里会得到 ‘hyifu’

我们在第一个脚本中定义了一个函数,这个函数接受一个参数,调用这个函数会打印出参数中的一个属性值,我们在第二个脚本中直接传参调用了这个函数,于是打印出了我的名字缩写。不知道有没有注意到一个问题,第二个外部脚本是后面请求的,如果我们不是一开始就写好了这个脚本,它又是怎样知道这个函数名并且知道它需要怎样的参数呢?

继续往下挖掘,我们使用跨域技术,其实是希望能跟普通的 Ajax 请求一样,请求一个特定的网址并附上参数,就可以从服务器那边拿到想要的数据,因此 srcB 的服务器根本就不知道你想要执行的函数是什么。于是一个就需要一个通用的约定:请求脚本的时候,url 上带上参数,这个参数告诉了服务器 srcA 这个脚本定义的函数名字是什么,需要的数据又是什么,srcB 的服务器根据这些来动态生成脚本并返回。于是按照约定出现了以下代码:

1
2
3
4
// 域名 srcA 的脚本
var srcB = document.createElement('script')
srcB.src = 'www.srcB.com/script?callback=jsonp&id=2017'
document.head.appendChild(srcB)

这里动态生成一个 <script> 标签并插入到了页面 <head> 中,页面会立即向服务器请求这个脚本,注意到请求 url 中到这些参数:?callback=jsonp&id=2017callback=jsonp 告诉服务器你生成到代码要把参数传到这个函数中调用,&id=2017 则告诉服务器需要作为参数传入到数据是 id2017 的用户的资料,于是服务器动态生成了需要的脚本响应了浏览器的请求,于是 srcA 就打印出来了这个用户的资料!这就是 JSONP 的全部细节。

实际上,插入后的脚本在代码执行后一般就再也没有用了,但它在 DOM 中依然可以被查看到,像 jQuery 这种类库会帮您在回调执行完以后删除这个标签
同时,如果基于团队开发,一个页面中可能用到非常多但 JSONP 请求,很难避免两个人同时使用一个回调的函数名称(但有时简便的实现是也可能后台写死了),这会导致冲突,因此类似 jQuery 这种类库会生成一个足够随机的回调函数名来实现,总之,如果要兼容老旧的浏览器,加入 jQuery 确实少了很多麻烦

上面就是 JSONP 的技术实现和原理,其优点是非常简单易用,几乎兼容所有的浏览器实现跨域的双向信息交互
但是缺点也是明显的,通过请求脚本的方式注定了其只能支持 GET 请求,同时如果无法确定请求服务器是否安全的,在响应中有可能夹带恶意代码(XSS 攻击),最后是,无法判断请求是否失败(只能通过设置定时器来判断响应是否超时)

广告商的最爱 Image Ping

“图像Ping”,其实每天都活跃在我们的身边,每天我们打开的网页,有 90% 以上都使用到了这个技术
跟互联网的几乎所有的广告流量跟踪一样,我们在店铺的流量跟踪脚本 tracker.js 中也是利用了图片的跨域访问原理

广告是网站盈利最重要的一个地方,那么就需要监听广告到底有多少个人看,于是就需要有一个服务器来统计这些流量来源

假如你打开一个网页,里面有这么一段代码:

1
<img style=“display:none” src="www.trace.com/image_ping?name=hyf">

可以预见得到,你会向服务器 www.trace.com 发起一个图片的请求,这个请求 url 的参数传递了我的名字缩写
实际上,更常用更灵活的方法是使用脚本来进行这个操作,我们知道,在脚本中请求图片,即使并没有插入到 DOM 中(实际上使我们的 HTML 代码更干净),依然会触发网络请求,为后续可能的插入做好缓存。同时脚本中我们可以进行更多的操作(如服务器未响应可以考虑重新请求)

1
2
3
4
5
6
7
8
var img = new Image()
img.onload = function () {
// 如果服务器成功返回,则进入这里
}
img.onerror = function () {
// 请求失败,考虑重新再次请求
}
img.src = "www.trace.com/image_ping?name=hyf"

在单页 APP 中,这也常用来跟踪用户到底访问了哪个页面,每切换一次路由,就请求一次图像 Ping,参数附上当前所在页面

在这里需要注意一下服务器常见的反应:服务器会接受到完整的请求,因此就获得来需要的查询信息(在这里是我的名字缩写,但其实可以用脚本传递几乎所有字符串,因此可能存在安全问题),一般服务器接到请求后会返回一个(1px x 1px)的透明图片,或者只返回 204 状态码

图像Ping 和 JSONP 一样都只能发送 GET 请求,而且只能单向通讯,只有服务器可以拿到浏览器给的数据

iframe 系列

前面提到了 iframe 也可以跨域,但是并没有把它归并到 JSONP 一起讲解,这是因为 iframe 本质上并不能算是一种资源,而且其跨域的手段跟前面的资源类跨域实现也有很大区别。

跨文档消息传送(cross-document messaging),简称 XDM,主要是指一个页面通过某种技术手段与另外一个页面进行数据交互,一般说的这两个页面是跨域的,举例说,主页面需要向内嵌的 iframe 或者弹出的窗口传递信息
其技术基础一般有:

  • document.domain
  • location.hash
  • window.name
  • postMessage()

我听说古老的 IE6 的存在一个 bug,父页面和子页面都可以访问 window.navigator 这个对象,通过在这个对象上添加属性或方法,就可以达到共享数据啦(然并卵,谁会这么做呢?)

其中,前三种都是基于 <iframe 标签进行的兼容性实现,而 postMessage() 实际上是 XDM 的规范化实现

document.domain

比较常用的跨域方法,但限制是,只能用于同一个主域下不同子域之间,比如 foo.comimg.foo.com,或者 img.bar.commp3.bar.com,但是如果用在 foo.combaz.com 之间则会失败
下面的代码清晰地展示了这个机制的应用:

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
<!-- foo.com -->
<iframe id="iframe" src="http://img.foo.com"></iframe>
<script>
// 指向主域名
document.domain = 'foo.com'
// 定义于父级文档的函数 fnSuper
function fnSuper(str) {
console.log('父函数说:' + str)
}
// 当 `iframe` 完成加载时尝试调用子文档定义的函数 fnSub
window.onload = function () {
var iframe = document.getElementById('iframe')
iframe.contentWindow.fnSub('父级') // '子函数说:父级'
}
</script>
<!-- img.foo.com -->
<script>
// 指向主域名
document.domain = 'foo.com'
// 定义子文档的一个函数 fnSub
function fnSub(str) {
console.log('子函数说:' + str)
}
parent.fnSuper('子级') // '父函数说:子级'
</script>

location.hash

这个方法是利用了同域名的子 iframe 是可以通过 top 或者 parents 来拿到父框架的引用的原理(及作为通讯的中转,hash 一般是在中转页面中处理)

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
<!-- foo.com -->
<iframe id="iframeFoo" src="http://bar.com"></iframe>
<script>
function callback (data) {
console.log(data)
}
</script>
<!-- bar.com -->
<script>
window.onload = function() {
var bus = document.createElement('iframe')
var name = '我来自域名 Bar'
// 打开 foo 域名下的一个中转页面,其路径 `hash` 上放入要传递的数据
bus.src = 'http://foo.com/bus.html#' + name
document.body.appendChild(iframe)
}
</script>
<!-- foo.com/bus.html -->
<script>
var data = JSON.parse(location.hash.substr(1)) // 处理数据,得到 ‘我来自域名 Bar’
// top 指向父页面 foo.com,因此可以调用 foo 中定义的 callback 函数(也可以手动 parent 指定具体的父页面)
top.callback(data) // 最终数据作为参数传入了指定的函数并调用
</script>

这个方法实现起来相对繁琐,同时跨域的数据都需要暴露在 URL 中,由于 URL 的长度一般有所限制,导致传送的数据量也相对有限(只能传可 JSON 化的数据)

window.name

在同一个浏览器中(同一个窗口同一个标签页),window.name 可以在不同页面加载后依然保持,举例说:打开了 www.baidu.com这个页面,在控制台中输入 window.name = 'test',然后在地址栏输入 www.qq.com,然后再次查看 window.name 会发现其值依然保持为 test,利用这种特性就可以在嵌套的页面中进行数据传送


写作这篇时,我的 Safari 浏览器测试不到这个实现,建议放弃使用这个方法,因此也不过多进行赘述

postMessage()

前面所说的页面间的通讯方法,实现起来既麻烦又容易出错,还有各种限制,因此如果不需要兼容 IE8 以下的浏览器,我们迎来了一个归一的替换方法:postMessage(message, url),这个方法第一个参数是希望传递的数据,第二个参数则一般是一个 url,指定接受数据的源,只有源匹配才能成功通讯(可以指定为 * 允许所有源)

以上是发送信息,接受信息是以异步事件的形式完成的,以下是一个简单的应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- foo.html -->
<iframe id="myframe" src="bar.html"></iframe>
<button type="button" name="button">Click</button>
<script>
var iframeWindow = document.getElementById('myframe').contentWindow
document.getElementsByTagName('button')[0].addEventListener('click', function () {
iframeWindow.postMessage('A Click', '*')
})
</script>
<!-- bar.html -->
<script>
window.addEventListener('message', function (event) {
console.log(event.data) // 传递的数据
console.log(event.source) // 发送消息的窗口对象
console.log(event.origin) // 发送消息窗口的源(协议+主机+端口号)
})
</script>

点击父框架 foo 的按钮会向子框架传递一个字符串,子框架通过监听事件的方式可以接收到这个不限制接收源的信息

可以看到,相比于前面定义的其他框架间跨域通讯方法,postMessage 方法的使用清晰了很多,跟我们定义一个 DOM 事件并无太大的区别,而且本方法兼容 IE8,可以说在现今这个甚至可以抛弃 IE8 的大环境下,框架间的通讯完全可以只考虑 postMessage

CORS 系列

总算进入现代浏览器的范畴了,经历了前面那么多曲折的 Ajax 跨域实现,我们是多么希望这个同源策略可以在安全的范围内有所打破!

CORS 是一个 W3C 标准,全称是”跨域资源共享”(Cross-Origin Resource Sharing),主要针对 Ajax 跨域,需要浏览器和服务器同时支持。一般而言,CORS 与同源的 Ajax 在前端开发者和用户体验上并没有什么区别,支持的浏览器会自动在请求头部加上特殊的头信息,因此,该技术在开发中关键在意服务器的配置(当然,一般只需要注意 IE8 和 IE9 并没有原生实现,需要通过兼容的版本 XDR),根据不同的请求在服务器中设置 Access-Control- Allow-Origin 并予以回应

对于本技术原理的详尽解析,建议参考阮一峰的博客:跨域资源共享 CORS 详解,这里我只讲解浏览器中的具体实现和兼容

  • 原生的 CORS 标准实现中,在前端代码层面上跟写普通的 XHR 没有任何区别,不过考虑安全,有一些限制

    • 不能使用 setRequestHeader() 自定义请求头部
    • 不会携带 cookie
    • 调用 getAllResponseHeaders() 总是返回空字符串(无效化)
  • IE 中的实现,同样也有一些限制

    • 不会携带 cookie
    • 只能设置头部的 Content-Type
    • 不能访问响应的头部
    • 只支持 GETPOST

XDR 也跟 XHR 很类似,如下:

1
2
3
4
5
6
7
var xdr = new XDomainRequest() // 创建独有的 XDomainRequest 对象
xdr.onload = function(){
// 这里只能访问响应的原始文本,而没发读取到响应状态
alert(xdr.responseText)
}
xdr.open('get', 'http://www.somewhere-else.com')
xdr.send(null)

其他思路

前面介绍了很多跨域的实现,基本上都是假定浏览器会遵循同源策略,所以是以绕过同源限制的方法去考虑一些问题。但我有时想,能不能打破这个限制呢?其实是有的,还很多

你一定觉得很搞笑,同源策略是浏览器最基本的安全实现,怎么可能不遵守呢?

没错,浏览器一定会遵守同源策略,但是我们可以给浏览器一个“代理”,这个代理并不是浏览器,它可能就没有同源限制。
实际上,最简单的跨域实现是,让你的同源服务器代你跨域拿取数据!我们一直忽略了这一点:跟自己的服务器之间一直是同源的。

基于这个思路,其实就有很多这样的代理实现:臭名昭著的 Flash,各种浏览器控件,浏览器给了它们信任的接口,但它们自己却没有同源策略的限制。方法虽好,但随之带来的是安全性的下降,用户体验的缺失(浏览你的网站还需要特定的插件,想想就不好了),因此这些方法也即将随着现代浏览器的快速更新和普及而消亡……