Ajax 是 Asynchronous JavaScript + XML 的简写,这一技术能够向服务器请求额外的数据而无须卸载页面,会带来更好的用户体验。
Ajax 技术的核心是 XMLHttpRequest
对象(简称 XHR),XHR 为向服务器发送请求和解析服务器响应提供了流畅的接口。
XHR 对象
XHR 属性
readyState
(只读):代表XHR对象的当前状态。0
:未初始化。尚未调用open()
方法。1
:启动。已经调用open()
方法,但尚未调用send()
方法。2
:发送。已经调用send()
方法,但尚未接收到响应。3
:接收。已经接收到部分响应数据。4
:完成。已经接收到全部响应数据,而且已经可以在客户端使用了。
onreadystatechange
:它是 XHR 对象的一个方法,只要readyState
属性值发生变化,就会调用该方法。必须在调用open()
方法之前为onreadystatechange
赋值才能确保跨浏览器兼容性。并且在这个事件处理程序中最好不要使用this
对象,否则可能会出问题。response
(只读):返回响应的主体部分。主体部分内容的格式由responseType
属性决定。responseText
(只读):返回响应的主体内容的字符串形式。responseType
:通过这个属性指定期望的response
的类型。该属性的默认值为text
。responseURL
(只读):返回响应的序列化URL,如果 URL 为空则返回空字符串。如果URL有锚点,则位于#
后面的内容会被删除。如果 URL 有重定向,responseURL
的值会是经过多次重定向后的最终URL。responseXML
(只读):如果响应的内容类型是"text/xml"
或"application/xml"
,这个属性中将保存包含着响应数据的 XML DOM 文档。status
(只读):HTTP 状态码。statusText
(只读):HTTP 状态码对应的描述信息。timeout
:设置请求超时的毫秒数。超时后会触发ontimeout
事件处理程序。upload
(只读):该属性专门用于上传文件,可用于显示上传进度。它返回的是一个XMLHttpRequestUpload
对象,该对象也拥有 loadstart、progress、error、abort、load、loadend 和 timeout 事件。withCredentials
:该属性为布尔值,它指示了是否该使用类似 cookies,authorization headers 或者 TLS 客户端证书这一类资格证书来创建一个跨站点访问控制请求。在同一个站点下使用 withCredentials 属性是无效的。默认值为false
。
XHR方法
open(method, url, async, user, password)
:初始化一个 XHR 对象。method
:一个字符串,用于表示要发送的请求类型,比如:"get"
、"post"
。url
:请求的 URL。async
(可选):一个布尔值,用于表示是否发送是异步请求,默认为true
。user
(可选):用户名,默认为null
。password
(可选):密码,默认为null
。
send(body)
:在send()
之后,通过调用该发送请求。body
参数是请求主体要发送的数据,如果不需要通过请求主体发送数据,则传入null
。abort()
:终止已经发送的请求。当调用该方法时,被终止的 XHR 对象的readyState
值变为0
,status
值也变为0
。getResponseHeader(headerName)
:用于获取对应首部字段的值。getAllResponseHeaders()
:用于获取所有的首部字段内容。各个首部字段被CRLF
分隔,如果无首部字段,则返回null
。setRequestHeader(header,value)
:设置首部字段。必须在调用open()
之后,调用send()
之前使用该方法才有效。overrideMimeType(mimeType)
:重写响应的MIME类型。必须在调用send()
之前使用该方法才有效。
XHR事件
需要在调用 open()
之前注册事件处理程序的事件:
loadstart
:在接收到响应数据的第一个字节时触发。progress
:在接收响应期间持续不断地触发。error
:在请求发生错误时触发。abort
:在因为调用abort()
方法而终止连接时触发。load
:在接收到完整的响应数据时触发。loadend
:在通信完成或者触发 error、abort 或 load 事件后触发。
需要在调用 open()
之后,调用 send()
之前注册事件处理程序的事件:
- timeout:在请求超时时触发。
demo
demo 用于展示如何利用 XHR 发送请求,并没有进行什么特殊配置。
1 | var xhr = new XMLHttpRequest() |
跨域
通过 XHR 实现 Ajax 通信的一个主要限制,来源于跨域安全策略(同源策略)。默认情况下,使用 XHR 对象发送请求的 URL 域名必须和当前页面完全一致。完全一致的意思是,域名要相同(www.example.com
和 example.com
不同),协议要相同(http
和 https
不同),端口号也要相同。有的浏览器比较宽松,允许端口不同,大多数浏览器都会严格遵守这个限制。这种安全策略可以预防某些恶意行为。但是,实现合理的跨域请求对开发某些浏览器应用程序也是至关重要的。
就算是域名和域名对应的 IP 之间发起请求,也算作跨域。
CORS
CORS(Cross-Origin Resource Sharing,跨域资源共享)是 W3C 的一个工作草案,定义了在必须访问跨域资源时,浏览器与服务器应该如何沟通。CORS 背后的基本思想,就是使用自定义的 HTTP 头部让浏览器与服务器进行沟通,从而决定请求或响应是应该成功,还是应该失败。
比如一个简单的使用 GET 或 POST 发送的请求,它没有自定义的头部,而主体内容是 text/plain
。在发送该请求时,需要给它附加一个额外的 Origin
头部,其中包含请求页面的源信息(协议、域名和端口),以便服务器根据这个头部信息来决定是否给予响应。下面是 Origin
头部的一个示例:
1 | Origin: http://www.nczonline.net |
如果服务器认为这个请求可以接收,就在响应的 Access-Control-Allow-Origin
头部中回发相同的源信息(如果是公共资源,可以回发 "*"
)。例如:
1 | Access-Control-Allow-Origin: http://www.nczonline.net |
如果响应没有这个头部,或者有这个头部但源信息不匹配,浏览器就会驳回请求。正常情况下,浏览器会处理请求。注意,请求和响应都不包含 cookie
信息。
现代浏览器都通过 XMLHttpRequest
对象对象实现了对CORS的源生支持,在尝试打开不同来源的资源时,无需额外编写代码就可以触发这个行为。
跨域 XHR 对象也有一些限制,但为了安全这些限制是必需的。以下就是这些限制:
- 不能使用
setRequestHeader()
设置自定义头部。 - 默认不能发送和接收
cookie
,但是可以通过 XHR 对象的withCredentials
来发送带凭据的跨域请求。 - 调用
getAllResponseHeaders()
方法总会返回空字符串。
由于无论同源请求还是跨源请求都使用相同的接口,因此对于本地资源,最好使用相对 URL,在访问远程资源时再使用绝对 URL。这样做能消除歧义,避免出现限制访问头部或本地 cookie
信息等问题。
Preflight request
想象一个场景,我们发送一个 POST 跨域请求,服务器收到请求后对数据库进行了相应的操作并返回响应,但是由于浏览器的跨域限制,导致我们收到的是请求失败的结果。这种情况就是明明用户请求的操作成功了,但是用户不知道他成功了。
预检请求(Preflight request)就是为了解决上述问题的。某些情况下浏览器在发送跨域请求之前会先发送一个相应的预检请求,从而获知服务器是否允许该跨域请求。如果允许,就发送跨域请求。如果不允许,则阻止发送跨域请求。
触发预检请求的条件:
- 如果请求方法不是 GET、HEAD 或 POST,那么将发送预检请求。
- 如果人为设置了对 CORS 安全的首部字段集合之外的首部字段,那么将发送预检请求。对 CORS 安全的首部字段集合包含:
Accept
Accept-Language
Content-Language
Content-Type
DPR
Downlink
Save-Data
Viewport-Width
Width
- 如果请求头的
Content-Type
的值不是application/x-www-form-urlencoded
、multipart/form-data
、text/plain
之一的话,那么将发送预检请求。
预检请求使用的是 OPTIONS 方法,发送下列头部:
Origin
:源信息。Access-Control-Allow-Method
:跨域请求使用的方法。Access-Control-Allow-Headers
(可选):跨域请求会额外发送的头部字段,这些头部字段用逗号分隔。
预检请求的响应会包含如下头部字段:
Access-Control-Allow-Origin
:源信息。Access-Control-Allow-Methods
:允许的方法,多个方法以逗号分隔。Access-Control-Allow-Headers
:允许的头部,多个头部以逗号分隔。Access-Control-Max-Age
(可选):应该将这个预检请求缓存多长时间(以秒表示),也就是说在这个时间范围内,不会再发送相同的预检请求。
带凭据的请求
默认情况下,跨域请求不提供凭据(cookie
、HTTP 认证及客户端 SSL 证明等)。通过将 withCredentials
属性设置为 true
,可以指定某个请求应该发送凭据。如果服务器接受带凭据的请求,会用下面的 HTTP 头部来响应:
1 | Access-Control-Allow-Credentials: true |
如果发送的是带凭据的请求,但服务器的响应中没有包含这个头部,那么浏览器就不会把响应交给 JavaScript(于是,responseText
中将是空字符串,status
的值为 0
,而且会调用 onerror()
事件处理程序)。另外,服务器还可以在预检请求的响应中发送这个 HTTP 头部,表示允许源发送带凭据的请求。
其他跨域技术
图像 Ping
图像 Ping 是与服务器进行简单、单向的跨域通信的一种方式。请求的数据是通过查询字符串形式发送的,而响应可以是任意内容,但通常是像素图或 204 响应。通过图像 Ping,浏览器得不到任何具体的数据,但通过侦听 load 和 error 事件,它能知道响应是什么时候接收到的。来看下面的例子:
1 | var img = new Image() |
图像 Ping 最常用于跟踪用户点击页面或动态广告曝光次数。图像 Ping 有两个主要缺点,一是只能发送 GET 请求,二是无法访问服务器的响应文本。因此,图像 Ping 只能用于浏览器与服务器的单向通信。
JSONP
JSONP 是 JSON with padding(填充式 JSON 或参数式 JSON)的简写,是应用 JSON 的一种方法。JSONP 看起来与 JSON 差不多,只不过是被包含在函数调用中的 JSON,就像下面这样:
1 | handleResponse({ "name": "Nicholas" }) |
JSONP 由两部分组成:回调函数和数据。回调函数是当响应到来时应该在页面中调用的函数,回调函数一般是在请求中通过 callback
查询字段指定的。而数据就是传入回调函数中的 JSON 数据。下面是一个典型的 JSONP 请求:
1 | http://aadonkeyz.com/example.js?callback=handleResponse |
JSONP 的原理很简单,它是通过动态 <script>
元素来发送请求的,然后将收到的响应内容(JSONP 格式)当作是 JavaScript 代码处理。
请看下面的例子:
1 | function handleResponse(response) { |
这个例子的执行顺序为:
- 浏览器通过
<script>
标签发送请求。 - 服务器收到请求后,发现这个是 JSONP 的请求,根据请求的
callback
查询字段获取回调函数的名称,然后准备要返回的 JSON 数据,最后以 JSONP 的格式结合回调函数名称和 JSON 数据。 - 浏览器接收到 JSONP 格式的响应,开始执行代码,即
handleResponse({ "name": "Nicholas" })
。
与图像 Ping相比,JSONP 非常简单易用并且能够直接访问响应文本,支持在浏览器与服务器之间双向通信。不过它也有自己的缺点:
首先,JSONP 是从其他域中加载代码执行,如果其他域不安全,很可能会在响应中夹带一些恶意代码,而此时除了完全放弃 JSONP 调用之外,没有办法追究。因此在使用不是你自己运维的 Web 服务时,一定要保证它安全可靠。
其次,要确定 JSONP 请求是否失败并不容易。但是 HTML5 给 <script>
标签新增了一个 onerror
事件处理程序,现在来说这个应该不算缺点了吧……
服务器推送技术
Comte
Comte 是 Alex Russell 发明的一个词儿,指的是一种更高级的 Ajax 技术(经常也有人称为“服务器推送”)。Ajax 是一种从页面向服务器请求数据的技术,而 Comte 则是一种服务器向页面推送数据的技术。Comte 能够让信息近乎实时地被推送到页面上。
有两种实现 Comte 的方式:长轮询和流。
短轮询是浏览器定时向服务器发送请求,查看是否有更新的数据。
长轮询是在页面发起一个到服务器的请求,然后服务器一直保持连接打开,直到有数据可发送。发送完数据之后,浏览器关闭连接,随即又发起一个到服务器的新请求。这一过程在页面打开期间一直持续不断。
无论是短轮询还是长轮询,浏览器都要在接收数据之前,先发起对服务器的链接。两者最大的区别在于服务器如何发送数据。短轮询是服务器立即发送响应,无论数据是否有效,而长轮询是等待发送响应。
第二种流行的 Comet 实现是 HTTP 流。流不同于上述两种轮询,因为它在页面的整个生命周期内只使用一个 HTTP 连接。具体来说,就是浏览器向服务器发送一个请求,而服务器保持连接打开,然后周期性地向浏览器发送数据。
在浏览器中,通过注册 onreadystatechange
事件处理程序及检测 readyState
的值,就可以利用 XHR 对象实现 HTTP 流。随着不断从服务器接收数据,readyState
的值会周期性地变为 3
,而浏览器将接收的所有数据均保存在 responseText
属性中。
使用 XHR 对象实现 HTTP 流的典型代码如下所示:
1 | function createStreamingClient(url, progress, finished) { |
SSE
使用 SSE 可以跨域。
SSE(Server-Sent Events,服务器发送事件)是围绕只读 Comet 交互推出的 API。SSE API 用于创建到服务器的单向连接,服务器通过这个连接可以发送任意数量的数据。服务器响应的 MIME 类型必须是 text/event-stream
,而且是浏览器中的 JavaScript API 能解析格式输出。SSE 支持短轮询、长轮询和 HTTP 流,而且能在断开连接时自动确定何时重新连接。
SSE 的 JavaScript API与其他传递消息的JavaScript API很相似。要预订新的事件流,首先要创建一个新的EventSource
对象实例,并传进一个入口点:
1 | var source = new EventSource(url) |
上面的 url
可以与当前网页同域,也可以跨域。跨域时,可以指定第二个参数。withCredentials
是一个布尔值,表示是否一起发送 cookie
。
默认情况下,EventSource
对象会保持与服务器的活动连接。如果连接断开,还会重新连接。这就意味着 SSE 适合长轮询和 HTTP 流。如果想强制立即断开连接并且不再重新连接,可以调用 close()
方法。
1 | source.close() |
EventSource
的实例有一个 readyState
属性
- 值为
0
表示正连接到服务器。 - 值为
1
表示打开了连接。 - 值为
2
表示关闭了连接。
另外,还有以下三个事件:
open
:在建立连接时触发。message
:在从服务器接收到新事件时触发。error
:在无法建立连接时触发。
服务器发回的数据已字符串形式保存在 event.data
中。
所谓的服务器事件会通过一个持久的 HTTP 响应发送,这个响应的 MIME 类型为 text/event-stream
。响应数据的格式是纯文本,最简单的情况是每个数据项都带有前缀 data:
,例如:
1 | data: foo |
对以上响应而言,事件流中的第一个 message 事件返回的 event.data
值为 "foo"
,第二个 message 事件返回的 event.data
值为 "bar"
,第三个 message 事件返回的 event.data
值为 "foo\nbar"
(注意中间的换行符)。对于多个连续的以 data:
开头的数据行,将作为多段数据解析,每个值之间以一个换行符分隔。只有在包含 data:
的数据行后面有空格时,才会触发 message 事件,因此在服务器上生成事件流时不能忘了多添加这一行。
通过 id:
前缀可以给特定的事件指定一个关联的 ID,这个 ID 行位于 data:
行前面或后面皆可:
1 | data: foo |
设置了 ID 后,EventSource
对象会跟踪上一次触发的事件。如果连接断开,会向服务器发送一个包含名为 Last-Event-ID
的特殊 HTTP 头部的请求,以便服务器知道下一次该触发哪个事件。在多次连接的事件流中,这种机制可以确保浏览器以正确的顺序收到连接的数据段。
Web Sockets
Web Sockets 的目标是在一个单独的持久连接上提供全双工、双向通信。在 JavaScript 中创建了 Web Sockets 之后,会有一个 HTTP 请求发送到浏览器以发起连接。在取得服务器响应后,建立的连接会使用 Web Sockets 协议。也就是说,使用标准的 HTTP 服务器无法实现 Web Sockets,只有支持 Web Sockets 协议的专门服务器才能正常工作。
由于 Web Sockets 使用了自定义的协议,所以 URL 模式也略有不同。未加密得到连接不再是 http://
而是 ws://
。加密的连接也不是 https://
而是 wss://
。在使用 Web Sockets URL 时,必须带着这个模式,因为将来还有可能支持其他模式。
要创建 Web Sockets,先实例一个 WebSocket
对象并传入要连接的URL:
1 | var socket = new WebSocket('ws://www.example.com/server.php') |
- 必须给
WebSocket
构造函数传入绝对 URL。 - 同源策略对 Web Sockets 不适用,因此可以通过它打开到任何站点的连接(不存在跨域问题)。
- 实例化了
WebSocket
对象后,浏览器就会马上尝试创建连接。
与 XHR 类似,WebSocket
也有一个表示当前状态的 readyState
属性。不过,这个属性的值与 XHR 并不相同,而是如下所示:
WebSocket.OPENING(0)
:正在建立连接。WebSocket.OPEN(1)
:已经建立连接。WebSocket.CLOSING(2)
:正在关闭连接。WebSocket.CLOSE(3)
:已经关闭连接。
WebSocket
没有 readystatechange 事件。
要关闭 Web Sockets 连接,可以在任何时候调用 close()
方法。
1 | socket.close() |
调用了 close()
方法之后,readyState
属性的值立即变为 2
,而在关闭连接后就会变成 3
。
Web Sockets 打开之后,就可以通过连接发送和接收数据。要向服务器发送数据,使用 send()
方法并传入任意字符串,例如:
1 | var socket = new WebSocket('ws://www.example.com/server.php') |
当服务器向客户端发来消息时,WebSocket
对象就会触发 message 事件。这个 message 事件与其他传递消息的协议类似,也是把返回的数据保存在 event.data
属性中。
1 | socket.onmessage = function () { |
- 在使用 Web Sockets 连接发送和接收数据时,需要注意传递的数据只能是纯文本数据。
WebSocket
对象还有其他三个事件,在连接生命周期的不同阶段触发,分别是open
、error
和close
。WebSocket
对象不支持 DOM2 级事件侦听器,因此必须使用 DOM0 级语法分别注册每个事件处理程序。
1 | var socket = new WebSocket('ws://www.example.com/server.php') |
在这三个事件中,只要 close 事件的 event
对象有额外的信息。这个事件的事件对象有三个额外属性:wasClean
、code
和 reason
。其中,wasClean
是一个布尔值,表示连接是否已经明确地关闭。code
是服务器返回的数值状态码。而 reason
是一个字符串,包含服务器发回的消息。
SSE 与 Web Sockets
面对某个具体的用例,在考虑是使用 SSE 还是使用 Web Sockets 时,可以考虑如下几个因素。首先,你是否有自由度建立和维护 Web Sockets 服务器?因为 Web Sockets 协议不同于 HTTP,所以现有服务器不能用于 Web Sockets 通信。SSE 倒是通过常规 HTTP 通信,因此现有服务器就可以满足需求。
第二个要考虑的问题是到底需不需要双向通信。如果用例只需读取服务器数据,那么 SSE 比较容易实现。如果用例必须双向通信,那么 Web Sockets 显然更好。别忘了,在不能选择 Web Sockets 的情况下,组合 XHR 和 SSE 也是能实现双向通信的。