跨域请求的四种方案

浏览器的“同源策略”固然保障了互联网世界的数据隐私与数据安全,但是如果当我们需要使用AJAX跨域请求资源时,“同源策略”又会成为开发者的阻碍。在本文中,我们会简单介绍需要跨域请求资源的两种情景,然后,详细解释目前主流的四种跨域请求资源方案。(不知道同源策略,请移步:同源策略)

让我们开始吧!(注意:很长,没有足够时间就先收藏吧,有时间翻出来看看,确实不错的文章)

一、何时需要跨域

试想,当我们拥有多个站点,并且这些站点又经常共享相同的数据,那么为每个站点存储一份数据看起来就蠢透了。更好的方案是,我们建设一台静态资源存储服务器,然后让我们的所有站点都从这一台服务器上获取资源。很理想的方案,但是现实中,我们首要解决的问题便是浏览器的“同源策略”,别忘了,不同域之间无法通过AJAX技术获取资源。这是需要跨域获取资源的主要情景。

另外,站在互联网“开放,平等,自由”精神的角度上讲,如果所有人的数据都被设置为只有同域才能访问,那么互联网世界未免也太无聊了,如果我就是想要与更多的人分享我的数据,难道不应该有办法让我做到这一点吗?

当然有办法,下面我们就将一一解释当下主流的跨域请求资源方式。


二、跨域请求资源方案

我们将主要介绍以下四种跨域请求资源的方案,并逐一解释他们的原理,实用方式以及优缺点,希望你和我一样有耐心,耐心总是能带来回报:

  1. 野路子出身却好用的方式:JSONP;
  2. 官方推荐的跨域资源共享方案:CORS;
  3. 使用HTML5 API:postMessage;
  4. 抛弃HTTP,使用:Web Sockets;

在开始下面的内容之前,我们首先需要强调一点,无论是怎样的跨域资源获取方案,本质上都需要服务器端的支持。跨域获取资源之所以能够成功,本质是服务器默许了你有权限获取相应资源。下面我们所运用的种种方式,实际上是客户端和服务端互相配合,绕过同源策略进行数据交互的工作,千万不要误以为掌握了下述技术后,我们就能成为一个黑客 ‍♂️。

(一)野路子出身却异常好用的方式:JSONP

正如标题所描述的那样,JSONP技术是早期某个(些?)聪明的程序员发明的跨域资源获取方式,由于该技术的简单易用,逐渐变得越来越流行,最终成为经典的跨域获取资源方案。

JSONP是“JSON with padding”的简写,我将其翻译为“被包裹的JSON”,当你看完这个章节,你一定会觉得这个名字相当贴切。

让我们模拟一下当初想到JSONP技术的高手程序员是如何推理的:

首先,我们应该清楚的认识到,浏览器的“同源策略”只是阻止了通过AJAX技术跨域获取资源,而并没有禁止跨域获取资源这件事本身,正因如此,我们可以通过<link>标签,<img>标签以及<script>标签中的href属性或src属性获取异域的CSS,JS资源和图片(虽然我们其实并不能读取这些资源的内容)。

其次,我们知道(也许你不知道,但是,还记得吗,我在模拟那个高手程序员?)<script>标签通过src属性加载的JS资源,实际上只是将JS文件内容原封不动的放置在<scritp>的标签内,并没有什么神奇之处!

也就是说,如果我们的sayHi.js文件只有这样一段代码:

// sayHi.js
alert('Hi')

当我们在HTML文件中,成功加载sayHi.js文件时,浏览器只不过是做了如下操作:

<!-- 加载前 -->
<script src="sayHi.js"></script>

<!-- 加载后 (为了方便阅读,我格式化了代码)-->
<script src="sayHi.js">
    alert('Hi')
</script>

这意味着什么呢?这意味着被加载的文件与HTML文件下的其他JS文件共享一个全局作用域。也就是说,<scritp>标签加载到的资源是可以被全局作用域下的函数所使用的!

但是慢着!如果<script>标签加载到的一些数据并不符合JavaScript语法规定的数据类型,JavaScript就无法处理这些错误不是吗?而且就算数据类型正常了,我们还应该将数据存储于一个变量内,然后调用这个变量...

说的没错!不过我们其实已经离正确答案很近了。

还记的我们这一方案的名称吗?JSONP!,也就是说我们已经约定好了数据的格式为JSON,这是JavaScript可以处理的数据类型,并且JSON格式的数据可以承载大量信息。那么有关变量的问题呢?这个回答则更巧妙些,因为我们会通过向服务器传入一个函数的方式,将数据变为函数的参数,让我们直接看看JSONP的使用方式:

1.    function handleResponse(response) {
2.        alert(`You get the data : ${response}`)
3.    }
4.    const script = document.createElement('script')
5.    script.src = 'http://somesite.com/json/?callback=handleResponse'
6.    document.body.insertBefore(script, document.body.firstChild)

很容易看到,我们在1-3行中创建了一个函数,该函数用来处理我们将要获得的数据,该函数的参数response即是服务器响应的数据。在4-6行中我们所做的是利用JavaScript动态生成一个script标签,并将其插入HTML文档。但是注意第5行我们制定的src值,在URL末尾,我们有这样一段查询参数callback=handleResponse,callback的值正是我们先前创建的函数。

事情开始变得有些令人困惑了,究竟发生了什么呢?我们如何通过上述代码最终实现跨域获取资源?

答案就藏在服务端的代码中,当服务端支持JSONP技术时,会做如下一些设置:

  1. 识别请求的URL,提取callback参数的值,并动态生成一个执行该参数值(一个函数)的JavaScript语句;
  2. 将需要返回的数据放入动态生成的函数中,等待其加在到页面时被执行;

此时该文件内容看起来就像这样:

handleResponse(response) // response为被请求的JSON格式的数据

因此,当资源加载到位,内容显示在script标签内时,浏览器引擎会执行这条语句,我们想要的数据就可以被我们以任何想要的方式处理了。真不可思议!

你现在知道为什么这项技术被命名为JSONP了吧?那个“padding”指的就是我们的“callback”函数,真是恰如其名。

最后,我们还要对JSONP技术再强调两点:

  1. JSONP技术与AJAX技术无关:虽然同样牵扯到跨域获取资源这个主题,但我们应该已经清楚的看到,JSONP的本质是绕过AJAX获取资源的机制,使用原始的src属性获取异域资源;
  2. JSONP技术存在一下三点缺陷:
  • 无法发送POST请求,也就是说JSONP技术只能用于请求异域资源,无法上传数据或修改异域数据;
  • 无法监测JSONP请求是否失败;
  • 可能存在安全隐患:别忘了,JSONP之所以能成功获取异域服务器资源,靠的是服务器动态生成了回调函数,并在页面中执行,那么如果服务器在原有的回调函数下再添加些别的恶意JavaScript代码会怎样?当然也会被执行!所以在使用JSONP技术时,一定要确保请求资源的服务器是值得信赖的;


虽然存在一些缺陷,但JSONP的浏览器兼容性却是非常好的,可以说是一种非常小巧高效的跨域资源获取技术。


(二)官方推荐的跨域资源共享方案:CORS

CORS是W3C颁布的一个浏览器技术规范,其全称为“跨域资源共享”(Cross-origin resource sharing),它的意义在于,它是由W3C官方推广的允许通过AJAX技术跨域获取资源的规范,因此相较于JSONP而言,功能更加强大,使用起来也没有了hack的味道。

关于CORS的具体细节,我建议你可以移步阮一峰的同主题博客阅读,我认为该文章已经将这个主题讲解的十分透彻了。

你当然也可以选择继续向下阅读,看看我是怎样理解CORS技术并重新梳理CORS技术相关知识的,希望也能给你带来帮助。

我们之前提到过,如果想要绕过浏览器“同源策略”,实现使用AJAX技术跨域获取资源,需要服务端和客户端的协同合作。而对于CORS标准而言,实现AJAX跨域获取资源,重点还在于服务器端返回的响应是否清楚的告知了浏览器此次跨域AJAX请求的合法性。

那么?服务器端该如何向浏览器传达这一信息呢?答案是要看AJAX请求的复杂程度,也就是说,对于简单的AJAX请求,服务器要向浏览器做出的“说明”就少,而如果是复杂的AJAX,服务器则要向浏览器多“解释”几句。

那么,如何区分AJAX请求的复杂度呢,标准在于简单的AJAX请求只符合下面两个条件:

  1. 请求方法只属于HEADGETPOST请求的其中一种;
  2. HTTP的头信息只限于以下字段:
  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type(只能为application/x-www-form-urlencodedmultipart/form-datatext/plain其中一种)


而当浏览器检测到一个简单的跨域AJAX请求,浏览器会首先为我们添加一个头部信息:Origin它的值为请求发送代码所在的源(希望你还记得,一个由“协议”,“域名端口”组成)。类似这样:

GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0 ...

而当这样的一条HTTP请求发送到服务端时,服务端会检测该请求报头中的Origin字段的值是否在许可范围内,如果的确是服务端认可的域,那么服务端会在响应报文中添加如下字段:

  • Access-Control-Allow-Origin(必须):该字段用来告知浏览器服务端接受的能够发送跨域AJAX请求的域,它的值要么是该次AJAX请求报头中由浏览器自动添加的Origin值,要么还可以是一个*号,表示可以接受任意的域名请求;
  • Access-Control-Allow-Credentials(可选):该字段用来告知浏览器是否允许客户端向服务端发送Cookie。默认情况下,CORS规范会阻止跨域AJAX向服务端发送Cookie,因此该字段默认值为false,当你显式的将该字段值设置为true时,则表示允许此次跨域AJAX向服务端发送Cookie。
  • Access-Control-Expose-Headers(可选):该字段用来向客户端暴露可获取的响应头;

CORS规范规定,客户端XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本的字段:
* Cache-Control:表示响应遵循的缓存机制;
* Content-Language:表示响应体的语言;
* Content-Type:表示响应体的MIME类型;
* Expires:表示文档的过期时间,到期不再缓存;
* Last-Modified:表示文档的最后改动时间;
* Pragma:用来包含特定的指令;
但是当客户端想要获取额外的响应头字段时,就需要服务端通过在该字段后定义相应的客户端可获取的响应头字段名称。


以上就是简单跨域AJAX请求,客户端与服务端的交互,在继续介绍复杂的跨域AJAX请求前,让我们先停一停,回过头来看看响应报头的Access-Control-Allow-Origin字段,谈一谈CORS规范中为什么默认不允许跨域AJAX请求携带Cookie,以及如果客户端需要传送Cookie时,客户端与服务端又该如何交互的问题。

首先,我们要知道,在客户端与服务端数据传输的过程中,Cookie一直是以明文的形式伴随着数据的传输,只要客户端发送了Cookie至服务端,服务端就会至少返回该段Cookie。而我们又提到过,大多数网站都使用Cookie短暂存储用户会话中的身份信息,因此将Cookie暴露在外是存在安全隐患的,CSRF攻击的目的便是获取用户的Cookie信息,因此在跨域AJAX请求中,为了减少Cookie泄露的风险,CORS规范默认禁止跨域AJAX请求携带Cookie。

那么如果客户端实在需要携带Cookie信息怎么办呢?正如上文提到过的,需要客户端与服务端一起配合,让我们看看具体细节:

  • 首先是客户端:

开发者需要在创建XMLHttpRequest对象实例时,手动配置withCredentials属性,将其值设置为true

var xhr = new XMLHttpRequest()
xhr.withCredentials = true

某些浏览器会默认允许在跨域AJAX请求中发送Cookie,此时如果不想要发送Cookie,你只需要将其值设置为false

  • 其次是服务端:

对于服务端而言,除了像之前提到的要在响应报头设置Access-Control-Allow-Credential字段的值为true之外,还需要为Access-Control-Allow-Origin字段设置一个明确的域,不可以再使用*号。

相信你也能明白,这一切都是为了保护客户端与服务端Cookie的隐私和安全。


现在我们可以继续我们的主题,一起看一看如果我们的跨域AJAX请求超出了“简单”的标准,客户端与服务端又应该如何相互配合,实现跨域的资源共享。

与简单AJAX跨域请求不同,“复杂“的AJAX跨域请求一共会发送两次HTTP请求,其中第一次为”查询请求“,第二次才是我们正式的”AJAX跨域请求“。为什么多出了一次”查询请求“呢?道理其实很简单,我们想象一下当发送”复杂“的AJAX跨域请求时,浏览器最先拿到请求开始识别,然后发现这个请求并不“单纯”(不满足简单跨域AJAX请求标准),于是感到十分疑惑的浏览器会试探的沿着请求的地址向服务端发问,询问服务端是否允许异域的客户端向它发送额外的请求信息,这一次“发问”,即是第一次HTTP请求,即“查询请求”。而服务端当然也会这次“发问”给出相应的回答,然后浏览器就会根据回答的结果决定是否继续发送该跨域AJAX请求。

让我们看看具体的实现细节:

首先,让我们创造出一个“复杂”的AJAX跨域请求:

var url = 'http://another.com/cors'
var xhr = new XMLHttpRequest()
xhr.open('put', url, true) // 这里我们设置请求的方式为'put'
xhr.setRequestHeader('X-Custom-Header', 'Value') // 这里我们自定义了一个请求头字段
xhr.send()

当浏览器识别到该请求“并不简单”时,就会自动向服务其发送一个“查询请求”,其报头信息大致如下:

OPTIONS /cors HTTP/1.1
Origin: http://thisOne.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: another.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

注意这次“查询请求”使用了“OPTIONS”的请求方法,表明了这是一个查询请求。请求头部的信息说明了请求来源的域请求使用的HTTP方法以及请求额外发送的头部字段

让我们再转换至服务器视角,当服务端接收到浏览器发来的这样一个查询请求后,就可以判断出是否应该接收该请求。如果想要向浏览器表示允许该请求,则会返回这样的响应报文:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61(Unix)
Access-Control-Allow-Origin: http://thisOne.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header // 该字段值为以“,”号分割的字符串
Content-type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

读到这里我们已经大概猜的出服务端向浏览器传递的信息了:

  • 首先,Access-Control-Allow-Origin字段向浏览器说明了发起AJAX请求的域是被服务器认可的(注意这个字段的值也可以为一个“*”号);
  • 其次,Access-Control-Allow-Methods字段向浏览器说明了服务器接收跨域AJAX的请求方式;
  • 最后,Access-Control-Allow-Headers字段向浏览器说明了服务器允许跨域AJAX额外发送的报头信息;

当浏览器收到服务端这样的表示同意请求的响应后,就会正常发送接下来的跨域AJAX请求,而服务器也会正常的回应。值的一提的是,在服务端与客户端整个跨域AJAX请求的交互中,Access-Control-Allow-Origin头信息自始至终都是必须携带的。

而当服务器在检查“查询请求”后,如果不同该请求,则会返回一个正常的HTTP响应,报文中包含任何与CORS规范有关的报头字段,此时,浏览器就会心领神会的明白服务器拒绝接收发出的跨域AJAX请求,因此会返回一个错误状态(可以被XML对象实例使用onerror回调函数捕获)并在控制台打印一条错误信息:

XMLHttpRequest cannot load http://another.com
Origin http://thisOne.com is not allowed by Access-Control-Allow-Origin

至此,无论是“简单”的跨域AJAX请求还是“复杂”的跨域AJAX请求,我们都已经清楚的知晓了他们的运作原理,这真是件了不起的事情。但是先别着急庆祝,我们刚才还遗漏了一个话题没有谈到:“节约复杂AJAX跨域请求的HTTP请求数”。

相信你还记的,对于“复杂”的跨域AJAX请求,浏览器会向服务器发送两次HTTP请求,虽然实际上两次HTTP请求与一次HTTP请求所耗费的时间几乎难以感知,但是如果我们有办法一次搞定,又为什么还要重复做两次呢?

对于服务器而言,“一次搞定”的方法就在于,在浏览器第一次发送复杂的跨域AJAX查询请求时,在响应报头中添加Access-Control-Max-Age字段,这是一个可选的字段,它用来指定本次查询请求的有效期,单位为秒。也就是说,通过该字段,服务器拥有了告知浏览器“这个请求我批准了,X秒以内不需要再向我确认”的能力。至此,我们成功的将接下来的跨域请求数由两次节约为一次!

三、小结

一口气看到这里?真不容易! 希望这是值得的,让我们总结一下我们在本文中都谈到了些什么。首先,我们谈到了我们何时需要发起跨域AJAX请求的问题,做到了“知其然”。其次,我们深入探讨了使用JSONP技术和CORS规范实现发送跨域AJAX请求的细节,成功达到了我们“知其所以然”的目标。相信现在的你已经对向他人谈论“跨域”这个主题充满自信。真的很棒对吧?

如果你依然觉得意犹未尽,不妨接着和我继续深入这个主题,看看实现跨域共享资源的另外两种“时髦”的方式:使用 postMessage 和 webSocket。


好休息完了我们继续,让我们先简单回顾一下之前谈到的内容,AJAX是一种无页面刷新的获取服务器资源的混合技术。而基于浏览器的“同源策略”,不同“域”之间不可以发送AJAX请求。但是在某些情境下,我们需要“跨域获取资源”,为了满足这一需求,我们可以使用“JSONP”与“CORS”两种技术。

现在,我们将要简要了解“跨域共享资源”的另外两种方式:WebSocket 和 postMessage。让我们先大概看看他们是什么,以及究竟是基于怎样的原理,满足了我们的需求 - “跨域获取资源”。

四、WebSocket

基于维基百科的定义,WebSocket是一种在单个TCP连接上进行全双工通讯的协议。在这里我并不打算解释“TCP连接”和“全双工通讯”这两个专业术语(这样做会让这篇文章变得很长,而且也偏离了我们的主题),让我们聚焦这段定义的最后两个字协议

说到协议,你是否联想到“HTTP协议”?没错,HTML5标准之所以提出了一种新的互联网通信协议 - WebSocket,就是为了弥补在某些情景下使用HTTP协议通信的一些不足。但是注意,这并不意味WebSocket协议就可以完全取代HTTP协议了,其实两者的关系更像是两兄弟,各自有着各自擅长的领域,而且时不时还一同协作解决难题。

那么上面提到的某些情景具体是指什么呢?答案是“服务端与客户端的双向通信”。我们知道,当我们使用HTTP协议时,客户端与服务端的通信模式始终是由客户端向服务端发送请求,服务端只负责验证请求并返回响应。

我们可以这样想象,在HTTP协议下,服务端扮演着“守门人”的角色,而客户端则是一个邮局,它每发送一个请求就像是委托一个信使携带一封信(信里注明自己的身份和需要获取资源的名称)到服务端,当信使到达时,“守门人”会拆开信封,检查里面的身份信息,如果身份合法则打开资源宝库的大门,将相应的资源交给信使,令其返回给客户端。

在这个故事里,服务端的角色有些枯燥呆板对吧?不仅如此,故事中服务端扮演的“守门人”角色还患有严重的脸盲症,在工作中他只“认信不认人”,也就是说客户端发送的每一个请求,对于服务而言都是全新的,守门人不会因为信使上次来过,或是收到两次相同的信而觉得眼熟,对信使有额外的寒暄。这也就是为什么我们说HTTP协议是“无状态的”。乍看起来,这似乎有些不合理,但是这种设计却使服务器的工作变得简单可控,提升了服务器的工作效率。

但是这样的设计仍然存在两个问题:

  1. 每一个请求都需要身份验证,这对于用户而言意味着需要在每一次发送请求时输入身份信息;
  2. 当客户端所请求的资源是动态生成的时,客户端无法在资源生成时得到通知(还记得吧,服务器只是一个原地不动的“守门人”);

如何解决这两个问题呢?对于前者,答案是使用“Cookie”,而对于后者,则轮到我们今天的主角“WebSocket”大显身手。

在讨论WebSocket之前,让我们先稍微绕点路,谈谈“Cookie”是如何解决“每一个请求都需要身份验证”的问题的。

(一)为HTTP协议添加状态 - Cookie

我们之前提到,HTTP协议下,客户端与服务端的通信是“无状态”的,也就是说,如果服务器中的某部分资源是由某个客户专属的,那么每当这个客户想要获取资源时,都需要首先在浏览器中输入账号密码,然后再发送请求,并在被服务器识别身份信息成功后获取请求的资源。我们当然不想每次发送一个请求都要输入一遍账号密码,因此我们需要Cookie,这个既可以存储在浏览器,又会被浏览器发送HTTP请求时默认发送至服务端,并且还受浏览器“同源策略”保护的东西帮助我们提高发起一次请求的效率。

在有了Cookie之后,我们可以在一次会话中(从用户登录到浏览器关闭)只输入一次账号密码,然后将其保存在Cookie中,在整个会话期间,Cookie都会伴随着HTTP请求的发送被服务器识别,从而避免了我们重复的输入身份信息。

不仅如此,基于Cookie的特性:可以保存在浏览器内,还会在浏览器发送HTTP请求时默认携带,服务端也可以操作Cookie。Cookie还可以帮助我们节省网络请求的发起数量。例如,当我们在制作一个购物网站时,我们当然不希望用户在每添加一个商品到购物车就向服务器发送一个请求(请求数量越少,服务器压力就越小),此时,我们就可以将添加商品所导致的数据变动存储在Cookie内,然后等待下次发送请求时,一并发送给服务器处理。

现在我们可以说,Cookie的出现,为无状态的HTTP协议通信添加了状态。

最后需要注意,Cookie大多数情况下,都保存着用户的身份信息,因此各种恶意攻击者对于Cookie的攻击便花样百出,层出不穷。其本质上就是想要获得用户的Cookie,再利用其中的身份信息伪装成用户获取相应资源,而浏览器的“同源策略”本质上就是保护用户的Cookie信息不会泄露。

(二)让服务器也动起来 - WebSocket

绕了一个小弯,现在可以回过头来继续谈谈我们的主角WebSocket了。再让我们回忆一下WebSocket要解决的问题:

客户端无法获知请求的动态资源何时到位“,让我们描述的更详细一点,有时候客户端想要请求的资源,服务器需要一定时间后才能返回(比如该资源依赖于其他服务器的计算返回结果),由于在HTTP协议下,网络通信是单向的,因此服务器并不具备当资源准备就绪时,通知浏览器的功能(因为我们要保障服务器的工作效率)。因此,基于HTTP协议通常的做法是,设置一个定时器,每隔一定时间由浏览器向服务器发送一次请求以探测资源是否到位。

这种做法显然浪费了很多请求,换句话说,浪费了很多带宽(我们每个请求都要携带Cookie和报头,这些都会占用带宽传输),不仅低效率,而且也不够优雅。

理所当然的,在这种情况下,我们希望当服务器资源到位时,能够主动通知浏览器并返回相应资源。而为了实现这一点,HTML5标准推出了WebSocket协议,使浏览器和服务器实现了双向通信,更妙的是,除了IE9及以下的IE浏览器,所有的浏览器都支持WebSocket协议。

让我们也同样构建一个基于WebSocket协议的心智模型,在这个心智模型中,服务端扮演的角色发生了一些改变,服务端不再只是一个“守门人”,同时它也运营着一个和客户端一样的“邮局”,也就是说,他也拥有了可以向客户端发送数据的能力。至此一个完整的基于WebSocket协议的通信流程为:

客户端派发一个信使向服务器送信,服务器扮演的“守门人”检查信件,发现信件中写到“让我们用更加潮流的WebSocket方式交流吧”,服务器在在信件末尾添加上一句“没问题,浏览器伙计”,让信使原路返回告知浏览器。当浏览器再次向服务器告知收到消息时(第三次握手),服务器就开始运转“邮局”,向客户端派发信使与浏览器互发信息,转发资源。

让我们看看这个模型的具体实现:

下面是客户端告知服务端要升级为WebSocket协议的报头:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

下面是服务端向客户端返回的响应报头:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

想知道这些报头中的字段中代表什么?可以参考维基百科下的说明。

(三)客户端发起WebSocket请求

既然我们已经为了解释“什么是WebSocket”,“WebSocket的意义”花了那么多篇幅,那么不妨添加上最后一个环节,让这个主题变得更加完整,接下来我们将要简单讲解一下客户端如何发起一个WebSocket请求。

像发起AJAX请求一样,发起WebSocket请求需要借助浏览器提供的WebSocket对象,该对象提供了用于创建和管理WebSocket连接,以及通过该连接收发数据的API。所有的浏览器都默认提供了WebSocket对象。让我们看看该对象的用法:

和使用XHRHttpRequest对象一样,我们首先要实例化一个WebSocket对象:

var ws = new WebSocket("wss://echo.websocket.org")

传入的参数为响应WebSocket请求的地址。

同样类似AJAX的是,WebSocket对象也有一个readyState属性,用来表示对象实例当前所处的链接状态,有四个值:

  • 0:表示正在连接中(CONNECTING);
  • 1:表示连接成功,可以通信(OPEN);
  • 2:表示连接正在关闭(CLOSING);
  • 3:表示连接已经关闭或打开连接失败(CLOSED);

我们可以通过判断这个值来执行我们相应的代码。

除此之外,WebSocket对象还提供给我们一系列事件属性,使我们控制连接过程中的通信行为:

  • onopen:用于指定连接成功后的回调函数;
  • onclose:用于指定连接关闭后的回调函数;
  • onmessage:用于指定收到服务器数据后的回调函数;
  • onerror:用于指定报错时的回调函数;

通过.send()方法,我们拥有了向服务器发送数据的能力(WebSocket还允许我们发送二进制数据):

ws.send('Hi, server!')

如何知道何时我们的数据发送完毕呢?我们需要使用WebSocket对象的bufferedAmount属性,该属性的返回值表示了还有多少字节的二进制数据没有发送出去,所以我们可以通过判断该值是否为0而确定数据是否发送结束。

var data = new ArrayBuffer(1000000)
ws.send(data)

if (socket.bufferedAmount === 0) {
    // 发送完毕
} else {
    // 还在发送
}

OK,目前为止我们花了大量篇幅解释了WebSocket协议是什么,它能够帮助我们做什么,以及客户端发送WebSocket请求的方式。但是目前为止,我们还是没有谈论一丁点关于WebSocket是如何帮助我们绕过浏览器的“同源策略”让我们实现“跨域资源共享”,你是否已经有点等的不耐烦了?

但是别急,当你清楚的了解到WebSocket是什么之后,答案就呼之欲出了,那就是当客户端与服务端创建WebSocket连接后,本身就可以天然的实现跨域资源共享,WebSocket协议本身就不受浏览器“同源策略”的限制(还记得吧,同源策略只是限制了跨域的AJAX请求?),所以问题本身就不成立(有点赖皮是吧?)。

但是你可能又会问,如果没有浏览器“同源策略”的限制,那么用户的Cookie安全又由谁来保护呢?问得好,看来你有认真阅读上面的文字,为了解答这个问题,让我们换一种角度思考,我们说过Cookie的存在就是为了给无状态的HTTP协议通讯添加状态,因为Cookie是明文传输的,且通常包含用户的身份信息,所以非常受到网络攻击者的“关注”。但是想想WebSocket协议下的通讯机制,客户端和服务端一旦建立连接,就可以顺畅的互发数据,因此WebSocket协议本身就是“有状态的”,不需要Cookie的帮忙,既然没有Cookie,自然也不需要“同源策略”去保护,因此其实这个问题也不成立。

至此,已经将关于WebSocket的所有内容都大致讲述了一遍,真没想到是如此巨大的工作量。看来本篇文章不应该叫做“再也不学AJAX了”,而是“再也不学AJAX,JSONP,CORS,WebSocket..”。

真是了不起。


二、postMessage

回头一看,我们已经在“跨域”这个主题上整整停留了三篇文章,涉及的技术包括JSONP,CORS与WebSocket。需要注意的是,以上这些跨域技术都只适用于客户端请求异域服务端资源的情景。而除此之外,有时候我们还需要在异域的两个客户端之间共享数据,例如页面与内嵌iframe窗口通讯,页面与新打开异域页面通讯。

这就是使用HTML5提供的新API -- postMessage的时候了。

使用postMessage技术实现跨域的原理非常简单,一方面,主窗口通过postMessageAPI向异域的窗口发送数据,另一方面我们在异域的页面脚本中始终监听message事件,当获取主窗口数据时处理数据或者以同样的方式返回数据从而实现跨窗口的异域通讯。

让我们用具体的业务场景与代码进一步说明,假如我们的页面现在有两个窗口,窗口1命名为“window_1”, 窗口2命名为“window_2”,当然,窗口1与窗口2的“域”是不同的,我们的需求是由窗口1向窗口2发送数据,而当窗口2接收到数据时,将数据再返回给窗口1。先让我们看看窗口1script标签内的代码:

// window_1 域名为 http://winodow1.com:8080
window.postMessage("Hi, How are you!", "http://window2.com:8080")

可以看到,postMessage函数接收两个参数,第一个为要发送的信息(可以是任何JavaScript类型数据,但部分浏览器只支持字符串格式),第二个为信息发送的目标地址。让我们再看看窗口2script标签内的代码:

// window_2 域名为 http://window2.com:8080
window.addEventListener("message", receiveMessage, false)

function receiveMessage(event) {
    // 对于Chorme,origin属性为originalEvent.origin属性
    var origin = event.origin || event.originalEvent.origin
    if (origin !== "http://window1.com:8080") {
        return 
    }
    window.postMessage("I\'m ok", "http://window1.com:8080")
}

看到了吗,我们在window上绑定了一个事件监听函数,监听message事件。一旦我们接收到其他域通过postMessage发送的信息,就会触发我们的receiveMessage回调函数。该函数会首先检查发送信息的域是否是我们想要的(之后我们会对此详细说明),如果验证成功则会像窗口1发送一条消息。

看起来很好懂不是吗,一方发送信息,一方捕捉信息。但是,我需要格外提醒你的是所有“跨域”技术都需要关注的“安全问题”。让我们想想postMessage技术之所以能实现跨域资源共享,本质上是要依赖于客户端脚本设置了相应的message监听事件。因此只要有消息通过postMessage发送过来,我们的脚本都会接收并进行处理。由于任何域都可以通过postMessage发送跨域信息,因此对于设置了事件监听器的页面来说,判断到达页面的信息是否是安全的是非常重要的事,因为我们并不想要执行有危险的数据。

那么接下来的问题便是,如何鉴别发送至页面的信息呢?答案是通过 message事件监听函数的事件对象,我们称它为event,该对象有三个属性:

  • data:值为其他window传递过来的对象;
  • origin:值为消息发送方窗口的域名;
  • source:值为对发送消息的窗口对象的引用;

很显然的,我们应该着重检测event对象的origin属性,建立一个白名单对origin属性进行检测通常是一个明智的做法。

最后,再让我们谈谈postMessage对象的浏览器兼容性,这方面到是很幸运,除了IE8以下的IE浏览器,所有的浏览器都支持postMessage方法!

举报
评论 0