网络请求与远程资源
Web 应用要想从服务器取得或写入数据,离不开浏览器提供的网络 API。早期我们主要依赖 XMLHttpRequest(XHR)实现 “AJAX” 异步刷新;如今 Fetch API、Streams、Service Worker 等能力不断成熟,已经成为前端网络编程的默认选择。本章会系统梳理它们的使用方式、差异与注意事项,帮助你在项目中选择合适的请求方案。
概览
- XHR(XMLHttpRequest):2000 年代起广泛使用,支持进度事件、同步请求等特性,但 API 相对冗长,Promise 支持需要额外封装
- Fetch API:现代浏览器默认方案,基于 Promise,语义清晰,天然适配
async/await,并与 Service Worker、Streams 等周边能力协作良好 - 其他网络能力:包括
navigator.sendBeacon、WebSocket、EventSource、HTTP/2 的服务器推送、跨源通信策略(CORS)等
实际项目中可遵循以下原则:
- 首选 Fetch 完成绝大多数 HTTP 请求,配合
async/await、AbortController处理超时与取消 - 保留 XHR 用于 Fetch 尚不支持的场景,例如监听上传/下载进度、兼容极老旧浏览器
- 理解底层原理,包括请求生命周期、缓存、跨源策略、错误处理和安全问题,才能在网络异常时快速定位并恢复
XMLHttpRequest 对象
虽然 Fetch 已成为默认选择,但了解 XHR 仍然重要:它提供上传/下载进度事件、同步请求、对旧版浏览器的兼容能力,并且大量遗留代码和第三方库依然基于它实现。本节回顾 XHR 的核心 API,帮助理解其与 Fetch 的差异以及在特殊场景下如何使用。
所有现代浏览器都通过 XMLHttpRequest 构造函数原生支持 XHR 对象:
let xhr = new XMLHttpRequest()使用 XHR
在使用 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 对象的属性
- responseText:作为响应主体被返回的文本
- responseXML:如果响应的内容类型是"text/xml"或"application/xml",这个属性中将保存包含着响应数据的 XML DOM 文档
- status:响应的 HTTP 状态
- statusText:HTTP 状态的说明
在接收到响应后,第一步是检查 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 头部信息
每个 HTTP 请求和响应都会带有相应的头部信息,其中有的对开发人员有用,有的也没有什么用。XHR 对象会通过一些方法暴露与请求和响应相关的头部字段
默认情况下,XHR 请求会发送以下头部字段
- Accept:浏览器能够处理的内容类型
- Accept-Charset:浏览器能够显示的字符集
- Accept-Encoding:浏览器能够处理的压缩编码
- Accept-Language:浏览器当前设置的语言
- Connection:浏览器与服务器之间连接的类型
- Cookie:当前页面设置的任何 Cookie
- Host:发出请求的页面所在的域
- Referer:发出请求的页面的 URI。注意,HTTP 规范将这个头部字段拼写错了,而为保证与规范一致,也只能将错就错了(这个英文单词的正确拼法应该是 referrer)
- User-Agent:浏览器的用户代理字符串
使用 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这种格式化的输出可以方便我们检查响应中所有头部字段的名称,而不必一个一个地检查某个字段是否存在
GET 请求
对 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 请求
通常用于向服务器发送应该被保存的数据。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() 方法第三个参数要求传入的是一个 bool 值,其作用就是设置此次请求是否采用异步方式执行,默认为 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(不管同步或者异步)
response 属性
以对象的形式表述响应体,其类型取决于 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 或 XML XMLDocument ,这取决于接收到的数据的 MIME 类型。请参阅 HTML in XMLHttpRequest 以了解使用 XHR 获取 HTML 内容的更多信息 |
| "json" | response 是一个 JavaScript 对象。这个对象是通过将接收到的数据类型视为 JSON 解析得到的 |
| "text" | response 是包含在 DOMString 对象中的文本 |
Get 请求缓存问题
缓存问题指的是:多次 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)
// => 每次得到的结果都是相同的
}URL 加戳
这个办法的核心就是让浏览器认为每次请求的地址都是不同的。
不同的 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())
})XMLHttpRequest 2.0
HTML5 中对 XMLHttpRequest 类型全面升级,更易用,更强大
FormData
现代 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 语句当中
overrideMineType 方法
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:在因为调用 abort()方法而终止连接时触发
- load:在接收到完整的响应数据时触发
- loadend:在通信完成或者触发 error、abort 或 load 事件后触发
每个请求都从触发 loadstart 事件开始,接下来是一或多个 progress 事件,然后触发 error、abort 或 load 事件中的一个,最后以触发 loadend 事件结束
load 事件
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 事件
progress 事件
Mozilla 在 XHR 对象上另一个创新是 progress 事件,在浏览器接收数据期间,这个事件会反复触发。每次触发时,onprogress 事件处理程序都会收到 event 对象,其 target 属性是 XHR 对象,且包含 3 个额外属性:lengthComputable、position 和 totalSize。
其中,lengthComputable 是一个布尔值,表示进度信息是否可用;position 是接收到的字节数;totalSize 是响应的 Content Length 头部定义的总字节数。有了这些信息,就可以给用户提供进度条了
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 (event.lengthComputable) {
divStatus.innerHTML = "Received " + event.position + " of " + event.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 头部的一个示例:
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 属性,也允许同步请求。出于安全考虑,跨域 XHR 对象也施加了一些额外限制。
- 不能使用 setRequestHeader() 设置自定义头部
- 不能发送和接收 cookie
- getAllResponseHeaders() 方法始终返回空字符串
因为无论同域还是跨域请求都使用同一个接口,所以最好在访问本地资源时使用相对 URL,在访问远程资源时使用绝对 URL。这样可以更明确地区分使用场景,同时避免出现访问本地资源时出现头部或 cookie 信息访问受限的问题
预检请求
CORS 通过一种叫预检请求(preflighted request)的服务器验证机制,允许使用自定义头部、除 GET 和 POST 之外的方法,以及不同请求体内容类型。在要发送涉及上述某种高级选项的请求时,会先向服务器发送一个“预检”请求。这个请求使用 OPTIONS 方法发送并包含以下头部
- Origin:与简单请求相同
- Access-Control-Request-Method:请求希望使用的方法
- Access-Control-Request-Headers:(可选)要使用的逗号分隔的自定义头部列表
下面是一个假设的 POST 请求,包含自定义的 NCZ 头部:
Origin: http://www.nczonline.net
Access-Control-Request-Method: POST
Access-Control-Request-Headers: NCZ在这个请求发送后,服务器可以确定是否允许这种类型的请求。服务器会通过在响应中发送如下头部与浏览器沟通这些信息。
- Access-Control-Allow-Origin:与简单请求相同。
- Access-Control-Allow-Methods:允许的方法(逗号分隔的列表)。
- Access-Control-Allow-Headers:服务器允许的头部(逗号分隔的列表)。
- Access-Control-Max-Age:缓存预检请求的秒数。
例如:
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
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 也有一些缺点。
- 首先,JSONP 是从不同的域拉取可执行代码。如果这个域并不可信,则可能在响应中加入恶意内容。此时除了完全删除 JSONP 没有其他办法。在使用不受控的 Web 服务时,一定要保证是可以信任的。
- 第二个缺点是不好确定 JSONP 请求是否失败。虽然 HTML5 规定了
<script>元素的 onerror 事件处理程序,但还没有被任何浏览器实现。为此,开发者经常使用计时器来决定是否放弃等待响应。这种方式并不准确,毕竟不同用户的网络连接速度和带宽是不一样的
Web Socket
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 得到了所有主流浏览器支持
API
要创建一个新的 Web Socket,就要实例化一个 WebSocket 对象并传入提供连接的 URL:
let socket = new WebSocket("ws://www.example.com/server.php")注意,必须给 WebSocket 构造函数传入一个绝对 URL。同源策略不适用于 Web Socket,因此可以打开到任意站点的连接。至于是否与来自特定源的页面通信,则完全取决于服务器。(在握手阶段就可以确定请求来自哪里。)
浏览器会在初始化 WebSocket 对象之后立即创建连接。与 XHR 类似,WebSocket 也有一个 readyState 属性表示当前状态。不过,这个值与 XHR 中相应的值不一样。
- WebSocket.OPENING(0):连接正在建立
- WebSocket.OPEN(1):连接已经建立
- WebSocket.CLOSING(2):连接正在关闭
- WebSocket.CLOSE(3):连接已经关闭
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 个其他事件
- open:在连接成功建立时触发
- error:在发生错误时触发。连接无法存续
- close:在连接关闭时触发
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。
- wasClean 是一个布尔值,表示连接是否干净地关闭
- code 是一个来自服务器的数值状态码
- reason 是一个字符串,包含服务器发来的消息。可以将这些信息显示给用户或记录到日志
socket.onclose = function (event) {
console.log(`as clean? ${event.wasClean} Code=${event.code} Reason=${event.reason}`)
}其他浏览器网络 API
navigator.sendBeacon
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 等类型;如果需要保证送达并处理失败,可结合队列或重试机制。
Server-Sent Events(EventSource)
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。
Fetch Keepalive 与 Background Sync
- Keepalive:Fetch 的
keepalive: true允许在页面即将关闭时继续发送请求(受 64KB 限制),常与日志上报结合。多数现代浏览器已支持。 - Background Sync:Service Worker 的后台同步可在设备重新联网后重试失败请求,适用于离线提交表单、消息草稿等场景。需要注册 Service Worker 并在后台事件中使用 Fetch。
选择指南
| 需求 | 推荐方案 |
|---|---|
| 单次或短期 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 的一般理论认为,需要验证请求发送者拥有对资源的访问权限。可以通过如下方式实现。
- 要求通过 SSL 访问能够被 Ajax 访问的资源。
- 要求每个请求都发送一个按约定算法计算好的令牌(token)
注意,以下手段对防护 CSRF 攻击是无效的
- 要求 POST 而非 GET 请求(很容易修改请求方法)
- 使用来源 URL 验证来源(来源 URL 很容易伪造)
- 基于 cookie 验证(同样很容易伪造)
封装请求工具
理解底层 API 后,可以根据项目需求封装统一的请求层,以便集中处理鉴权、重试、日志、国际化错误等逻辑。以下示例分别展示基于 XHR 和 Fetch 的最小封装思想。
基于 XHR 的封装(兼容老环境)
/**
* 发送一个 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)
})基于 Fetch 的封装(现代浏览器)
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 续签、重试、缓存等能力
第三方库:Axios、Ky 等
- Axios:默认基于 XHR(浏览器)和 Node.js
http模块,提供请求/响应拦截器、取消令牌、转换器、进度事件等能力 - Ky:轻量封装 Fetch,默认返回 Promise,内置重试、超时、JSON 解析
- SWR、React Query:在 Fetch/Axios 之上提供缓存、状态管理、失效策略等高级数据获取模式
实践中,可根据团队技术栈与浏览器兼容性选择合适的库;理解底层 API 有助于定位异常和扩展功能