Fetch

基本用法

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

基本语法:

js
1let 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。

一个完整的请求示例:

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

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

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

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

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

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

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

Response header

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

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

Request header

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

js
1let 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 形式目前是最为广泛使用的:

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

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

发送图片

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

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

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

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

小结

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

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

换成promise形式则是这样的:

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

当解析 response header 时,我们可以获取到响应状态码response.status、response.ok、response.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——以 string,FormData,BufferSource,Blob 或 UrlSearchParams 对象的形式发送的数据(request body)

FormData

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

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

javascript
1let formData = new FormData([form])

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

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

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

js
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
7<script>
8  formElem.onsubmit = async (e) => {
9    e.preventDefault();
10
11    let response = await fetch('/article/formdata/post/user', {
12      method: 'POST',
13      body: new FormData(formElem)
14    });
15
16    let result = await response.json();
17
18    alert(result.message);
19  };
20</script>

FormData 方法

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

  • formData.append(name, value) —— 添加具有给定 name 和 value 的表单字段
  • 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方法

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

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

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

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

发送带有文件的表单

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

html
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
7<script>
8  formElem.onsubmit = async (e) => {
9    // 防止表单提交后页面刷新
10    e.preventDefault()
11
12    let response = await fetch('/article/formdata/post/user-avatar', {
13      method: 'POST',
14      body: new FormData(formElem),
15    })
16
17    let result = await response.json()
18
19    alert(result.message)
20  }
21</script>

发送具有 Blob 数据的表单

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

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

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

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

请注意 append 和 set 的区别。

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

其他方法是:

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

用 Fetch 实现下载进度

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

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

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

大概过程是这样的:

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

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

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

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

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

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

步骤是这样的:

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

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

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

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

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

用 Fetch 实现请求中止(Abort)

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

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

AbortController 对象用法

创建一个控制器:

js
1let controller = new AbortController()

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

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

当abort被调用时:

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

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

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

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

html
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属性,我们可以将AbortController 的 signal 属性传递进去:

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

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

javascript
1controller.abort()

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

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

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

并行取消多个 fetch

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

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

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

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

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