《JavaScript高级程序设计》第二十一章,Ajax与Comet。
Ajax指的是Asynchronous JavaScript+XML,这一技术是2005年提出的,能够向服务器请求额外的数据而无须卸载页面。Ajax的核心是XMLHttpRequest
对象(简称XHR),它提供了流畅的接口。能够以异步方式从服务器取得更多信息,意味着用户单击后无须刷新页面也能取得新数据。
实际上这种技术在提出之前就已经存在了,只不过之前叫做远程脚本(remote scripting),早在1998年就有人采用不同的手段实现了这种浏览器与服务器的通信。以前是用Java applet或Flash等中间层向服务器发送请求,而XHR则将浏览器原生的通信能力提供给开发者。
Comet是Ajax的进一步扩展,让服务器几乎能够时实地向客户端推送数据。
* 虽然Ajax的命名里有XML,但Ajax通信与数据格式无关,不一定非得用XML。
Ajax
Ajax的核心是XMLHttpRequest对象,下面就介绍它。
最早引入XHR的是IE5,它内部实现是基于MSXML库中的ActiveX对象,有多种版本;IE7+和其它浏览器则支持原生的XHR对象。因此如果要考虑IE7之前的话要用new ActiveXObject(version)
兼容一下,这里我就忽略一下,直接用下面这一行创建XHR对象。
1 | const xhr = new XMLHttpRequest(); |
基本用法
XHR对象有几个方法,按操作顺序如下。
准备
open()
方法用来启动请求,接收3个参数:请求类型、请求的URL、表示是否异步发送的布尔值。
1 | xhr.open("get", "example.php", false); |
上例会启动对“和当前执行代码页面同目录下”的example.php
的GET
请求(当然也可以用绝对路径,但还是有同源限制)。需要注意的是,调用open()
并不会真正发送请求,而只是启动一个请求以备发送。
发送
send()
方法用来发送请求,接收1个参数:要作为请求主体发送的数据(如果不发送数据则用null
)。
1 | xhr.send(null); |
接收
由于这次请求是同步的,JavaScript代码会等到服务器响应之后再继续执行。在收到响应后,响应数据会自动填充到XHR对象里,作为它的属性,有下面这些。
responseText
:作为响应主体被返回的文本responseXML
:当响应的内容类型为"text/xml"
或"application/xml"
时,此属性为包含响应数据的XML DOM文档;否则为null
status
:响应的HTTP状态statusText
:HTTP状态的说明
1 | // false表示发送同步请求 |
上面的同步请求固然可行,但大多数情况下我们还是要发送异步请求,才能让JavaScript继续执行而不必等待响应。此时可以检测XHR对象的readyState
属性和readystatechange
事件,来查看请求/响应过程所处的阶段,并做出回应。它有几种取值:
1 | const xhr = new XMLHttpRequest(); |
取消
在接收到响应之前还可以调用abort()
来取消异步请求。调用此方法后,XHR对象会停止触发事件,也不允许访问任何与响应有关的对象属性。还要注意是的,此时还应该对XHR对象进行解引用操作。
1 | xhr.abort(); |
HTTP头部
XHR对象提供操作请求头或响应头的方法,包括发送时的设置、接收时的提取。默认情况下,包含这么些header字段:Accept
、Accept-Charset
、Accept-Encoding
、Accept-Language
、Connection
、Cookie
、Host
、Referer
、User-Agent
。
使用setRequestHeader()
可以设置自定义的请求头部信息,它接收2个参数:头部字段名称、头部字段值。要注意的是,需要在open()
后send()
前调用此方法才有用。
1 | xhr.open("get", "example.txt", true); |
使用getResponseHeader()
并传入头部字段名称,可以获取相应的响应头部信息。使用getAllResponseHeaders()
则可以获取包含所有头部信息的字符串。
1 | const myHeader = xhr.getResponseHeader("MyHeader"); |
提交表单:FormData
表单功能在现代Web应用中使用非常频繁,XMLHttpRequest 2级为此定义了FormData
类型,为序列化表单及创建与表单格式相同的数据(用于通过XHR传输)提供了便利。
1 | /* 创建FormData */ |
使用FormData
的好处在于,不必手动给XHR设置请求头部,它会自动识别传入的FormData
实例并配置适当的头部信息。
超时设定:timeout
XHR对象有个timeout
属性,表示请求在等待响应多少毫秒之后就终止。设置数值后,如果在规定的时间内浏览器还没有接收到响应,就会触发timeout
事件。
1 | const xhr = new XMLHttpRequest(); |
重写MIME类型:overrideMimeType
overrideMimeType()
用于重写XHR响应的MIME类型。因为返回响应的MIME类型决定了XHR对象如何处理它,但是存在一些返回类型错误的问题(比如说服务器返回的MIME类型是text/plain
,而数据中实际包含的是XML
,根据MIME类型,即使数据是XML
,responseXML
依然是null
),因此提供这种重写响应MIME的方法。
1 | const xhr = new XMLHttpRequest(); |
这里强迫XHR将响应当作XML而非纯文本来处理,要注意的是此方法必须在send()
之前调用才能保证重写。
Comet
Comet指的是更高级的Ajax技术,也被称作服务器推送。Ajax是从页面向服务器请求数据的技术,Comet是服务器向页面推送数据的技术。有两种实现Comet的方式:长轮询和流。
轮循
传统的轮循是短轮循,即浏览器定时向服务器发送请求,看看有没有更新的数据,时间线如下:
长轮询则颠倒了一下,即页面发起一个到服务器的请求,服务器一直保持连接打开,直到有数据可发送。发送完数据后,浏览器关闭连接的同时又发起一个到服务器的新请求,时间线如下:
二者的区别是,短轮循下服务器立即发送响应,长轮询下服务器等待有数据后发送响应。轮循的优点在于所有浏览器都支持,因为使用XHR和setTimeout()
就能实现。
流
“流”这个名称就很明显了,与轮循的区别在于,流只用一个HTTP连接。过程是,浏览器向服务器发送一个请求,服务器保持连接打开,然后周期性地向浏览器发送数据。一般的实现是,将内容打印到输出缓存,将其一次性发送出去,再刷新。
1 | function createStreamingClient(url, progress, finished) { |
SSE
背景
上面的例子比较简单,也能在大多数浏览器上运行,但管理Comet的连接是很容易出错的。为了简化,又为Comet创建了两个新的接口。
SSE即Server-Sent Events,服务器发送事件。SSE API用于创建到服务器的单向连接(那为什么叫服务器发送事件,而不叫浏览器发送事件?因为浏览器只是发起请求和创建连接,实际上发送数据的是服务器呀!)。服务器可以通过这个连接发送任意数量的数据,但仅限于MIME为text/event-stream
的数据;它支持短轮循、长轮询和HTTP流;且能在断开连接的时候自动确定何时重连,比较方便。
使用EventSource
要预定新的事件流,首先要创建一个EventSource
对象,并传入一个接口点。这里的事件流,创建于浏览器,表示的是来自“源”(服务器)的“事件”们。所以这个接口点也就是想要连的服务器端URL。要注意的是,这个传入的URL有同源限制。
1 | const source = new EventSource("myevents.php"); |
EventSource
对象有几个属性和方法:
readyState
:0
表示正连接到服务器、1
表示打开了连接、2
表示关闭了连接close()
:EventSource
默认保持与服务器的连接,连接断开时候还会重新连接,因此强制断开连接并且不再重新连接需要调用此方法
EventSource
对象有几个事件,它们的event
里都有个data
属性,保存着返回数据的字符串形式:
open
:在建立连接时触发message
:在从服务器接收到新数据时触发error
:在无法建立连接时触发
1 | source.onmessage = function(event) { |
数据格式
要注意的有如下几点:
- 响应的最简单情况是每个数据项都带有前缀
data
- 只有在数据行后面有空行时,才会触发
message
事件 - 如果发送多行,那么接收到的数据内会有换行符
1 | <!-- 假如服务器发送了3次数据如下 --> |
还可以通过id:
前缀给特定的事件指定关联ID,这个ID位于data:
前后均可。设置了ID后,EventSource
对象会跟踪上一次触发的事件。当连接断开时,会向服务器发送带有Last-Event-ID
这个HTTP头部的请求,以便服务器知道下一次该触发哪个事件。这有助于浏览器按顺序收到数据段。
1 | data: foo |
Web Sockets
背景
Web Sockets的目标是在一个单独的持久连接上提供全双工、双向通信。
它的整个连接过程是:在浏览器中创建了Web Socket后,会有一个HTTP请求发送到服务器以创建连接;服务器收到这个请求后发回响应;浏览器收到响应后,建立的连接会从HTTP协议升级到Web Socket协议(所以URL模式为ws://
和wss://
,对应http://
和https://
);再相互交换信息。
使用自定义协议的优点是,数据量小,不用HTTP那样字节级的开销,适合移动应用;使用自定义协议的缺点是,制定协议的时间比指定JavaScript API的时间还长,因为不断有人发现这个协议存在一致性和安全性问题(不过这貌似也不算缺点…只能说制定过程比较曲折)。
使用WebSocket
创建WebSocket
对象时要传入要连接的URL,且必须是绝对URL。还有就是,它不受同源策略的限制。
1 | const socket = new WebSocket("ws://www.example.com/server.php"); |
WebSocket
对象有几个属性(类似展开了的readyState
,但它没有readystatechange
事件),几个方法:
WebSocket.OPENING
(0):正在建立连接WebSocket.OPEN
(1):已经建立连接WebSocket.CLOSING
(2):正在关闭连接WebSocket.CLOSE
(3):已经关闭连接send()
:只能发送纯文本(格式化数据的话,就得序列化一下了)close()
:关闭连接
WebSocket
对象有几个事件如下,要注意的是WebSocket不支持DOM2级事件侦听器,得用DOM0级的。
open
:在建立连接时触发message
:在从服务器接收到新数据时触发,event.data
为接收的数据的字符串形式error
:在发生错误时触发close
:在连接关闭时触发,event
有3个额外属性(wasClean
为连接是否已明确地关闭的布尔值、code
为服务器返回的数值状态码、reason
为服务器发回的消息)
1 | const socket = new WebSocket("ws://www.example.com/server.php"); |
* SSE vs Web Sockets
二者的选用需要考虑两个因素。一是是否能够建立和维护Web Sockets服务器。如果不能就只能用SSE,否则才考虑选择的问题。二是是否需要双向通信。如果只需要读取数据(如获取比赛成绩),那么SSE比较容易实现;如果必须双向通信(如聊天室),那么Web Sockets更好。不过在无法选用Web Sockets的情况下,组合XHR和SSE也是可以实现双向通信的。
进度事件
Progress Events定义了与客户端服务器通信有关的事件。这些事件最早其实只针对XHR操作,后来也被其它API借鉴了。有6个进度事件:
loadstart
:接收到响应数据的第一个字节时触发progress
:接收响应期间持续触发error
:请求发生错误时触发abort
:调用abort()
而终止连接时触发load
:接收到完整的响应数据时触发loadend
:通信完成或触发error
、abort
、load
事件后触发
每个请求都从loadstart
开始,然后一个或多个progress
,然后error
或abort
或load
,最后loadend
。大部分事件都很直观,下面说一下其中两个事件中需要注意的地方。
load
load
事件的引入主要是为了简化异步交互模型,用来替代readystatechange
事件。响应接收完毕后触发load
事件,这样就不用去检测readyState
属性了。这个事件对象event
的target
就是XHR对象。
1 | const xhr = new XMLHttpRequest(); |
progress
这个事件就会在接收数据的时候持续触发啦。这个事件对象event
的target
也是XHR对象,但包含3个额外属性:lengthComputable
(表示进度信息是否可用的布尔值)、position
(已接收的字节数)、totalSize
(根据Content-Length
响应头部确定的预期字节数)。这些信息可以用来创建进度条。
1 | const xhr = new XMLHttpRequest(); |
跨域资源共享
通过XHR实现Ajax通信的主要限制在于,XHR对象有同源限制(相同域、端口、协议),这样可以预防某些恶意行为,不过同时也会带来阻碍。
CORS
简单请求
为了解决这个问题,W3C提出了CORS(Cross-Origin Resource Sharing)。它的基本思想在于,使用自定义的HTTP头部让浏览器与服务器进行沟通,从而决定请求或响应是应该成功还是应该失败。具体实现则是,发方附加Origin
头部,收方附加Access-Control-Allow-Origin
头部,内容均含协议、域名、端口(或后者可以使用"*"
)。如果匹配上,则允许访问;否则驳回请求。
1 | <!-- 发方 --> |
IE的实现
微软在IE8引入XDR(XDomainRequest),它与XHR类似,但能实现安全可靠的跨域通信,它和XHR的区别在于:
cookie
不会随请求发送,也不会随响应返回- 只能设置请求头中的
Content-Type
字段 - 不能访问响应头,意味着没法确定响应的状态码,只能通过某些事件的触发来判断是否成功了。响应有效则触发
load
事件,响应失败则触发error
事件,但也没额外的信息了 - 只支持
GET
和POST
open()
方法只接收2个参数(请求的类型和URL),意味着所有XDR请求都是异步的
1 | const xdr = new XDomainRequest(); |
其它浏览器的实现
其它浏览器都通过XMLHttpRequest对象实现了对CORS的原生支持。也就是说什么都不用做都行,只不过有一点限制:
- 不能使用
setRequestHeader()
设置自定义头部 - 不能发送和接收
cookie
- 调用
getAllResponseHeaders()
总会返回空字符串
预检请求
CORS通过Preflighted Requests的透明服务器验证机制,支持开发人员使用自定义头部、GET或POST之外的方法,以及不同类型的主题内容(重点:主要是为了跨域发送这些特殊请求,而让请求和响应都附加一些头部,且在第一次多发送一个OPTIONS请求)。
发送方的额外请求头为:
Origin
:与简单请求相同Access-Control-Request-Method
:请求自身使用的方法Access-Control-Request-Headers
:可选的自定义头部,多个则用逗号分隔
服务器返回的响应则用下面这些头部与之进行沟通:
Access-Control-Allow-Origin
:与简单请求相同Access-Control-Allow-Methods
:允许的方法,多个则用逗号分隔Access-Control-Allow-Headers
:允许的头部,多个则以逗号分隔Access-Control-Max-Age
:应该将Preflight请求缓存的时间(以秒表示)
1 | <!-- 预检请求头 --> |
预检请求结束后,会按照响应中的时间被缓存起来,也就是说第一次发请求之前会多发一个预检请求,之后只要在缓存时间内,再发送相同请求的话就不需要额外发送预检请求了。
带凭据的请求
默认的跨域请求不提供凭据(cookie、HTTP认证、客户端SSL证明等),通过将withCredentials
设置为true
,可以指定某个请求应该发送凭据;如果服务器接受带凭据的请求则用Access-Control-Allow-Credentials: true
来响应。
图像Ping
加载图像不用考虑跨域,因此可以借用。
1 | const img = new Image(); |
这里创建了一个Image实例,通过指定onload
和onerror
来获取响应通知,请求从设置src
的那一刻开始。这种方法常用于跟踪用户点击页面或动态广告曝光次数,有几个主要的缺点:一是只能发送GET请求;二是无法访问服务器的响应文本。因此只能用于单向通信。
JSONP
加载脚本不用考虑跨域,因此可以借用。
这里的JSONP即JSON with padding(填充式JSON/参数式JSON),叫这个名字主要是因为它是包含着JSON数据的一个回调函数。那很显然它的组成就是:回调函数、数据,像下面这样。
1 | callback({ "name": "Nicholas" }); |
举个通过JSONP进行跨域发送请求的例子:
1 | function handleResponse(resp) { |
这里通过查询字符串来指定JSONP服务的回调参数,这里就是handleResponse()
方法。当设置完src
并加载完资源后,就会立即执行该callback。
JSONP和图像Ping相比,优点在于能够直接访问响应文本,支持浏览器与服务器之间的双向通信。不过它仍然有不足:一是它从其它域加载代码执行,有安全隐患;二是不容易确定JSONP请求是否失败了,因为<script>
元素的onerror
虽然在HTML5中新增了,但目前还没得到任何浏览器支持。
安全
XHR访问URL的过程中,服务器最好要验证发送请求者是否有权访问相应的资源。否则就会有CSRF(Cross-Site Request Forgery,跨站点请求伪造),也就是未被授权的系统伪装自己去访问某个资源,让服务器认为它是合法的。
下面的做法是有效的:
- 要求以SSL连接来访问资源
- 要求请求要附带经过相应算法计算得到的验证码
下面的做法是无效的:
- 要求发送POST而不是GET——很容易改变
- 检查来源URL以确定是否可行——来源记录很容易伪造
- 基于cookie信息进行验证——很容易伪造