上传和下载的进度监控

需求分析

有些时候,当我们上传文件或者是请求一个大体积数据的时候,希望知道实时的进度,甚至可以基于此做一个进度条的展示。

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

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

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

代码实现

首先修改一下类型定义。

types/index.ts

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

接着在发送请求前,给 xhr 对象添加属性。

core/xhr.ts

  1. const {
  2. /*...*/
  3. onDownloadProgress,
  4. onUploadProgress
  5. } = config
  6. if (onDownloadProgress) {
  7. request.onprogress = onDownloadProgress
  8. }
  9. if (onUploadProgress) {
  10. request.upload.onprogress = onUploadProgress
  11. }

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

我们先添加一个判断 FormData 的方法。

helpers/util.ts

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

然后再添加相关逻辑。

core/xhr.ts

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

我们发现,xhr 函数内部随着需求越来越多,代码也越来越臃肿,我们可以把逻辑梳理一下,把内部代码做一层封装优化。

  1. export default function xhr(config: AxiosRequestConfig): AxiosPromise {
  2. return new Promise((resolve, reject) => {
  3. const {
  4. data = null,
  5. url,
  6. method = 'get',
  7. headers,
  8. responseType,
  9. timeout,
  10. cancelToken,
  11. withCredentials,
  12. xsrfCookieName,
  13. xsrfHeaderName,
  14. onDownloadProgress,
  15. onUploadProgress
  16. } = config
  17. const request = new XMLHttpRequest()
  18. request.open(method.toUpperCase(), url!, true)
  19. configureRequest()
  20. addEvents()
  21. processHeaders()
  22. processCancel()
  23. request.send(data)
  24. function configureRequest(): void {
  25. if (responseType) {
  26. request.responseType = responseType
  27. }
  28. if (timeout) {
  29. request.timeout = timeout
  30. }
  31. if (withCredentials) {
  32. request.withCredentials = withCredentials
  33. }
  34. }
  35. function addEvents(): void {
  36. request.onreadystatechange = function handleLoad() {
  37. if (request.readyState !== 4) {
  38. return
  39. }
  40. if (request.status === 0) {
  41. return
  42. }
  43. const responseHeaders = parseHeaders(request.getAllResponseHeaders())
  44. const responseData =
  45. responseType && responseType !== 'text' ? request.response : request.responseText
  46. const response: AxiosResponse = {
  47. data: responseData,
  48. status: request.status,
  49. statusText: request.statusText,
  50. headers: responseHeaders,
  51. config,
  52. request
  53. }
  54. handleResponse(response)
  55. }
  56. request.onerror = function handleError() {
  57. reject(createError('Network Error', config, null, request))
  58. }
  59. request.ontimeout = function handleTimeout() {
  60. reject(
  61. createError(`Timeout of ${config.timeout} ms exceeded`, config, 'ECONNABORTED', request)
  62. )
  63. }
  64. if (onDownloadProgress) {
  65. request.onprogress = onDownloadProgress
  66. }
  67. if (onUploadProgress) {
  68. request.upload.onprogress = onUploadProgress
  69. }
  70. }
  71. function processHeaders(): void {
  72. if (isFormData(data)) {
  73. delete headers['Content-Type']
  74. }
  75. if ((withCredentials || isURLSameOrigin(url!)) && xsrfCookieName) {
  76. const xsrfValue = cookie.read(xsrfCookieName)
  77. if (xsrfValue) {
  78. headers[xsrfHeaderName!] = xsrfValue
  79. }
  80. }
  81. Object.keys(headers).forEach(name => {
  82. if (data === null && name.toLowerCase() === 'content-type') {
  83. delete headers[name]
  84. } else {
  85. request.setRequestHeader(name, headers[name])
  86. }
  87. })
  88. }
  89. function processCancel(): void {
  90. if (cancelToken) {
  91. cancelToken.promise.then(reason => {
  92. request.abort()
  93. reject(reason)
  94. })
  95. }
  96. }
  97. function handleResponse(response: AxiosResponse): void {
  98. if (response.status >= 200 && response.status < 300) {
  99. resolve(response)
  100. } else {
  101. reject(
  102. createError(
  103. `Request failed with status code ${response.status}`,
  104. config,
  105. null,
  106. request,
  107. response
  108. )
  109. )
  110. }
  111. }
  112. })
  113. }

我们把整个流程分为 7 步:

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

这样拆分后整个流程就会显得非常清晰,未来我们再去新增需求的时候代码也不会显得越来越臃肿。

demo 编写

这节课的 demo 非常有意思,我们第一次给界面上增加了一些交互的按钮。

examples/more/index.html

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <title>More example</title>
  6. <link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css"/>
  7. </head>
  8. <body>
  9. <h1>file download</h1>
  10. <div>
  11. <button id="download" class="btn btn-primary">Download</button>
  12. </div>
  13. <h1>file upload</h1>
  14. <form role="form" class="form" onsubmit="return false;">
  15. <input id="file" type="file" class="form-control"/>
  16. <button id="upload" type="button" class="btn btn-primary">Upload</button>
  17. </form>
  18. <script src="/__build__/more.js"></script>
  19. </body>
  20. </html>

另外,我们为了友好地展示上传和下载进度,我们引入了一个开源库 nprogress,它可以在页面的顶部展示进度条。

examples/more/app.ts

  1. const instance = axios.create()
  2. function calculatePercentage(loaded: number, total: number) {
  3. return Math.floor(loaded * 1.0) / total
  4. }
  5. function loadProgressBar() {
  6. const setupStartProgress = () => {
  7. instance.interceptors.request.use(config => {
  8. NProgress.start()
  9. return config
  10. })
  11. }
  12. const setupUpdateProgress = () => {
  13. const update = (e: ProgressEvent) => {
  14. console.log(e)
  15. NProgress.set(calculatePercentage(e.loaded, e.total))
  16. }
  17. instance.defaults.onDownloadProgress = update
  18. instance.defaults.onUploadProgress = update
  19. }
  20. const setupStopProgress = () => {
  21. instance.interceptors.response.use(response => {
  22. NProgress.done()
  23. return response
  24. }, error => {
  25. NProgress.done()
  26. return Promise.reject(error)
  27. })
  28. }
  29. setupStartProgress()
  30. setupUpdateProgress()
  31. setupStopProgress()
  32. }
  33. loadProgressBar()
  34. const downloadEl = document.getElementById('download')
  35. downloadEl!.addEventListener('click', e => {
  36. instance.get('https://img.mukewang.com/5cc01a7b0001a33718720632.jpg')
  37. })
  38. const uploadEl = document.getElementById('upload')
  39. uploadEl!.addEventListener('click', e => {
  40. const data = new FormData()
  41. const fileEl = document.getElementById('file') as HTMLInputElement
  42. if (fileEl.files) {
  43. data.append('file', fileEl.files[0])
  44. instance.post('/more/upload', data)
  45. }
  46. })

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

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

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

example/server.js

  1. const multipart = require('connect-multiparty')
  2. app.use(multipart({
  3. uploadDir: path.resolve(__dirname, 'upload-file')
  4. }))
  5. router.post('/more/upload', function(req, res) {
  6. console.log(req.body, req.files)
  7. res.end('upload success!')
  8. })

这里我们需要在 examples 目录下创建一个 upload-file 的空目录,用于存放上传的文件。

通过这个中间件,我们就可以处理上传请求并且可以把上传的文件存储在 upload-file 目录下。

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

至此,ts-axios 支持了上传下载进度事件的回调函数的配置,用户可以通过配置这俩函数实现对下载进度和上传进度的监控。下一节课我们来实现 http 的认证授权功能。