Web 应用要想从服务器取得或写入数据,离不开浏览器提供的网络 API。早期我们主要依赖 XMLHttpRequest(XHR)实现 “AJAX” 异步刷新;如今 Fetch API、Streams、Service Worker 等能力不断成熟,已经成为前端网络编程的默认选择。本章会系统梳理它们的使用方式、差异与注意事项,帮助你在项目中选择合适的请求方案。
async/await,并与 Service Worker、Streams 等周边能力协作良好navigator.sendBeacon、WebSocket、EventSource、HTTP/2 的服务器推送、跨源通信策略(CORS)等实际项目中可遵循以下原则:
async/await、AbortController 处理超时与取消虽然 Fetch 已成为默认选择,但了解 XHR 仍然重要:它提供上传/下载进度事件、同步请求、对旧版浏览器的兼容能力,并且大量遗留代码和第三方库依然基于它实现。本节回顾 XHR 的核心 API,帮助理解其与 Fetch 的差异以及在特殊场景下如何使用。
所有现代浏览器都通过 XMLHttpRequest 构造函数原生支持 XHR 对象:
let xhr = new XMLHttpRequest()在使用 XHR 对象时,要调用的第一个方法是 open(),它接受 3 个参数:请求类型("get"、"post"等)、请求 URL,以及表示请求是否异步的布尔值。
xhr.open("get", "example.php", false)调用 open()方法并不会真正发送请求,而只是启动一个请求以备发送,要发送特定的请求,必须像下面这样调用 send() 方法。
xhr.open("get", "example.txt", false)
xhr.send(null)这里的 send() 方法接收一个参数,即要作为请求主体发送的数据。如果不需要通过请求主体发送数据,则必须传入 null,因为这个参数对有些浏览器来说是必需的。调用 send() 之后,请求就会被分派到服务器
由于这次请求是同步的,JavaScript 代码会等到服务器响应之后再继续执行。在收到响应后,响应的数据会自动填充 XHR 对象的属性
在接收到响应后,第一步是检查 status 属性,以确定响应已经成功返回。
状态代码为 304 表示请求的资源并没有被修改,可以直接使用浏览器中缓存的版本;当然,也意味着响应是有效的。为确保接收到适当的响应,应该像下面这样检查上述这两种状态代码:
xhr.open("get", "example.txt", false)
xhr.send(null)
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText)
} else {
alert("Request was unsuccessful: " + xhr.status)
}通过检测 status 来决定下一步的操作,不要依赖 statusText,因为后者在跨浏览器使用时不太可靠。
大多数情况下,我们还是要发送异步请求,才能让 JavaScript 继续执行而不必等待响应。此时,可以检测 XHR 对象的 readyState 属性
由于 readystatechange 事件是在 xhr 对象状态变化时触发(不单是在得到响应时),也就意味着这个事件会被触发多次
| readyState | 状态描述 | 说明 |
|---|---|---|
| 0 | UNSENT | 代理(XHR)被创建,但尚未调用 open()方法 |
| 1 | OPENED | open() 方法已经被调用,建立了连接 |
| 2 | HEADERS_RECEIVED | send() 方法已经被调用,并且已经可以获取状态行和响应头 |
| 3 | LOADING | 响应体下载中, responseText 属性可能已经包含部分数据 |
| 4 | DONE | 响应体下载完成,可以直接使用 responseText |
var xhr = createXHR()
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText)
} else {
alert("Request was unsuccessful: " + xhr.status)
}
}
}
xhr.open("get", "example.txt", true)
xhr.send(null)以上代码利用 DOM 0 级方法为 XHR 对象添加了事件处理程序,原因是并非所有浏览器都支持 DOM 2 级方法。与其他事件处理程序不同,onreadystatechange 事件处理程序不会收到 event 对象
另外,在接收到响应之前还可以调用 abort()方法来取消异步请求
xhr.abort()调用这个方法后,XHR 对象会停止触发事件,而且也不再允许访问任何与响应有关的对象属性。在终止请求之后,还应该对 XHR 对象进行解引用操作。由于内存原因,不建议重用 XHR 对象
每个 HTTP 请求和响应都会带有相应的头部信息,其中有的对开发人员有用,有的也没有什么用。XHR 对象会通过一些方法暴露与请求和响应相关的头部字段
默认情况下,XHR 请求会发送以下头部字段
使用 setRequestHeader() 方法可以设置自定义的请求头部信息。这个方法接受两个参数:头部字段的名称和头部字段的值。要成功发送请求头部信息,必须在调用 open()方法之后且调用 send()方法之前调用 setRequestHeader()
var xhr = createXHR()
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText)
} else {
alert("Request was unsuccessful: " + xhr.status)
}
}
}
xhr.open("get", "example.php", true)
xhr.setRequestHeader("MyHeader", "MyValue")
xhr.send(null)使用自定义的头部字段名称,不要使用浏览器正常发送的字段名称,否则有可能会影响服务器的响应。有的浏览器允许开发人员重写默认的头部信息,但有的浏览器则不允许这样做
调用 XHR 对象的 getResponseHeader() 方法并传入头部字段名称,可以取得相应的响应头部信息。而调用 getAllResponseHeaders() 方法则可以取得一个包含所有头部信息的长字符串。来看下面的例子。
var myHeader = xhr.getResponseHeader("MyHeader")
var allHeaders = xhr.getAllResponseHeaders()在服务器端,也可以利用头部信息向浏览器发送额外的、结构化的数据。在没有自定义信息的情况下,getAllResponseHeaders() 方法通常会返回如下所示的多行文本内容
Date: Sun, 14 Nov 2004 18:04:03 GMT
Server: Apache/1.3.29 (Unix)
Vary: Accept
X-Powered-By: PHP/4.3.8
Connection: close
Content-Type: text/html; charset=iso-8859-1这种格式化的输出可以方便我们检查响应中所有头部字段的名称,而不必一个一个地检查某个字段是否存在
对 XHR 而言,位于传入 open()方法的 URL 末尾的查询字符串必须经过正确的编码才行。查询字符串中每个参数的名称和值都必须使用 encodeURIComponent() 进行编码,然后才能放到 URL 的末尾;而且所有名-值对儿都必须由和号(&)分隔
xhr.open("get", "example.php?name1=value1&name2=value2", true)下面这个函数可以辅助向现有 URL 的末尾添加查询字符串参数:
function addURLParam(url, name, value) {
url += url.indexOf("?") == -1 ? "?" : "&"
url += encodeURIComponent(name) + "=" + encodeURIComponent(value)
return url
}下面是使用这个函数来构建请求 URL 的示例
var url = "example.php"
//添加参数
url = addURLParam(url, "name", "Nicholas")
url = addURLParam(url, "book", "Professional JavaScript")
//初始化请求
xhr.open("get", url, false)通常用于向服务器发送应该被保存的数据。POST 请求应该把数据作为请求的主体提交,而 GET 请求传统上不是这样
POST 请求的主体可以包含非常多的数据,而且格式不限。在 open()方法第一个参数的位置传入"post",就可以初始化一个 POST 请求
xhr.open("post", "example.php", true)默认情况下,服务器对 POST 请求和提交 Web 表单的请求并不会一视同仁。因此,服务器端必须有程序来读取发送过来的原始数据,并从中解析出有用的部分。
不过,我们可以使用 XHR 来模仿表单提交:首先将 Content-Type 头部信息设置为 application/x-www-form-urlencoded,也就是表单提交时的内容类型,其次是以适当的格式创建一个字符串
POST 数据的格式与查询字符串格式相同。如果需要将页面中表单的数据进行序列化,然后再通过 XHR 发送到服务器,那么就可以使用 serialize() 函数(将表单数据序列化为查询字符串格式)来创建这个字符串
function submitData() {
var xhr = createXHR()
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText)
} else {
alert("Request was unsuccessful: " + xhr.status)
}
}
}
xhr.open("post", "postexample.php", true)
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
var form = document.getElementById("user-info")
xhr.send(serialize(form))
}这个函数可以将 ID 为"user-info"的表单中的数据序列化之后发送给服务器
xhr.open() 方法第三个参数要求传入的是一个布尔值,其作用就是设置此次请求是否采用异步方式执行,默认为 true,如果需要同步执行可以通过传递 false 实现:
var xhr = new XMLHttpRequest()
// 默认第三个参数为 true 意味着采用异步方式执行
xhr.open("GET", "/time", true)
xhr.send(null)
xhr.onreadystatechange = function () {
if (this.readyState === 4) {
// 这里的代码最后执行
console.log("request done")
}
}如果采用同步方式执行,则代码会卡死在 xhr.send() 这一步
console.log("before ajax")
var xhr = new XMLHttpRequest()
// 同步方式
xhr.open("GET", "/time", false)
// 同步方式 执行需要 先注册事件再调用 send,否则 readystatechange 无法触发
xhr.onreadystatechange = function () {
if (this.readyState === 4) {
console.log("request done")
}
}
xhr.send(null)
// 因为 send 方法执行完成 响应已经下载完成
console.log(xhr.responseText)
console.log("after ajax")所以一定在发送请求 send() 之前注册 readystatechange(不管同步或者异步)
以对象的形式表述响应体,其类型取决于 responseType 的值。你可以尝试设置 responseType 的值,以便通过特定的类型请求数据
var xhr = new XMLHttpRequest()
xhr.open("GET", "/api/users")
// 主观认为服务端返回的响应体为 JSON 格式
xhr.responseType = "json"
xhr.onload = function () {
console.log(this.response)
// => Array 而不是 JSON String
}
xhr.send(null)responseType 要在调用 open() 初始化请求之后,在调用 send() 发送请求到服务器之前设置方可生效
| 值 | 描述 |
|---|---|
| "" | 将 responseType 设为空字符串与设置为"text"相同, 是默认类型 (实际上是 DOMString) |
| "arraybuffer" | response 是一个包含二进制数据的 JavaScript ArrayBuffer |
| "blob" | response 是一个包含二进制数据的 Blob 对象 |
| "document" | response 是一个 HTML Document 或 XMLDocument,这取决于接收到的数据的 MIME 类型。请参阅 HTML in XMLHttpRequest 以了解使用 XHR 获取 HTML 内容的更多信息 |
| "json" | response 是一个 JavaScript 对象。这个对象是通过将接收到的数据类型视为 JSON 解析得到的 |
| "text" | response 是包含在 DOMString 对象中的文本 |
缓存问题指的是:多次 AJAX GET 请求同一个 URL 得到的结果是相同的,目前绝大多数浏览器已经没有这个问题了,只有早期的 IE 浏览器(<= IE9) 仍然存在这个问题
var xhr = new XMLHttpRequest()
xhr.open("GET", "/time")
xhr.send(null)
xhr.onreadystatechange = function () {
if (this.readyState !== 4) return
console.log(this.responseText)
// => 每次得到的结果都是相同的
}这个办法的核心就是让浏览器认为每次请求的地址都是不同的。
不同的 querystring 会被浏览器认为是不同的地址,浏览器会忽略客户端缓存。
var xhr = new XMLHttpRequest()
xhr.open("GET", "/time?t=" + Date.now())
xhr.send(null)
xhr.onreadystatechange = function () {
if (this.readyState !== 4) return
console.log(this.responseText)
}由服务端通过 HTTP 响应报文中的响应头告知客户端浏览器不要缓存当前地址
app.get("/time", (req, res) => {
res.set("Cache-Control", "no-cache")
res.set("Pragma", "no-cache")
res.set("Expires", "-1")
// 返回数据
res.send(Date.now().toString())
})HTML5 中对 XMLHttpRequest 类型全面升级,更易用,更强大
现代 Web 应用中频繁使用的一项功能就是表单数据的序列化,XMLHttpRequest 2 级为此定义了 FormData 类型。FormData 为序列化表单以及创建与表单格式相同的数据(用于通过 XHR 传输)提供了便利
var data = new FormData()
data.append("name", "Nicholas")可以像这样添加任意多个键值对儿。也可以通过向 FormData 构造函数中传入表单元素,也可以用表单元素的数据预先向其中填入键值对儿
var data = new FormData(document.forms[0])创建了 FormData 的实例后,可以将它直接传给 XHR 的 send() 方法
var xhr = createXHR()
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText)
} else {
alert("Request was unsuccessful: " + xhr.status)
}
}
}
xhr.open("post", "postexample.php", true)
var form = document.getElementById("user-info")
xhr.send(new FormData(form))var formElement = document.querySelector("form#login")
// 表单数据对象
var data = new FormData(formElement)
// 额外文本内容
data.append("key", "value")
// 额外文件内容
data.append("file", dom.files[0])
var xhr = new XMLHttpRequest()
xhr.open("POST", "/api/upload")
xhr.send(data)
xhr.onload = function () {
console.log(this.responseText)
}timeout 属性,表示请求在等待响应多少毫秒之后就终止。在给 timeout 设置一个数值后,如果在规定的时间内浏览器还没有接收到响应,那么就会触发 timeout 事件,进而会调用 ontimeout 事件处理程序。这项功能后来也被收入了 XMLHttpRequest 2 级规范中
var xhr = createXHR()
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
try {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText)
} else {
alert("Request was unsuccessful: " + xhr.status)
}
} catch (ex) {
//假设由 ontimeout 事件处理程序处理
}
}
}
xhr.open("get", "timeout.php", true)
xhr.timeout = 1000 //将超时设置为 1 秒钟(仅适用于 IE8+)
xhr.ontimeout = function () {
alert("Request did not return in a second.")
}
xhr.send(null)将 timeout 属性设置为 1000 毫秒,意味着如果请求在 1 秒钟内还没有返回,就会自动终止。请求终止时,会调用 ontimeout 事件处理程序
但此时 readyState 可能已经改变为 4 了,这意味着会调用 onreadystatechange 事件处理程序。可是,如果在超时终止请求之后再访问 status 属性,就会导致错误。为避免浏览器报告错误,可以将检查 status 属性的语句封装在一个 try-catch 语句当中
Firefox 最早引入了 overrideMimeType() 方法,用于重写 XHR 响应的 MIME 类型。这个方法后来也被纳入了 XMLHttpRequest 2 级规范。因为返回响应的 MIME 类型决定了 XHR 对象如何处理它,所以提供一种方法能够重写服务器返回的 MIME 类型是很有用的。
比如,服务器返回的 MIME 类型是 text/plain ,但数据中实际包含的是 XML。根据 MIME 类型,即使数据是 XML,responseXML 属性中仍然是 null。通过调用 overrideMimeType()方法,可以保证把响应当作 XML 而非纯文本来处理
var xhr = createXHR()
xhr.open("get", "text.php", true)
xhr.overrideMimeType("text/xml")
xhr.send(null)强迫 XHR 对象将响应当作 XML 而非纯文本来处理。调用 overrideMimeType() 必须在 send() 方法之前,才能保证重写响应的 MIME 类型
Progress Events 规范最初为 XHR 设计,如今也被部分其他 API 借鉴(例如 <img>、<video>、FileReader)。目前 Fetch API 还没有原生上传进度事件,仅能通过 Streams 监控下载进度,因此监听上传百分比时仍需回退到 XHR 或第三方封装。
Progress Events 规范是 W3C 的一个工作草案,定义了与客户端服务器通信有关的事件。这些事件最早其实只针对 XHR 操作,但目前也被其他 API 借鉴。有以下 6 个进度事件
每个请求都从触发 loadstart 事件开始,接下来是一或多个 progress 事件,然后触发 error、abort 或 load 事件中的一个,最后以触发 loadend 事件结束
Firefox 最初在实现 XHR 的时候,曾致力于简化交互模式。最终,增加了一个 load 事件用于替代 readystatechange 事件。load 事件在响应接收完成后立即触发,这样就不用检查 readyState 属性了。
onload 事件处理程序会收到一个 event 对象,其 target 属性设置为 XHR 实例,在这个实例上可以访问所有 XHR 对象属性和方法。不过,并不是所有浏览器都实现了这个事件的 event 对象。考虑到跨浏览器兼容,还是需要像下面这样使用 XHR 对象变量:
let xhr = new XMLHttpRequest()
xhr.onload = function () {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText)
} else {
alert("Request was unsuccessful: " + xhr.status)
}
}
xhr.open("get", "altevents.php", true)
xhr.send(null)只要是从服务器收到响应,无论状态码是什么,都会触发 load 事件。这意味着还需要检查 status 属性才能确定数据是否有效。Firefox、Opera、Chrome 和 Safari 都支持 load 事件
Mozilla 在 XHR 对象上另一个创新是 progress 事件,在浏览器接收数据期间,这个事件会反复触发。每次触发时,onprogress 事件处理程序都会收到 event 对象,其 target 属性是 XHR 对象,且包含 3 个额外属性:lengthComputable、position 和 totalSize。
其中,lengthComputable 是一个布尔值,表示进度信息是否可用;position 是接收到的字节数;totalSize 是响应的 Content Length 头部定义的总字节数。(注:position/totalSize 是早期实现的历史属性名,现行 ProgressEvent 标准属性为 loaded/total,如下方示例所示。)有了这些信息,就可以给用户提供进度条了
var xhr = new XMLHttpRequest()
xhr.open("GET", "/time")
xhr.onload = function () {
// onload readyState => 4
// 只在请求完成时触发
console.log(this.readyState)
}
xhr.onprogress = function (e) {
// onprogress readyState => 3
// 只在请求进行中触发
console.log(this.readyState)
var divStatus = document.getElementById("status")
if (e.lengthComputable) {
divStatus.innerHTML = "Received " + e.position + " of " + e.totalSize + " bytes"
}
// e.loaded 在周期性调用中接受到了多少信息。
// e.total 该请求一共有多少信息。
}
xhr.send(null)为确保正常执行,必须在调用 open() 方法之前添加 onprogress 事件处理程序。在前面的例子中,每次触发 progress 事件,都会以新的状态信息更新 HTML 元素的内容。如果响应头部中包含 Content-Length 字段,那么也可以利用此信息来计算从响应中已经接收到的数据的百分比
通过 XHR 进行 Ajax 通信的一个主要限制是跨源安全策略。默认情况下,XHR 只能访问与发起请求的页面在同一个域内的资源。这个安全限制可以防止某些恶意行为。不过,浏览器也需要支持合法跨源访问的能力。Fetch API 同样遵循 CORS 规范,可以通过 mode、credentials、headers 等选项精确声明跨源行为。
跨源资源共享(CORS,Cross-Origin Resource Sharing)定义了浏览器与服务器如何实现跨源通信。CORS 背后的基本思路就是使用自定义的 HTTP 头部允许浏览器和服务器相互了解,以确实请求或响应应该成功还是失败。
对于简单的请求,比如 GET 或 POST 请求,没有自定义头部,而且请求体是 text/plain 类型,这样的请求在发送时会有一个额外的头部叫 Origin。Origin 头部包含发送请求的页面的源(协议、域名和端口),以便服务器确定是否为其提供响应。下面是 Origin 头部的一个示例:
确切地说,满足以下全部条件的请求才是"简单请求",浏览器不会为其发送预检:
- 请求方法为 GET、HEAD 或 POST;
- 仅使用 CORS 安全的请求头部:Accept、Accept-Language、Content-Language,以及 Content-Type(其值仅限 application/x-www-form-urlencoded、multipart/form-data、text/plain 三种);
- 请求未给 XMLHttpRequestUpload(fetch 中为上传流)注册事件监听器,也未使用 ReadableStream 请求体。
Origin: http://www.nczonline.net如果服务器决定响应请求,那么应该发送 Access-Control-Allow-Origin 头部,包含相同的源;或者如果资源是公开的,那么就包含"*"。比如:
Access-Control-Allow-Origin: http://www.nczonline.net如果没有这个头部,或者有但源不匹配,则表明不会响应浏览器请求。否则,服务器就会处理这个请求。注意,无论请求还是响应都不会包含 cookie 信息。
现代浏览器通过 XMLHttpRequest 对象原生支持 CORS。在尝试访问不同源的资源时,这个行为会被自动触发。要向不同域的源发送请求,可以使用标准 XHR 对象并给 open()方法传入一个绝对 URL,比如:
let xhr = new XMLHttpRequest()
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText)
} else {
alert("Request was unsuccessful: " + xhr.status)
}
}
}
xhr.open("get", "http://www.somewhere-else.com/page/", true)
xhr.send(null)跨域 XHR 对象允许访问 status 和 statusText 属性,也允许同步请求。出于安全考虑,浏览器对跨域请求的响应也会施加一些额外限制。
withCredentials = true,且服务器返回 Access-Control-Allow-Credentials: true(见下节"凭据请求")Access-Control-Expose-Headers 显式暴露因为无论同域还是跨域请求都使用同一个接口,所以最好在访问本地资源时使用相对 URL,在访问远程资源时使用绝对 URL。这样可以更明确地区分使用场景,同时避免出现访问本地资源时出现头部或 cookie 信息访问受限的问题
CORS 通过一种叫预检请求(preflighted request)的服务器验证机制,允许使用自定义头部、除 GET 和 POST 之外的方法,以及不同请求体内容类型。在要发送涉及上述某种高级选项的请求时,会先向服务器发送一个“预检”请求。这个请求使用 OPTIONS 方法发送并包含以下头部
下面是一个假设的 POST 请求,包含自定义的 NCZ 头部:
Origin: http://www.nczonline.net
Access-Control-Request-Method: POST
Access-Control-Request-Headers: NCZ在这个请求发送后,服务器可以确定是否允许这种类型的请求。服务器会通过在响应中发送如下头部与浏览器沟通这些信息。
例如:
Access-Control-Allow-Origin: http://www.nczonline.net
Access-Control-Allow-Methods: POST, GET
Access-Control-Allow-Headers: NCZ
Access-Control-Max-Age: 1728000预检请求返回后,结果会按响应指定的时间缓存一段时间。换句话说,只有第一次发送这种类型的请求时才会多发送一次额外的 HTTP 请求
默认情况下,跨源请求不提供凭据(cookie、HTTP 认证和客户端 SSL 证书)。可以通过将 withCredentials 属性设置为 true 来表明请求会发送凭据。如果服务器允许带凭据的请求,那么可以在响应中包含如下 HTTP 头部:
Access-Control-Allow-Credentials: true如果发送了凭据请求而服务器返回的响应中没有这个头部,则浏览器不会把响应交给 JavaScript(responseText 是空字符串,status 是 0,onerror()被调用)。注意,服务器也可以在预检请求的响应中发送这个 HTTP 头部,以表明这个源允许发送凭据请求
CORS 出现之前,实现跨源 Ajax 通信是有点麻烦的。开发者需要依赖能够执行跨源请求的 DOM 特性,在不使用 XHR 对象情况下发送某种类型的请求。虽然 CORS 目前已经得到广泛支持,但这些技术仍然没有过时,因为它们不需要修改服务器
图片探测是利用 <img> 标签实现跨域通信的最早的一种技术。任何页面都可以跨域加载图片而不必担心限制,因此这也是在线广告跟踪的主要方式。可以动态创建图片,然后通过它们的 onload 和 onerror 事件处理程序得知何时收到响应。
这种动态创建图片的技术经常用于图片探测(image pings)。图片探测是与服务器之间简单、跨域、单向的通信。数据通过查询字符串发送,响应可以随意设置,不过一般是位图图片或值为 204 的状态码。
浏览器通过图片探测拿不到任何数据,但可以通过监听 onload 和 onerror 事件知道什么时候能接收到响应。下面看一个例子:
let img = new Image()
img.onload = img.onerror = function () {
alert("Done!")
}
img.src = "http://www.example.com/test?name=Nicholas"这个例子创建了一个新的 Image 实例,然后为它的 onload 和 onerror 事件处理程序添加了同一个函数。这样可以确保请求完成时无论什么响应都会收到通知。设置完 src 属性之后请求就开始了,这个例子向服务器发送了一个 name 值。
图片探测频繁用于跟踪用户在页面上的点击操作或动态显示广告。当然,图片探测的缺点是只能发送 GET 请求和无法获取服务器响应的内容。这也是只能利用图片探测实现浏览器与服务器单向通信的原因
JSONP 是“JSON with padding”的简写,是在 Web 服务上流行的一种 JSON 变体。JSONP 看起来跟 JSON 一样,只是会被包在一个函数调用里,比如:
callback({ name: "Nicholas" })JSONP 格式包含两个部分:回调和数据。回调是在页面接收到响应之后应该调用的函数,通常回调函数的名称是通过请求来动态指定的。而数据就是作为参数传给回调函数的 JSON 数据。下面是一个典型的 JSONP 请求:
http://freegeoip.net/json/?callback=handleResponse这个 JSONP 请求的 URL 是一个地理位置服务。JSONP 服务通常支持以查询字符串形式指定回调函数的名称。比如这个例子就把回调函数的名字指定为 handleResponse()。
JSONP 调用是通过动态创建 <script> 元素并为 src 属性指定跨域 URL 实现的。此时的 <script> 与 <img> 元素类似,能够不受限制地从其他域加载资源。因为 JSONP 是有效的 JavaScript,所以 JSONP 响应在被加载完成之后会立即执行。比如下面这个例子:
function handleResponse(response) {
console.log(`
You're at IP address ${response.ip}, which is in
${response.city}, ${response.region_name}`)
}
let script = document.createElement("script")
script.src = "http://freegeoip.net/json/?callback=handleResponse"
document.body.insertBefore(script, document.body.firstChild)JSONP 由于其简单易用,在开发者中非常流行。相比于图片探测,使用 JSONP 可以直接访问响应,实现浏览器与服务器的双向通信。
不过 JSONP 也有一些缺点。
<script> 元素的 onerror 事件处理程序(现代浏览器均已实现),但仅凭它难以准确判断失败原因,为此开发者经常使用计时器来决定是否放弃等待响应。这种方式并不准确,毕竟不同用户的网络连接速度和带宽是不一样的Web Socket(套接字)的目标是通过一个长时连接实现与服务器全双工、双向的通信。在 JavaScript 中创建 Web Socket 时,一个 HTTP 请求会发送到服务器以初始化连接。服务器响应后,连接使用 HTTP 的 Upgrade 头部从 HTTP 协议切换到 Web Socket 协议。这意味着 Web Socket 不能通过标准 HTTP 服务器实现,而必须使用支持该协议的专有服务器
因为 Web Socket 使用了自定义协议,所以 URL 方案(scheme)稍有变化:不能再使用 http:// 或 https:// ,而要使用 ws:// 和 wss:// 。前者是不安全的连接,后者是安全连接。在指定 Web Socket URL 时,必须包含 URL 方案,因为将来有可能再支持其他方案。
使用自定义协议而非 HTTP 协议的好处是,客户端与服务器之间可以发送非常少的数据,不会对 HTTP 造成任何负担。使用更小的数据包让 Web Socket 非常适合带宽和延迟问题比较明显的移动应用。
使用自定义协议的缺点是,定义协议的时间比定义 JavaScript API 要长。Web Socket 得到了所有主流浏览器支持
要创建一个新的 Web Socket,就要实例化一个 WebSocket 对象并传入提供连接的 URL:
let socket = new WebSocket("ws://www.example.com/server.php")注意,必须给 WebSocket 构造函数传入一个绝对 URL。同源策略不适用于 Web Socket,因此可以打开到任意站点的连接。至于是否与来自特定源的页面通信,则完全取决于服务器。(在握手阶段就可以确定请求来自哪里。)
浏览器会在初始化 WebSocket 对象之后立即创建连接。与 XHR 类似,WebSocket 也有一个 readyState 属性表示当前状态。不过,这个值与 XHR 中相应的值不一样。
WebSocket 对象没有 readystatechange 事件,而是有与上述不同状态对应的其他事件。
readyState 值从 0 开始。任何时候都可以调用 close()方法关闭 Web Socket 连接:
socket.close()调用 close()之后,readyState 立即变为 2(连接正在关闭),并会在关闭后变为 3(连接已经关闭)
打开 Web Socket 之后,可以通过连接发送和接收数据。要向服务器发送数据,使用 send() 方法并传入一个字符串、ArrayBuffer 或 Blob,如下所示:
let socket = new WebSocket("ws://www.example.com/server.php")
let stringData = "Hello world!"
let arrayBufferData = Uint8Array.from(["f", "o", "o"])
let blobData = new Blob(["f", "o", "o"])
socket.send(stringData)
socket.send(arrayBufferData.buffer)
socket.send(blobData)服务器向客户端发送消息时,WebSocket 对象上会触发 message 事件。这个 message 事件与其他消息协议类似,可以通过 event.data 属性访问到有效载荷:
socket.onmessage = function (event) {
let data = event.data
// 对数据执行某些操作
}与通过 send()方法发送的数据类似,event.data 返回的数据也可能是 ArrayBuffer 或 Blob。
这由 WebSocket 对象的 binaryType 属性决定,该属性可能是 "blob" 或 "arraybuffer"。
WebSocket 对象在连接生命周期中有可能触发 3 个其他事件
WebSocket 对象不支持 DOM Level 2 事件监听器,因此需要使用 DOM Level 0 风格的事件处理程序来监听这些事件:
let socket = new WebSocket("ws://www.example.com/server.php")
socket.onopen = function () {
alert("Connection established.")
}
socket.onerror = function () {
alert("Connection error.")
}
socket.onclose = function () {
alert("Connection closed.")
}在这些事件中,只有 close 事件的 event 对象上有额外信息。这个对象上有 3 个额外属性:wasClean、code 和 reason。
socket.onclose = function (event) {
console.log(`wasClean? ${event.wasClean} Code=${event.code} Reason=${event.reason}`)
}navigator.sendBeacon(url, data) 适合在页面卸载前上报日志、埋点或性能数据。它会异步、尽力发送数据,不会阻塞页面跳转,底层等同于一个带 keepalive 的 POST 请求。
const payload = JSON.stringify({
type: "perf",
ttfb: performance.getEntriesByType("navigation")[0].responseStart
})
window.addEventListener("unload", () => {
navigator.sendBeacon("/metrics", payload)
})支持发送 ArrayBufferView、Blob、FormData、URLSearchParams 等类型;如果需要保证送达并处理失败,可结合队列或重试机制。
EventSource 通过 HTTP 长连接持续接收服务端推送,浏览器自动重连,API 简单,适合实时性要求较低的场景(例如在线人数、股票行情、日志流):
const source = new EventSource("/stream")
source.onmessage = (event) => {
const data = JSON.parse(event.data)
console.log("收到推送", data)
}
source.onerror = (error) => {
console.error("SSE 发生错误", error)
}SSE 默认使用文本数据,支持自定义事件名称(event:),浏览器端只读。若需要双向通信或二进制数据,更适合选用 WebSocket。
keepalive: true 允许在页面即将关闭时继续发送请求(受 64KB 限制),常与日志上报结合。多数现代浏览器已支持。| 需求 | 推荐方案 |
|---|---|
| 单次或短期 HTTP 调用 | Fetch / XHR |
| 页面卸载前的日志上报 | sendBeacon 或 fetch(..., { keepalive: true }) |
| 服务端单向推送、文本流 | EventSource |
| 全双工实时通信、二进制流 | WebSocket |
| 离线后延迟发送 | Service Worker + Background Sync |
理解这些 API 的特性,可以在合适的时机组合使用,构建稳定、节能的网络层。
探讨 Ajax 安全的文章已经有了很多,事实上也出版了很多专门讨论这个话题的书。大规模 Ajax 应用程序需要考虑的安全问题非常多,但在通用层面上一般需要考虑以下几个问题。
首先,任何 Ajax 可以访问的 URL,也可以通过浏览器或服务器访问,例如下面这个 URL:
/getuserinfo.php?id=23请求这个 URL,可以假定返回 ID 为 23 的用户信息。访问者可以将 23 改为 24 或 56,甚至其他任何值。getuserinfo.php 文件必须知道访问者是否拥有访问相应数据的权限。否则,服务器就会大门敞开,泄露所有用户的信息。
在未授权系统可以访问某个资源时,可以将其视为跨站点请求伪造(CSRF,cross-site request forgery)攻击。未授权系统会按照处理请求的服务器的要求伪装自己。Ajax 应用程序,无论大小,都会受到 CSRF 攻击的影响,包括无害的漏洞验证攻击和恶意的数据盗窃或数据破坏攻击。
关于安全防护 Ajax 相关 URL 的一般理论认为,需要验证请求发送者拥有对资源的访问权限。可以通过如下方式实现。
注意,以下手段对防护 CSRF 攻击是无效的
理解底层 API 后,可以根据项目需求封装统一的请求层,以便集中处理鉴权、重试、日志、国际化错误等逻辑。以下示例分别展示基于 XHR 和 Fetch 的最小封装思想。
/**
* 发送一个 AJAX 请求
* @param {string} url 请求地址
* @param {string} method 请求方法
* @param {Object} params 请求参数
* @param {function} done 请求完成过后需要做的事情(委托/回调)
*/
function ajax(url, method, params, done) {
method = method.toUpperCase()
var pairs = []
for (var key in params) {
if (!params.hasOwnProperty(key)) continue
if (Object.prototype.toString.call(params[key]) === "[object Array]") {
for (var i = 0; i < params[key].length; i++) {
pairs.push(key + "[]=" + encodeURIComponent(params[key][i]))
}
} else {
pairs.push(key + "=" + encodeURIComponent(params[key]))
}
}
var querystring = pairs.join("&")
var url = method === "GET" ? url + (querystring ? "?" + querystring : "") : url
var xhr = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP")
var data = method === "GET" ? null : querystring
xhr.open(method, url)
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
done(JSON.parse(xhr.responseText))
}
}
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
xhr.send(data)
}
ajax("/getsomething", "get", { id: 123 }, function (data) {
console.log(data)
})const defaultHeaders = {
Accept: "application/json",
"Content-Type": "application/json"
}
async function request(url, { method = "GET", headers, body, params, signal } = {}) {
const finalHeaders = { ...defaultHeaders, ...headers }
const query = params ? new URLSearchParams(params).toString() : ""
const finalURL = query ? `${url}${url.includes("?") ? "&" : "?"}${query}` : url
const response = await fetch(finalURL, {
method,
headers: finalHeaders,
body: body ? JSON.stringify(body) : undefined,
signal
})
// 统一处理非 2xx 响应
if (!response.ok) {
throw new Error(`请求失败: ${response.status} ${response.statusText}`)
}
// 自动解析 JSON
return response.json()
}
// 使用
try {
const data = await request("/api/articles", { params: { page: 1 } })
console.log(data)
} catch (error) {
console.error(error)
}该封装支持查询参数、JSON 请求体、自动解析响应、抛出错误,可继续扩展 Token 续签、重试、缓存等能力
http 模块,提供请求/响应拦截器、取消令牌、转换器、进度事件等能力实践中,可根据团队技术栈与浏览器兼容性选择合适的库;理解底层 API 有助于定位异常和扩展功能