常见跨域通讯技术总结
背景
基于安全性的考虑,浏览器会遵守一个叫做同源策略的安全策略,默认情况下,XmlHttpRequest 对象只能访问其所在页面同一个域 (域名、协议、端口均相同) 的资源。然而,许多应用程序开发过程中,对跨域资源的访问也是很常见的需求。因此,掌握各种合理的跨域资源访问技术是很重要的。
下面列举一些常见的跨域技术。
CORS ( Cross-Origin Resource Sharing )
W3C 推出的 CORS 规范,定义了访问跨源资源时,浏览器和服务器的沟通方式。
使用这种跨域资源访问技术,需要服务器、浏览器同时支持。现代浏览器的支持情况:
- Firefox 3.5+
- Safari 4+
- IE 10+
- iOS Safari
- Android Browser
- Chrome
IE 8 开始引入的 XDomainRequest 也实现了一部分的 CORS 规范。
XDomainRequest 的使用跟 XMLHttpRequest 略有不同,
如 open 方法只支持两个参数(因为所有xdr请求都异步)、不能发送cookie、只支持 get、post方法等等。
CORS 具体实现是,通过 HTTP 首部字段进行交互。大致过程:
- 浏览器发送请求时,携带一个
Origin
首部,字段值为请求页面的源信息 (协议、域名、端口) - 服务器接收到请求后,检查
Origin
首部字段的值是否在允许的范围,从而作出不同的处理。返回请求时,在响应请求的首部字段中,增加一个Access-Control-Allow-Origin
字段,字段值同请求中的Origin
字段的值。如果请求的是公共资源,也可以使用*
作为字段值 - 浏览器判断响应请求的
Access-Control-Allow-Origin
字段值是否匹配,不匹配或者没有这个首部字段,则拒绝这个跨域请求
此外,对于 cookie 的发送控制,需要设置其他首部字段,发送复杂请求时会使用 Preflighted Request 机制 (提前发送一个 OPTIONS 请求),相关细节这里不赘述
例子:
服务器 ( Node.js )
1
2
3
4
5
6
require('http')
.createServer(function(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.end('CORS')
})
.listen(3000)
浏览器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CORS</title>
<script src="jquery.js"></script>
</head>
<body>
<script>
$.ajax('http://127.0.0.1:3000')
.done(function(data) {
console.log(data) // 打印出 'CORS'
})
</script>
</body>
</html>
运行后,将看到控制台打印出 ‘CORS’
JSONP ( JSON with padding )
JSONP 的命名很有迷惑性,但其并不是一种 JSON 相似数据交换格式,而是一种巧妙的与服务器交换数据的方式。
JSONP 利用了 HTML 元素的 src 属性可以跨域访问资源的特性。
通过动态地创建 <script>
并设置其 src
为需要跨域访问资源的 url 来绕过同源策略限制。
只需要服务器配合返回一份特定结构的 JavaScript 代码,便能达到我们跨域请求资源的目的。
服务器返回的 JavaScript 代码结构符合这样的特点:
- 是一个指定名称的函数的调用
- 我们需要的数据( 如 JSON )作为该函数的参数
如此一来,只需要我们事先在本地定义好这个指定名称的函数,便可以在请求到数据后使用该函数做相应的数据处理了。
JSONP 原理简单、使用方便,并且具有非常优秀的兼容性,所以广受开发者欢迎。
但 JSONP 也有其缺点。
比如判断 JSONP 是否已经失败了,能做的,基本只有设置超时检测,但这种检测受限于用户网速、带宽条件,难以做到尽善尽美。至于 HTML5 新增的 <script>
的 onerror
事件,还未得到浏览器支持。
此外,JSONP 使用过程可能受到其他域的恶意代码影响,因此需要谨慎判断其他域的安全性是否可靠。
例子
浏览器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>JSONP</title>
<script src="jquery.js"></script>
</head>
<body>
<script>
$.ajax({
url: 'http://127.0.0.1:3000/',
dataType: 'jsonp',
jsonpCallback: 'mycb',
success: function(data) {
console.log(data) // 打印 { hello : 'world' }
}
})
</script>
</body>
</html>
服务器( Node.js )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use strict'
const http = require('http')
const url = require('url')
const data = { 'hello': 'world' }
http.createServer(function(req, res) {
let params = url.parse(req.url, true)
// 回调名称,本例子中为 mycb
let cbName = params.query.callback
// 输出回调包裹数据的形式: mycb({ hello: 'world' })
let resStr = `${ cbName }(${ JSON.stringify(data) })`
res.writeHead(200, { 'Content-type': 'text/plain' })
res.end(resStr)
})
.listen(3000)
window.postMessage
使用 HTML5 引入的 message API 进行跨域。
具体方式是使用 postMessage 发送消息,该方法允许来自不同源的脚本进行安全的、异步的通信。而接受消息的一方则注册一个message事件的处理器,然后就可以从事件对象中提取有用的信息进行处理。
不同于上面提到的方法可以跟服务器直接交换数据,使用 postMessage 只能在两个窗口 ( iframe ) 之间交换数据,需要一个窗口里使用 iframe
打开另一个窗口,或者用 window.open()
方法打开另外一个窗口,因为这样才能获取目标窗口的 window
对象引用。
API 说明:
otherWindow.postMessage(message, targetOrigin, [transfer])
对象 otherWindow
是接受方的 window
对象。所以发送消息需要以能得到接收方的 window
对象引用为前提。通常有这么些得到方式:
- iframe
win = window.frames[0]
win = document.getElementsByTagName('iframe')[0].contentWindow
- window.open 的返回值
win = window.open(...)
- 由哪个 window 打开的
win = window.opener
- message 事件处理器的事件对象的属性
win = event.source
参数 message
为要传递的消息,考虑到浏览器支持情况,最好使用字符串( 尽管规范允许是 JavaScript 的任意基本类型或可复制的对象 )
参数 targetOrigin
是目标窗口的源( 即 “协议+主机+端口” 的形式,端口号后面还有内容的话会被忽略 ) 。该参数限制了消息只传递给指定的窗口,如果将该参数设置为 *
,则可以向任意窗口传递信息,如果将该参数设置成 /
,则只传递给同源的窗口。
接收消息:
1
2
3
4
5
6
7
8
9
10
11
12
13
window.addEventListener('message', messageHandle, false)
function messageHandle(event) {
// event.origin 为消息发送方的 origin
// 可以用来确认发送方是否可靠
if (event.origin !== 'http://example.org') return
// event.data 即发送过来的消息内容
console.log(event.data)
// event.source 是发送消息的 window 对象的引用
// 可以用来回发消息实现双向通讯
event.source.postMessage('收到了', event.orign)
}
document.domain
这种方式适合在同一个域名下,不同子域名的页面之间进行交互。
页面之间资源互访的限制如下:
- 端口不同,不允许
http://a.com:3000/a.js
&http://a.com:3001/b.js
- 协议不同,不允许
https://a.com/a.js
&http://a.com/b.js
- 域名与对应的IP,不允许
http://a.com/a.js
&http://123.123.123.123/b.js
- 子域名不同,不允许
http://a.a.com/a.js
&http://b.a.com/b.js
- 域名不同,不允许
http://a.com/a.js
&http://b.com/b.js
- 同协议、同域名、同子域名、同端口,允许
http://a.com/a.js
&http://a.com/b.js
http://a.com/a/a.js
&http://a.com/b/b.js
其中,域名相同,但子域名不同的情况,可以通过设置 document.domain
属性来解决。注意该值只能设置为自身层级或者更高层级( 如可以从a.a.com
设置成 a.a.com
或 a.com
),设置其他值会报错。
一般,只需要将 document.domain
都去掉子域名前缀即可,例如,http://a.a.com/a.html
和 http://b.a.com/b.html
都将 document.domain
设置为 a.com
,这样这两个页面就能相互访问了。
例子
a.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 略 -->
<script>
document.domain = 'a.com' // 统一成 a.com
var ifr = document.createElement('iframe')
ifr.src = 'http://b.a.com/b.html'
ifr.style.display = 'none'
document.body.appendChild(ifr)
ifr.onload = function() {
// 可以访问 b.html
var doc = ifr.contentDocument || ifr.contentWindow.document
console.log(doc.getElementsByTagName('title')[0].innerText) // b.html
}
</script>
<!-- 略 -->
b.html
1
2
3
4
5
6
<!-- 略 -->
<title>b.html</title>
<script>
document.domain = 'a.com' // 统一成 a.com
</script>
<!-- 略 -->
location.hash + iframe
该方式使用了 location.hash
大致过程:
- 动态创建一个不可见的
iframe
- 将
iframe
的src
设置为服务器数据页面URL( 相当于GET
方式的请求 ) - 服务器输出的页面中包含一段修改
window.location
的脚本,修改为与请求发起页面同源的 url,然后数据放在 hash 部分 - 请求发起页面,检测
iframe
的 hash 变化 (hashchange / 轮询等),处理数据
服务器核心代码
1
2
3
4
5
6
// 将数据输出到请求页面同源的代理页 url 的 hash 部分
let resStr = `
<script>
window.location = http://example.com/proxy.html#${ data }
</script>
`
浏览器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script>
function fetchData(url, callback) {
var iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = url
iframe.onload = function() {
callback(iframe.contentWindow.location.hash.slice(1))
window.location.hash = ''
document.body.removeChild(iframe)
}
document.body.appendChild(iframe)
}
// 获取数据
fetchData('http://example.com/data', function(data) {
console.log(JSON.parse(data))
})
</script>
这种方式,需要注意 url 的总长度是有限的,取决于浏览器的实现。并且只能通过 iframe 发起 GET 请求。
window.name + iframe
这种方式,与 location.hash
+ iframe
方案的思路基本是一致的。
利用的是 window.name
在页面刷新后,值也不会变化的特点,使用 iframe
发起请求,数据存放在 window.name
里面,然后 iframe
里面通过脚本切换到请求发起页面同源的 URL,这样请求发起页面就能成功读取到 window.name
里的数据。同样,window.name
的长度也有限制 (2MB ?)。
服务器核心代码
1
2
3
4
5
let resStr = `
<script>
window.name = 'data...'
</script>
`
浏览器
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
27
28
29
30
<script type="text/javascript">
function fetchData(url, callback) {
var lockFlag = false
var iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.onload = function() {
if (lockFlag) {
callback(iframe.contentWindow.name)
iframe.contentWindow.document.write('')
iframe.contentWindow.close()
document.body.removeChild(iframe)
} else {
// 成功load之后,上锁
// 以免 iframe 在切换 URL 下次 onload 时
// 又再次执行,以便直接进入数据处理
lockFlag = true
// 切换成同源的代理页(空页面即可),以便读取数据
iframe.contentWindow.location = 'http://example/proxy.html'
}
}
iframe.src = url
document.body.appendChild(iframe)
}
// 获取数据
fetchData('http://example.com/data', function(data) {
console.log(JSON.parse(data))
})
</script>
WebSocket
WebSocket 不受跨域限制
flash 方案
没研究,略…