目录

🌗 实现上传和下载进度监控

# 需求分析

当需要上传文件或者请求一个大体积数据时,希望知道实时的进度,或者一个进度条展示。

可以给 axios 的请求配置提供一个 onDownloadProgressonUploadProgress 两个函数属性,用户通过这两个函数实现对下载进度和上传进度的监控。

例如:

axios.get('/more/get',{
  onDownloadProgress(progressEvent) {
    // 监听下载进度
  }
})

axios.post('/more/post',{
  onUploadProgress(progressEvent) {
    // 监听上传进度
  }
})
1
2
3
4
5
6
7
8
9
10
11

xhr 对象提供了一个 progress 事件,可以通过监听这个事件对数据的下载进度做监控。另外, xhr.upload 对象也提供了 progress 事件,可以基于此对上传进度做监控。

# 代码实现

修改 AxiosRequestConfig 的类型定义, src/types/index.ts

export interface AxiosRequestConfig {
 	// ... 
  onDownloadProgress?: (e: ProgressEvent) => void
  onUploadProgress?: (e: ProgressEvent) => void
}
1
2
3
4
5

然后修改 src/core/xhr.ts ,在发送请求之前,给 xhr 对象添加属性:

export default function xhr(config: AxiosRequestConfig): AxiosPromise {
  return new Promise((resolve, reject) => {
    const {
      // ...
      onDownloadProgress,
      onUploadProgress
    } = config

    // ...
    
    if(onDownloadProgress) {
      request.onprogress = onDownloadProgress
    }
    
    if(onUploadProgress) {
      request.onprogress = onUploadProgress
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

另外,如果请求的数据是 FormData 类型,应该主动删除请求 headers 中的 Content-Type 字段,让浏览器自动根据请求数据设置 Content-Type 。比如当通过 FormData 上传文件的时候,浏览器会把请求 headers 中的 Content-Type 设置为 multipart/form-data

先添加一个判断 FormData 的方法,在 src/helpers/util.ts

export function isFormData(val: any): boolean {
  return typeof val !== 'undefined' && val instanceof FormData
}
1
2
3

然后再添加逻辑到 src/core/xhr.ts

if(isFormData(data)) {
  delete headers['Content-Type']
}
1
2
3

xhr.ts 随着需求越来越多,代码越来越臃肿,可以把逻辑梳理一下,把内部代码做一层封装优化。

点击查看
import { AxiosPromise, AxiosRequestConfig, AxiosResponse } from '../types'
import { parseHeaders } from '../helpers/headers'
import { createError } from '../helpers/error'
import { isURLSameOrigin } from '../helpers/url'
import cookie from '../helpers/cookie'
import { isFormData } from '../helpers/util'

export default function xhr(config: AxiosRequestConfig): AxiosPromise {
  return new Promise((resolve, reject) => {
    const {
      data = null,
      url,
      method = 'get',
      headers,
      responseType,
      timeout,
      cancelToken,
      withCredentials,
      xsrfCookieName,
      xsrfHeaderName,
      onDownloadProgress,
      onUploadProgress
    } = config

    const request = new XMLHttpRequest()

    request.open(method.toUpperCase(), url, true)

    configureRequest()
    addEvents()
    processHeaders()
    processCancel()

    request.send(data)

    function configureRequest(): void {
      if (responseType) {
        request.responseType = responseType
      }

      if (timeout) {
        request.timeout = timeout
      }

      if (withCredentials) {
        request.withCredentials = true
      }
    }

    function addEvents(): void {
      request.onreadystatechange = function handleLoad() {
        if (request.readyState !== 4) return
        if (request.status === 0) return

        const responseHeaders = parseHeaders(request.getAllResponseHeaders())
        const responseData =
          responseType && responseType !== 'text' ? request.response : request.responseText
        const response: AxiosResponse = {
          data: responseData,
          status: request.status,
          statusText: request.statusText,
          headers: responseHeaders,
          config,
          request
        }

        handleResponse(response)
      }

      request.onerror = function handleError() {
        reject(createError('Network Error', config, null, request))
      }

      request.ontimeout = function handleTimeout() {
        reject(createError(`Timeout of ${timeout} ms exceeded`, config, 'ECONNABORTED', request))
      }

      if (onDownloadProgress) {
        request.onprogress = onDownloadProgress
      }

      if (onUploadProgress) {
        request.onprogress = onUploadProgress
      }
    }

    function processHeaders(): void {
      if (isFormData(data)) {
        delete headers['Content-Type']
      }

      if ((withCredentials || isURLSameOrigin(url!)) && xsrfCookieName) {
        const xsrfValue = cookie.read(xsrfCookieName)
        if (xsrfValue) headers[xsrfHeaderName!] = xsrfValue
      }

      Object.keys(headers).forEach(name => {
        if (data === null && name.toLowerCase() === 'content-type') {
          delete headers[name]
        } else {
          request.setRequestHeader(name, headers[name])
        }
      })
    }

    function processCancel(): void {
      if (cancelToken) {
        cancelToken.promise.then(reason => {
          request.abort()
          reject(reason)
        })
      }
    }

    function handleResponse(response: AxiosResponse) {
      if (response.status >= 200 && response.status < 300) {
        resolve(response)
      } else {
        reject(
          createError(
            `Request failed with status code ${response.status}`,
            config,
            null,
            request,
            response
          )
        )
      }
    }
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131

把整个流程分为 7 部:

  • 创建一个 request 实例。
  • 执行 request.open 方法初始化。
  • 执行 configureRequest 配置 request 对象。
  • 执行 addEventsrequest 添加事件处理函数。
  • 执行 processHeaders 处理请求 headers
  • 执行 processCancel 处理请求取消逻辑。
  • 执行 request.send 方法发送请求。

# 编写测试 DEMO

examples/more/index.html

<!DOCTYPE html>
<html lang='en'>
<head>
  <meta charset='UTF-8'>
  <title>more example</title>
  <link rel='stylesheet' type='text/css'
        href='//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css' />
</head>
<body>
<h1>file download</h1>
<div>
  <button id='download' class='btn btn-primary'>download</button>
</div>

<h1>file upload</h1>
<div>
  <form role='form' class='form' onsubmit='return false'>
    <input type='file' id='title' class='form-control'>
    <button id='upload' class='btn btn-primary'>upload</button>
  </form>
</div>
<script src='/__build__/more.js'></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

添加下载按钮和上传表单。

examples/more/app.ts ,为了展示上传和下载的进度,引入 nprogress ,在页面的顶部展示进度条:

const instance = axios.create()

function calculatePercentage(loaded: number, total: number) {
  return Math.floor(loaded * 1.0) / total
}

function loadProgressbar() {
  const setupStartProgress = () => {
    instance.interceptors.request.use(config => {
      NProgress.start()
      return config
    })
  }

  const setupUpdateProgress = () => {
    const update = (e: ProgressEvent) => {
      console.log(e)
      NProgress.set(calculatePercentage(e.loaded, e.total))
    }

    instance.defaults.onDownloadProgress = update
    instance.defaults.onUploadProgress = update
  }

  const setupStopProgress = () => {
    instance.interceptors.response.use(response => {
      NProgress.done()
      return response
    }, error => {
      NProgress.done()
      return Promise.reject(error)
    })
  }

  setupStartProgress()
  setupUpdateProgress()
  setupStopProgress()
}

loadProgressbar()

const downloadEl = document.getElementById('download')
downloadEl!.addEventListener('click', e => {
  instance.get('https://cdn.jsdelivr.net/gh/simon1uo/image-flow@master/image/jSQm90.jpeg')
})

const uploadEl = document.getElementById('upload')
uploadEl!.addEventListener('click', e => {
  const data = new FormData()
  const fileEl = document.getElementById('file') as HTMLInputElement
  if(fileEl.files) {
    data.append('file', fileEl.files[0])

    instance.post('/more/upload', data)
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

对于 progress 事件参数 e ,会有 e.totale.loaded 属性,表示进程总体的工作量和已经执行的工作量,可以根据这 2 个值算出当前进度,然后通过 Nprogess.set 设置。另外,通过配置请求拦截器和响应拦截器执行 NProgress.start()NProgress.done()

给下载按钮绑定了一个 click 事件,请求一张图片,可以看到实时的进度;另外也给上传按钮绑定了一个 click 事件,上传选择的文件,同样也能看到实时进度。

为了处理上传请求,需要下载安装一个 express 的中间件 connect-multiparty ,然后使用它。

examples/server.js

const multipart = require('connect-multiparty')
app.use(multipart({
  uploadDir: path.resolve(__dirname, 'upload-file')
}))

router.post('more/upload', function(req, res) {
  console.log(req.body, req.files)
  res.end('upload success!')
})
1
2
3
4
5
6
7
8
9

需要在 examples 目录下创建一个文件夹 upload-file 作为上传文件的存储目录。通过这个中间件,就可以处理上传请求并且可以把上传的文件存储在 upload-file 目录下。

为了保证代码正常运行,我们还需要在 examples/webpack.config.js 中添加 css-loadercss-loader ,不要忘记先安装它们。

{
  test: /\.css?$/,
  use: [
    {
      loader: ['style-loader', 'css-loader']
    }
  ]
}
1
2
3
4
5
6
7
8
📢 上次更新: 2022/09/02, 10:18:16