Fetch

新的通用网络请求是fetch()方法。

基本语法:

  1. let promise = fetch(url, [options])
  • url —访问的 URL
  • options —可选参数:method、header 等

如果不传 options,则默认为 get 请求。

fetch方法天然能够获取promise对象。

获取响应时,fetch 需要做以下事情:

  1. 服务器发送了响应头,fetch使用 Response class 对象对响应头进行解析,并且会返回一个被 Promise 包裹着的 response 实例。

    在这个阶段,我们可以检查响应头来确定请求是否成功。

    如果fetch没办法建立HTTP 请求,比如网络问题,那么会返回一个 reject 的 promise。

    异常的 HTTP 状态,比如404 或者 500 等实际上都跟服务端做了交互,那么不会导致 error。

    我们可以通过 response 属性来查看 HTTP 状态:

    • status ——HTTP 状态码
    • ok —— 布尔值,如果 HTTP 状态码是 200-299,则为true
  2. 上面的方法并没有获取到 response body,我们还需要调用方法来获取

    Response提供多种方法,用不同的格式来拿到 body

    • response.text() —— 读取 response,并以文本形式返回 response
    • response.json() —— 将 response 解析为 JSON
    • response.formData() —— 以 FormData 对象的形式返回 response
    • response.blob() —— 以 Blob形式返回 response
    • response.arrayBuffer() —— 以 ArrayBuffer形式返回 response

    response.body是可读流(ReadableStream)对象,我们可以逐块读取body。

一个完整的请求示例:

  1. let response = await fetch(url);
  2. if (response.ok) { // 如果 HTTP 状态码为 200-299
  3. // 获取 response body,将 body 转换成 json
  4. let json = await response.json();
  5. } else {
  6. alert("HTTP-Error: " + response.status);
  7. }

也可以只用 promise 语法,不使用 await

  1. fetch(url).then(response=>response.json()).then(...)

使用 fetch 来获取 blob 不需要像 XHR 一样指定 responseType,直接调用response.blob()即可:

  1. let response = await fetch('/article/fetch/logo-fetch.svg');
  2. let blob = await response.blob(); // 下载为 Blob 对象
  3. // 为其创建一个 <img>
  4. let img = document.createElement('img');
  5. img.style = 'position:fixed;top:10px;left:10px;width:100px';
  6. document.body.append(img);
  7. // 显示它
  8. img.src = URL.createObjectURL(blob);
  9. // 允许浏览器删除内存映射
  10. URL.revokeObjectURL(img.src)

我们只能选择一种读取 body 的方法。

如果我们已经使用了 response.text() 方法来获取 response,那么如果再用 response.json(),则不会生效,因为 body 内容已经被处理过了。

Response header

response header类似Map 对象,我们可以使用get()来获取到它的每一项header属性,也可以迭代它们:

  1. let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits');
  2. // 获取一个 header
  3. alert(response.headers.get('Content-Type')); // application/json; charset=utf-8
  4. // 迭代所有 header
  5. for (let [key, value] of response.headers) {
  6. alert(`${key} = ${value}`);
  7. }

Request header

fetch的第二个参数中,我们可以设置请求头。

  1. let response = fetch(protectedUrl, {
  2. headers: {
  3. Authentication: 'secret'
  4. }
  5. });

为了保证 HTTP 的正确性和安全性,以下属性只能由浏览器控制,我们不能手动发送:

forbidden-header-name

POST 请求

要创建 POST 请求,我们需要设置两个option:

  • method ——HTTP 方法
  • body —— 请求体,可以是:
    • 字符串(例如 JSON 编码的)
    • FormData 对象
    • Blob/BufferSource 发送二进制数据
    • URLSearchParams,以 x-www-form-urlencoded 编码形式发送数据,很少使用。

JSON 形式目前是最为广泛使用的:

  1. let user = {
  2. name: 'John',
  3. surname: 'Smith'
  4. };
  5. let response = await fetch('/article/fetch/post/user', {
  6. method: 'POST',
  7. headers: {
  8. 'Content-Type': 'application/json;charset=utf-8'
  9. },
  10. body: JSON.stringify(user)
  11. });
  12. let result = await response.json();

这里需要注意的点是,当我们发送的是字符串时,fetch 会默认替我们设置成text/plain;charset=UTF-8,所以如果我们想要发送 JSON,则必须指定对应的 Content-type

发送图片

下面的例子是通过fetch提交二进制数据。

我们先将canvas进行描绘,然后通过 canvasElement.toBlob将其转化成图片格式,最后发送给服务器。

  1. <body style="margin:0">
  2. <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas>
  3. <input type="button" value="Submit" onclick="submit()">
  4. <script>
  5. canvasElem.onmousemove = function(e) {
  6. let ctx = canvasElem.getContext('2d');
  7. ctx.lineTo(e.clientX, e.clientY);
  8. ctx.stroke();
  9. };
  10. async function submit() {
  11. let blob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));
  12. let response = await fetch('/article/fetch/post/image', {
  13. method: 'POST',
  14. body: blob
  15. });
  16. // 服务器给出确认信息和图片大小作为响应
  17. let result = await response.json();
  18. alert(result.message);
  19. }
  20. </script>
  21. </body>

这里不需要设置Content-Type header,因为 Blob 对象具有内建的类型(这里是 image/png,通过 toBlob 生成的)。对于 Blob 对象,这个类型就变成了 Content-Type 的值。

小结

典型的fetch请求需要做两个步骤:

  1. let response = await fetch(url, options); // 解析 response header
  2. let result = await response.json(); // 将 body 读取为 json

换成promise形式则是这样的:

  1. fetch(url, options)
  2. .then(response => response.json())
  3. .then(result => /* process result */)

当解析 response header 时,我们可以获取到响应状态码response.statusresponse.okresponse.headers等属性。

需要解析 response body 时,需要调用对应的方法:

  • response.text() —— 读取 response,并以文本形式返回 response
  • response.json() —— 将 response 解析为 JSON
  • response.formData() —— 以 FormData 对象的形式返回 response
  • response.blob() —— 以 Blob形式返回 response
  • response.arrayBuffer() —— 以 ArrayBuffer形式返回 response

当发送请求时,我们可以设置:

  • method——请求方法
  • headers——请求头
  • body——以 stringFormDataBufferSourceBlobUrlSearchParams 对象的形式发送的数据(request body)

FormData

FormData 对象是 HTML 表单数据的对象。

通过构造函数可以创建一个 formData 实例:

  1. let formData = new FormData([form]);

HTML 的 form 元素会自动捕获 form 元素字段

fetch 可以接受一个 formData 作为body。它被编码出去后,会带上Content-Type: multipart/form-data

下面例子中,采用FormData 构造器,接受 HTML 的表单元素作为参数,发送一个 formData 给服务端

  1. <form id="formElem">
  2. <input type="text" name="name" value="John">
  3. <input type="text" name="surname" value="Smith">
  4. <input type="submit">
  5. </form>
  6. <script>
  7. formElem.onsubmit = async (e) => {
  8. e.preventDefault();
  9. let response = await fetch('/article/formdata/post/user', {
  10. method: 'POST',
  11. body: new FormData(formElem)
  12. });
  13. let result = await response.json();
  14. alert(result.message);
  15. };
  16. </script>

FormData 方法

我们可以使用以下方法来给 FormData 增加字段:

  • formData.append(name, value) —— 添加具有给定 namevalue 的表单字段
  • formData.append(name, blob, fileName) —— 添加一个字段,就像它是 <input type="file">,第三个参数 fileName 设置文件名(而不是表单字段名),因为它是用户文件系统中文件的名称
  • formData.delete(name) —— 移除带有给定 name 的字段
  • formData.get(name) —— 获取带有给定 name 的字段值
  • formData.has(name) —— 如果存在带有给定 name 的字段,则返回 true,否则返回 false
  • formData.set(name,value)——同 append方法
  • formData.set(name,blob,fileName)——同 append方法

appendset方法的区别是append方法可以添加多个具有相同名称的字段。

set方法会移除所有name字段,并附加一个新字段。它能够确保只有一个name字段。

我们也可以使用for..of来循环迭代 formData 字段

  1. let formData = new FormData();
  2. formData.append('key1', 'value1');
  3. formData.append('key2', 'value2');
  4. // 列出 key/value 对
  5. for(let [name, value] of formData) {
  6. alert(`${name} = ${value}`); // key1 = value1,然后是 key2 = value2
  7. }

发送带有文件的表单

表单始终以 Content-Type: multipart/form-data 来发送数据,这个编码允许发送文件。因此 <input type="file"> 字段也能被发送,类似于普通的表单提交。

  1. <form id="formElem">
  2. <input type="text" name="firstName" value="John">
  3. Picture: <input type="file" name="picture" accept="image/*">
  4. <input type="submit">
  5. </form>
  6. <script>
  7. formElem.onsubmit = async (e) => {
  8. // 防止表单提交后页面刷新
  9. e.preventDefault();
  10. let response = await fetch('/article/formdata/post/user-avatar', {
  11. method: 'POST',
  12. body: new FormData(formElem)
  13. });
  14. let result = await response.json();
  15. alert(result.message);
  16. };
  17. </script>

发送具有 Blob 数据的表单

通常情况下,发送文件的方式不是单独发送,而是作为表单的一部分发送,并带有附加字段,例如:name

服务器通常更适合接收多部分编码的表单(multipart-encoded form),而不是原始的二进制数据。

下面改写一下在fetch中发送 canvas 生成的图片的示例代码:

  1. <body style="margin:0">
  2. <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas>
  3. <input type="button" value="Submit" onclick="submit()">
  4. <script>
  5. canvasElem.onmousemove = function(e) {
  6. let ctx = canvasElem.getContext('2d');
  7. ctx.lineTo(e.clientX, e.clientY);
  8. ctx.stroke();
  9. };
  10. async function submit() {
  11. let imageBlob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));
  12. let formData = new FormData();
  13. formData.append("firstName", "John");
  14. formData.append("image", imageBlob, "image.png");
  15. let response = await fetch('/article/formdata/post/image-form', {
  16. method: 'POST',
  17. body: formData
  18. });
  19. let result = await response.json();
  20. alert(result.message);
  21. }
  22. </script>
  23. </body>

在这里主要使用formData.append("image", imageBlob, "image.png");来添加 Blob

就像表单中有 <input type="file" name="image"> 一样,用户从他们的文件系统中使用数据 imageBlob(第二个参数)提交了一个名为 image.png(第三个参数)的文件。

服务器读取表单数据和文件,就好像它是常规的表单提交一样。

小结

FormData 对象用于捕获 HTML 表单,并使用fetch或者其他网络方法提交。

我们可以从 HTML 表单创建 new FormData(form),也可以自己创建一个完全没有表单的对象,然后给他添加字段。

添加字段的方式有以下几种:

  • formData.append(name, value)
  • formData.append(name, blob, fileName)
  • formData.set(name, value)
  • formData.set(name, blob, fileName)

请注意 appendset 的区别。

如果我们需要发送文件,那么就需要使用三个参数的语法,最后一个参数是文件名,通常是从文件系统获取的。

其他方法是:

  • formData.delete(name)
  • formData.get(name)
  • formData.has(name)

用Fetch实现下载进度

fetch方法没办法跟踪上传进度,但是可以跟踪下载进度

我们可以使用response.body属性,这是一个可读流——ReadableStream,它可以逐块(chunk)提供 body。

我们在下载时可以通过response.body.getReader()方法来获取流读取器,然后通过这个流读取器来计算下载了多少。

大概过程是这样的:

  1. // 代替 response.json() 以及其他方法
  2. const reader = response.body.getReader();
  3. // 在 body 下载时,一直为无限循环
  4. while(true) {
  5. // 当最后一块下载完成时,done 值为 true
  6. // value 是块字节的 Uint8Array
  7. const {done, value} = await reader.read();
  8. if (done) {
  9. break;
  10. }
  11. console.log(`Received ${value.length} bytes`)
  12. }

调用await reader.read()方法会得到两个属性的对象:

  • done ——读取完成则为true,否则为false
  • value——字节的类型化数组:Uint8Array

由于浏览器问题,上面循环异步迭代ReadableStream的方式不使用for await..of,而是while循环

简单来说,我们需要在循环中接受响应块(response chunk),直到加载完成,也就是 donetrue

要将进度打印出来,我们只需要将每个接收到的片段 value 的长度(length)加到 counter 即可。

步骤是这样的:

  1. // Step 1:启动 fetch,并获得一个 reader
  2. let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits?per_page=100');
  3. const reader = response.body.getReader();
  4. // Step 2:获得总长度(length)
  5. const contentLength = +response.headers.get('Content-Length');
  6. // Step 3:读取数据
  7. let receivedLength = 0; // 当前接收到了这么多字节
  8. let chunks = []; // 接收到的二进制块的数组(包括 body)
  9. while(true) {
  10. const {done, value} = await reader.read();
  11. if (done) {
  12. break;
  13. }
  14. chunks.push(value);
  15. receivedLength += value.length;
  16. console.log(`Received ${receivedLength} of ${contentLength}`)
  17. }
  18. // Step 4:将块连接到单个 Uint8Array
  19. let chunksAll = new Uint8Array(receivedLength); // 创建一个具有所有数据块合并后的长度的同类型数组。
  20. let position = 0;
  21. for(let chunk of chunks) {
  22. chunksAll.set(chunk, position); // 使用 .set(chunk, position) 方法,从数组中一个个地复制这些 chunk
  23. position += chunk.length;
  24. }
  25. // Step 5:解码成字符串
  26. let result = new TextDecoder("utf-8").decode(chunksAll);
  27. // 我们完成啦!
  28. let commits = JSON.parse(result);
  29. alert(commits[0].author.login);

步骤解读:

  1. 使用 fetch 获取数据,但不调用response.json(),而是获取一个流读取器(stream reader)response.body.getReader()

    要么使用流读取器,要么使用 response 方法,不能同时用两种方法读取相同响应。

  2. 在读取数据之前,从Content-Length中获取完整的响应长度。

  3. 调用 await reader.read(),直到结果为done:true

    这时候需要将响应收集到数组chunks中。因为我们不能再用response.json来读取响应内容,所以需要有个地方把它存起来。

  4. 拥有了chunks结果之后,实际上里面是一段一段的 Unit8Array的字节块。

    • 如果我们想要创建二进制内容(比如图片、文件),就可以使用Blob类来创建一个 Blob对象。Blob 类直接可以接收内含Unit8Array字节块的数组,这里可以写为:

      1. let blob = new Blob(chunks);
    • 如果我们想要做其他事情,比如将字节块解析成一段字符串。我们首先需要将这些字节块拼起来:

      这里使用一些代码来将其串联起来:

      • 创建chunksAll = new Uint8Array(receivedLength)——一个具有所有数据块合并后的长度的同类型数组
      • 使用.set(chunk,position)方法,从这些数组中挨个复制这些chunk
      • 最后将结果拷贝到chunksAll中,但它们是字节数组,并不是字符串,我们需要解析这些字节——可以使用内建的TextDecoder对象来完成。

以上,这就是使用fetch来跟踪下载进度的过程。

用Fetch实现请求中止(Abort)

JavaScript 没有中止Promise的概念。但我们可以取消fetch请求。

有一个特殊的内置对象AbortController,它不单可以中止fetch,还可以中止其他异步任务。

AbortController 对象用法

创建一个控制器:

  1. let controller = new AbortController();

控制器中有一个属性和一个方法:

  • abort()方法
  • signal属性,我们可以在这个属性上设置事件监听器

abort被调用时:

  • controller.signal会触发abort事件
  • controller.signal.aborted 属性变为 true

这个处理方式需要我们分两部分去做:

  1. controller.signal上设置一个监听器,里面放一个取消操作后的回调函数
  2. 调用controller.abort()来取消

下面是一个取消setTimeout的例子:

  1. <body>
  2. <button id="button">点击我取消异步任务</button>
  3. <script>
  4. let controller = new AbortController();
  5. let signal = controller.signal;
  6. let timer = setTimeout(() => {
  7. alert("这是 setTimeout 异步执行的任务");
  8. }, 3000);
  9. // 可取消的操作这一部分
  10. // 获取 "signal" 对象,
  11. // 并将监听器设置为在 controller.abort() 被调用时触发
  12. signal.addEventListener("abort", () => clearTimeout(timer));
  13. button.onclick = function () {
  14. // 另一部分,取消(在之后的任何时候):
  15. controller.abort(); // 中止!
  16. // 事件触发,signal.aborted 变为 true
  17. alert(signal.aborted); // true
  18. };
  19. </script>
  20. </body>

上面的代码会在点击 button 后取消setTimeout异步任务。

其实就是在abort()后触发监听器里面的函数,跟正常的发布订阅模式没有区别。

上面的实现完全没必要用到AbortController对象也可以实现。

但这个对象有意思的地方在于与 fetch 的集成。

与 fetch 集成实现取消请求的功能

fetch 的 options 参数可以接受一个signal属性,我们可以将AbortControllersignal 属性传递进去:

  1. let controller = new AbortController();
  2. fetch(url, {
  3. signal: controller.signal
  4. });

这时候fetch会监听signalabort 事件。当我们想要中止fetch时,这样调用:

  1. controller.abort();

然后fetch就从 signal 获取了事件并中止了请求。

fetch被中止时,它的promise就会reject一个nameAbortErrorerror,我们需要用try..catch进行捕获。

  1. // 1 秒后中止
  2. let controller = new AbortController();
  3. setTimeout(() => controller.abort(), 1000);
  4. try {
  5. let response = await fetch('/article/fetch-abort/demo/hang', {
  6. signal: controller.signal
  7. });
  8. } catch(err) {
  9. if (err.name == 'AbortError') { // handle abort()
  10. alert("Aborted!");
  11. } else {
  12. throw err;
  13. }
  14. }

并行取消多个 fetch

AbortController 是可伸缩的。它允许一次取消多个 fetch。

  1. let urls = [...]; // 要并行 fetch 的 url 列表
  2. let controller = new AbortController();
  3. // 一个 fetch promise 的数组
  4. let fetchJobs = urls.map(url => fetch(url, {
  5. signal: controller.signal
  6. }));
  7. let results = await Promise.all(fetchJobs);
  8. // controller.abort() 被从任何地方调用,
  9. // 它都将中止所有 fetch

上面的代码能够并行 fetch 很多个 urls,并使用单个控制器使其全部中止。

如果我们有自己的与 fetch 不同的异步任务,我们可以使用单个 AbortController 中止这些任务以及 fetch。

  1. let urls = [...];
  2. let controller = new AbortController();
  3. let ourJob = new Promise((resolve, reject) => { // 我们的任务
  4. ...
  5. controller.signal.addEventListener('abort', reject);
  6. });
  7. let fetchJobs = urls.map(url => fetch(url, { // fetches
  8. signal: controller.signal
  9. }));
  10. // 等待完成我们的任务和所有 fetch
  11. let results = await Promise.all([...fetchJobs, ourJob]);
  12. // controller.abort() 被从任何地方调用,
  13. // 它都将中止所有 fetch 和 ourJob

小结

  • AbortController是一个简单对象,当abort()方法被调用时,会调用自身signal属性监听的abort事件,并将singnal.aborted设置为true

  • signal可以传递给 fetch 的options.signal属性,这样 fetch 就能够监听到他,因此可以中断 fetch

  • 我们也可以在自己的代码中使用AbortController,先监听abort事件,在调用abort()方法后触发abort事件来中止某些任务

Fetch API

https://zh.javascript.info/fetch-api