Jansiel Notes

使用XHR和fetch发送请求,监控请求发送及响应接收进度

零、前言

在现代web项目中,我们一般使用XHR(XMLHttpRequest)或fetch发送http请求,以实现网页在不刷新的情况下获取数据(即实现AJAX)。

本文讨论的主题是:使用上述两种方式发送http请求时,如何监控请求发送和响应接收的进度(这在传输数据量较大或网络环境较差的情况下会是一个对用户友好的功能)。

一、使用XHR实现

使用XHR发送http请求是现在较多项目的选择,它有一个闻名的二次封装库——axios。但本文并不讨论基于axios实现需求,而是使用原生的XHR方式。

1-1 监控请求发送进度

使用xhr监控请求发送进度,有两个需要注意的关键词:

  1. upload 对象
  2. progress 事件。

upload对象是xhr实例中的属性,它用于表示上传的进程,而同时这个对象可以用来添加事件监听器,监听progress事件,就能获得我们需要的信息。

1// 监控请求发送进度
2xhr.upload.addEventListener('progress', (e) => {
3  // loaded属性代表已经传输的数据量,total代表需要传输的总数据量。
4  // 单位是字节
5  console.log(e.loaded, e.total)
6  console.log( (e.loaded / e.total * 100).toFixed(2) + '%' )
7})
8

progress事件是在数据传输过程中周期性触发,并不是每传输一个数据包触发一次,所以并非100%实时,但这足够向用户反映进度。

1-2 监控响应接收进度

与监控请求发送的进度类似,监听响应接收的进度也是使用事件监听器 progress,但是添加的对象就不是xhr.upload对象,而是 xhr 对象本身。

1xhr.addEventListener('progress', (e) => {
2  // loaded属性代表已经接收的数据量,total代表需要接收的总数据量。
3  // 单位是字节
4  console.log(e.loaded, e.total)
5  console.log( (e.loaded / e.total * 100).toFixed(2) + '%' )
6})
7

此progress事件也是在数据传输过程中周期性触发

1-3 完整代码

旨在方便理解本文主题,如下函数封装中省略了很多现实中需要处理的情况(如检查http状态码等)。

 1function request(options = {}) {
 2  const { url, method = 'GET', data = null } = options
 3
 4  return new Promise((resolve, reject) => {
 5    const xhr = new XMLHttpRequest()
 6
 7    xhr.addEventListener('readystatechange', () => {
 8      // 数据完成传输,请求已经完成(无论成功或是失败)
 9      if (xhr.readyState === 4) {
10        resolve(xhr.responseText)
11      }
12    })
13
14    // 监控请求发送进度
15    xhr.upload.addEventListener('progress', e => {
16      // 单位是字节
17      console.log(e.loaded, e.total)
18      console.log( (e.loaded / e.total * 100).toFixed(2) + '%' )
19    })
20
21    // 进度事件,当服务器数据发送过来时,每过来一部分,就触发一次此事件
22    xhr.addEventListener('progress', (e) => {
23      // 单位是字节
24      console.log(e.loaded, e.total)
25      console.log( (e.loaded / e.total * 100).toFixed(2) + '%' )
26    })
27
28    xhr.open(method, url)
29    xhr.send(data)
30  })
31}
32

二、使用fetch实现

fetch是一个比XHR更加现代的网络请求api,但因为其原生能力的限制,使用fetch发送请求时,只能监控响应接收进度, 不能 监控请求发送的进度。

2-1 监控响应接收进度

fetch并没有提供像XHR中的loaded和total属性来直接展示现时数据传输量,而是需要手动计算得出结果。

1async function request(){
2    const response = await fetch(url)
3}
4

上述代码展示了使用fetch发送请求的情况,其返回值response是一个Promise对象,并且它会 在响应头接收完之后被解析为resolve(即使服务器返回的http状态码为4xx或5xx)

2-1-1 获取响应体中的总数据量

读取响应头中content-length,并将其转换成Number类型,可以得出服务器将要往浏览器传输的数据量。

1async function request(){
2    const response = await fetch(url)
3
4    // 获取响应头中的content-length,代表响应体的总数据量有多少(单位字节)
5    const total = +response.headers.get('content-length');
6}
7

2-1-2 计算目前接收的数据量

因为fetch可以流式读取响应体,所以我们可以实时累加已经接收的数据量

 1async function request(){
 2    const response = await fetch(url)
 3
 4    // response.body是一个可读流,调用其读取器getReader。
 5    const reader = response.body.getReader()
 6    // 目前已经接收的数据量(单位字节)
 7    let loaded = 0
 8
 9    while(true){
10      // 这里读的不是整个响应体,而是目前来的这一部分。done表示响应体是否已经传输完毕
11      const { done, value } = await reader.read()
12      if(done) {
13        break;
14      }
15      // 将每次接收的数据量(单位字节)拼接起来
16      loaded = loaded + value.length
17
18      // 在这里实时获取已经接收的响应体数据
19      console.log(loaded)
20    }
21}
22

需要注意的是,我们response.body是一个可读流,我们使用getReader()来读取它之后,就不可以在后续调用fetch原生的response.json()等api来格式化数据了(因为流式数据只能被消费一次)。

所以我们后续需要手动拼接数据体,并作为request函数的返回值供调用者使用。

 1async function request(){
 2    const response = await fetch(url)
 3
 4    // response.body是一个可读流,调用其读取器getReader。
 5    const reader = response.body.getReader()
 6    // 目前已经接收的数据量(单位字节)
 7    let loaded = 0
 8
 9    // 因为上述response.body.getReader()已经消费了数据流,
10    // 所以要手动拼接响应体数据,而不能调用response.json()等api。
11    const decoder = new TextDecoder(); // 当返回的二数据是字符串时,使用TextDecoder将其转换为字符串
12    let body = ''
13
14    while(true){
15      // 这里读的不是整个响应体,而是目前来的这一部分。done表示响应体是否已经传输完毕
16      const { done, value } = await reader.read()
17      if(done) {
18        break;
19      }
20      // 将每次接收的数据量(单位字节)拼接起来
21      loaded = loaded + value.length
22      // 在这里实时获取已经接收的响应体数据
23      console.log(loaded)
24
25      // 手动拼接响应体数据
26      body = body + decoder.decode(value)
27    }
28    return body
29}
30

2-1-3 完整代码

 1async function request(){
 2    const response = await fetch(url)
 3
 4    // 获取响应头中的content-length,代表响应体的总数据量有多少(单位字节)
 5    const total = +response.headers.get('content-length');
 6
 7    // response.body是一个可读流,调用其读取器getReader。
 8    const reader = response.body.getReader()
 9    // 目前已经接收的数据量(单位字节)
10    let loaded = 0
11
12    // 因为上述response.body.getReader()已经消费了数据流,
13    // 所以要手动拼接响应体数据,而不能调用response.json()等api。
14    const decoder = new TextDecoder(); // 当返回的二数据是字符串时,使用TextDecoder将其转换为字符串
15    let body = ''
16
17    while(true){
18      // 这里读的不是整个响应体,而是目前来的这一部分。done表示响应体是否已经传输完毕
19      const { done, value } = await reader.read()
20      if(done) {
21        break;
22      }
23      // 将每次接收的数据量(单位字节)拼接起来
24      loaded = loaded + value.length
25
26      // 手动拼接响应体数据
27      body = body + decoder.decode(value)
28
29      // 实时输出接收进度
30      console.log( (loaded / total * 100).toFixed(2) + '%')
31    }
32    return body
33}
34