前端领域中网络请求是一个绕不开的话题,目前来说,有两种流行的方式可以做到: Ajax、 Fetch API。
本文将探讨这两种工具各自的特点和区别。
Ajax 是一个成熟但在当下(2020)有些过时的技术,其核心是 XMLHttpRequest 对象。出于理解的需要,对其进行整理。
MDN 资源参考:
Ajax 全称 Asynchronous JavaScript + XML(异步 JavaScript 和 XML)。其本身不是一种新技术,而是一个在 2005 年被 Jesse James Garrett 提出的新术语,用来描述一种使用现有技术集合的‘新’方法,包括: HTML 或 XHTML, CSS, JavaScript, DOM, XML, XSLT, 以及最重要的 XMLHttpRequest。
XMLHttpRequest 之所以使用“XML”开头,是因为在它诞生之时用于异步数据交换的主要格式便是 XML。不过现在它能传递的数据不只是 XML,还有 JSON 这些格式。
它的 API 也略显古老,下面是一个例子:
const xhr = new XMLHttpRequest();
// 请求配置
xhr.open('GET', '/example/load');
// 通过网络发送请求
xhr.send(null);
// 响应事件,老版本的 API 可以设置 onreadystatechange 方法
xhr.onload = function() {
if (xhr.status === 200) {
// do something....
}
};
// 进度条事件
xhr.onprogress = function(event) {
if (event.lengthComputable) {
alert(`Received ${event.loaded} of ${event.total} bytes`);
}
};
// 错误事件
xhr.onerror = function() {
// do somthing...
};
在 xhr.onload 用于处理请求完成后的逻辑。在 onload 中 可以读到 xhr 对象的属性:
xhr.responseType 可以设置响应格式:
xhr.readyState 指示 xhr 自身状态的变化:
XMLHttpRequest 对象以 0 → 1 → 2 → 3 → … → 3 → 4 的顺序在它们之间转变。每当通过网络接收到一个数据包,就会重复一次状态 3。
我们可以使用 readystatechange 事件来跟踪它们:
// 不推荐使用
xhr.onreadystatechange = function() {
if (xhr.readyState == 3) {
// 加载中
}
if (xhr.readyState == 4) {
// 请求完成
}
};
注意: 在旧的脚本中,可能会看到 xhr.responseText 、 xhr.responseXML、readyState、onreadystatechange 属性的使用,它们是由于历史原因而存在的,现在早就被 xhr.response、onload、onerror 事件监听器替代。
onprogress 用于指示进度信息。
在浏览器接收数据期间,这个事件会反复触 发。每次触发时,onprogress 事件处理程序都会收到 event 对象。有了这些信息,就可以给用户提供进度条了。
在 onprogress 中 event 主要属性:
在 ajax 中,如果需要终止请求,则可以使用:
xhr.abort(); // 终止请求
xhr.onbort = function() {
// 终止事件的处理
}
XMLHttpRequest 允许发送自定义 header,并且可以从响应中读取 header。
header 有三种方法:
使用给定的 name 和 value 设置 request header。
xhr.setRequestHeader('Content-Type', 'application/json');
获取具有给定 name 的 header(Set-Cookie 和 Set-Cookie2 除外)。
xhr.getResponseHeader('Content-Type')
返回除 Set-Cookie 和 Set-Cookie2 外的所有 response header。
Cache-Control: max-age=31536000
Content-Length: 4260
Content-Type: image/png
Date: Sat, 08 Sep 2012 16:53:16 GMT
要建立一个 POST 请求,我们可以使用内建的 FormData 对象。
<form name="person">
<input name="name" value="John">
<input name="surname" value="Smith">
</form>
<script>
// 从表单预填充 FormData
let formData = new FormData(document.forms.person);
// 附加一个字段
formData.append("middle", "Lee");
// 将其发送出去
let xhr = new XMLHttpRequest();
xhr.open("POST", "/article/xmlhttprequest/post/user");
xhr.send(formData);
xhr.onload = () => alert(xhr.response);
</script>
这将以 multipart/form-data 编码发送表单。
或者也可以用 json 格式发送:
let xhr = new XMLHttpRequest();
let json = JSON.stringify({
name: "John",
surname: "Smith"
});
xhr.open("POST", '/submit')
xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');
xhr.send(json);
.send(body) 方法几乎可以发送任何 body,包括 Blob 和 BufferSource 对象。
onprogress 事件仅在下载阶段触发,如果我们需要跟踪 POST 上传阶段的进度情况,可以使用 xhr.upload。
它会生成事件,类似于 xhr,但是 xhr.upload 仅在上传时触发它们:
xhr.upload.onprogress = function(event) {
alert(`Uploaded ${event.loaded} of ${event.total} bytes`);
};
xhr.upload.onload = function() {
alert(`Upload finished successfully.`);
};
xhr.upload.onerror = function() {
alert(`Error during the upload: ${xhr.status}`);
};
XMLHttpRequest 原生支持 CORS 策略进行跨域请求。
默认情况下,跨域请求不提供凭据(cookie、HTTP 认证和客户端 SSL 证书)。要启用它们,可以将 xhr.withCredentials 设置为 true:
let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open('POST', 'http://anywhere.com/request');
...
如果服务器允许带凭据的请求,那么可以在响应中包含如下 HTTP 头部: Access-Control-Allow-Credentials: true。
如果发送了凭据请求而服务器返回的响应中没有这个头部,则浏览器不会把响应交给 JavaScript (responseText 是空字符串,status 是 0,onerror 被调用)。注意,服务器也可以在预检请求的响应中发送这个 HTTP 头部,以表明这个源允许发送凭据请求。
另一点要注意,如果 XMLHttpRequest 请求设置了 withCredentials 属性,那么服务器不得设置 Access-Control-Allow-Origin 的值为 * 。
Fetch API 是新一代的网络请求接口。它能够执行 XMLHttpRequest 对象的所有任务,但更容易使用,基于 Promise 异步风格的接口相比于 XMLHttpRequest 丑陋的 API 设计更加现代化。同时这个 API 能够应用在服务线程 (service worker)中,提供拦截、重定向和修改通过 fetch() 生成的请求接口。
Fetch 发送一个请求的基本语法:
let promise = fetch(url, [options])
浏览器立即启动请求,并返回一个用来获取结果的 promise 对象。
获取响应主要是通过 promise 对象返回的内建的 response 实例进行解析:
let response = await fetch(url);
if (response.ok) { // 如果 HTTP 状态码为 200-299
// 获取 response body
let json = await response.json();
} else {
alert("HTTP-Error: " + response.status);
}
我们可以在 response 的属性检查响应状态:
这里要注意的是,使用 Fetch API,不管网络请求是成功的 2xx 还是失败的 5xx,只要有响应,就会进入到 Promise 的 resovle 流程中,只有在请求失败(服务器没有响应),才会 reject。所以要对于不同的请求失败要有不同的容错处理。
response 提供了多种基于 Promise 的方法,来以不同的格式访问 body 数据:
另外,response 自身还有一个 body 属性。它是 ReadableStream 对象,它允许你逐块读取 body。这个在需要对数据进行更细化地操控的时候才用得到,比如读取下载进度(见下文)。
注意: 只能选择一种读取 body 的方法。
如果已经使用了 response.text() 方法来获取 response,那么如果再用 response.json(),则不会生效,因为 body 内容已经被处理过了。
Response header 位于 response.headers 中的一个类似于 Map 的 header 对象。
它不是真正的 Map,但是它具有类似的方法,我们可以按名称(name)获取各个 header,或迭代它们:
let response = await fetch(url);
// 获取一个 header
console.log(response.headers.get('Content-Type')); // application/json; charset=utf-8
// 迭代所有 header
for (let [key, value] of response.headers) {
console.log(`${key} = ${value}`);
}
要在 fetch 中设置 request header,可以使用 headers 选项。它有一个带有输出 header 的对象,如下所示:
let response = fetch(protectedUrl, {
headers: {
Authentication: 'secret'
}
});
但是有一些无法设置的 header(详见 forbidden HTTP headers):
出于安全考虑,这些都是由浏览器控制。
要创建一个 POST 请求,或者其他方法的请求,需要使用 fetch option 选项:
let user = {
name: 'John',
surname: 'Smith'
};
let response = await fetch('/article/fetch/post/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify(user)
});
let result = await response.json();
alert(result.message);
其中 request body 可以是:
Fetch API 允许去跟踪下载进度,但无法跟踪上传进度。如果要用到上传进度,只能使用 XMLHttpRequest 对象。
要跟踪下载进度,我们可以使用 response.body 属性。它是 ReadableStream —— 一个特殊的对象,它可以逐块(chunk)提供 body。在 Streams API 规范中有对 ReadableStream 的详细描述。
与 response.text(),response.json() 和其他方法不同,response.body 给予了对进度读取的完全控制,可以随时计算下载了多少。
下面是一个例子:
// Step 1:启动 fetch,并获得一个 reader
let response = await fetch(url);
const reader = response.body.getReader();
// Step 2:获得总长度(length)
const contentLength = +response.headers.get('Content-Length');
// Step 3:读取数据
let receivedLength = 0; // 当前接收到了这么多字节
let chunks = []; // 接收到的二进制块的数组(包括 body)
while(true) {
const {done, value} = await reader.read();
if (done) {
break;
}
chunks.push(value);
receivedLength += value.length;
console.log(`Received ${receivedLength} of ${contentLength}`)
}
// Step 4:将块连接到单个 Uint8Array
let chunksAll = new Uint8Array(receivedLength); // (4.1)
let position = 0;
for(let chunk of chunks) {
chunksAll.set(chunk, position); // (4.2)
position += chunk.length;
}
// Step 5:解码成字符串
let result = new TextDecoder("utf-8").decode(chunksAll);
console.log( JSON.parse(result));
如上所述,fetch() 返回一个 promise。JavaScript 通常并没有“中止” promise 的概念。那么怎样才能取消一个正在执行的 fetch 呢?
为此可以使用一个特殊的内建对象:AbortController,它不仅可以中止 fetch,还可以中止其他异步任务。
// 创建 AbortController 的实例
const controller = new AbortController()
const signal = controller.signal
// 监听 abort 事件,在 controller.abort() 执行后执行回调打印
// 并且将 signal.aborted 设为 true
signal.addEventListener('abort', () => {
console.log(signal.aborted) // true
})
// 触发中断
controller.abort()
控制器是一个极其简单的对象。
当 abort() 被调用时:
配合 fetch 使用,当一个 fetch 被中止时,它的 promise 就会给出一个 error:AbortError 的 reject,使用:
// 1 秒后中止
let controller = new AbortController();
setTimeout(() => controller.abort(), 1000);
try {
let response = await fetch('/article/fetch-abort/demo/hang', {
signal: controller.signal
});
} catch(err) {
if (err.name == 'AbortError') { // handle abort()
alert("Aborted!");
} else {
throw err;
}
}
AbortController 还能一次性控制多个 fetch 异步请求,甚至自定义的异步任务:
const controller = new AbortController();
const signal = controller.signal;
// 自定义任务
const selfJob = new Promise((resolve, reject) => {
...
signal.addEventListener('abort', reject);
});
// 多个 fetch 任务
const urls = [...];
const fetchJobs = urls.map(url => fetch(url, {
signal
}));
// 等待完成我们的任务和所有 fetch
let results = await Promise.all([...fetchJobs, selfJob]);
// 如果 controller.abort() 被从其他地方调用,
// 它将中止所有 fetch 和 selfJob
// controller.abort()
Fetch API 支持 CORS 跨域策略,可以在配置项目中这样配:
let response = await fetch(url, {
mode: 'cors'
})
mode 有如下选项:
区别在于,在通过构造函数手动创建 Request 实例时,默认为 cors; 否则默认为 no-cors。
这里总计一下 Ajax 和 Fetch 之特点和区别:
Ajax:
凭据(cookie)管理
Fetch:
凭据(cookie)管理,使用 credentials 配置
这样对比下来,发现除了少数几个功能外,其他的 Fetch API 都能做到。
另外提一点,很多文章说 fetch 请求默认不发送 cookie,但在高级程序第四版上明确说明 credentials 默认为 'same-origin',也就是同源会发送的,只是跨域默认不发送,这点和 Ajax 其实是一致的。