文件分片上传性能优化

831 阅读5分钟

本文将循序渐进地聊聊,如何进行分片文件上传、文件分片的原理、如何解放js主进程

  • 文件分片的原理及分片

    通过input标签得到一个FileList(设置了multiple)或者File,其中每一个item(可以通过FileList.item(index)或者FileList[index])是一个File,而File继承于Blob,那么就可以使用Blob的方法来处理文件了。item的内容如下:

    看看文件各个字段含义:

    • type: 文件的MIME type(这个属性可以用来做过滤和校验)

    • size: 单位是字节

    • name: 文件名

    使用slice方法可以将文件切成若干相等的块儿(最后一块儿可能小于其他块的大小,如:s3对分片要求最小的分片是5M,如果最后一块儿小于5M时需要合并到倒数第二块儿中),slice来自于file.__proto__。分片代码如下:

    const FILE_PER_PICE_SIZE = 1024 * 1024 * 5
    
    const splitFile = (file, pieceSize) => {
      let start = 0
      let end
      let index = 0
      const { size = 0 } = file || {}
    
      if (pieceSize < FILE_PER_PICE_SIZE) {
        // eslint-disable-next-line no-param-reassign
        pieceSize = FILE_PER_PICE_SIZE
      }
    
      const totalPieces = Math.floor(size / pieceSize)
    
      const chucks = []
    
      while (start < size) {
        end = start + pieceSize
        if (end > size) {
          end = size
        }
    
        if (index === totalPieces - 1) {
          chucks.push({ chuck: file.slice(start), index: index + 1 })
          break
        } else {
          chucks.push({ chuck: file.slice(start, end), index: index + 1 })
          start = end
          index++
        }
      }
    
      return {
        total: chucks.length,
        chucks: chucks.length === 1 ? [file] : chucks,
      }
    }
    
  • 分片上传文件

    文件被分成若干块后,需要确保每一块儿都上传成功,也就是若干请求都成功,首先想到了Promise.all

       const upload = (fileObj) => {
          const { total, chucks, name } = fileObj
          if (total) {
            setLoading(true)
            const type = (name && name.split('.').pop()) || ''
    
            const reqList = []
            getMultiKey(type || 'video', name.replace(/\s/g, ''))
              .then((res) => {
                const { id, key } = res || {}
                Promise.all(
                  chucks.map((item, i) => {
                    const { chuck, index } = item || {}
                    const formData = new FormData()
                    formData.append('Body', chuck)
                    formData.append('PartNumber', index)
                    formData.append('Key', key)
                    formData.append('UploadId', id)
    
                    return upload(formData, i, reqList)
                  })
                )
                  .then((list) => {
                    checkUploadStatus({
                      parts: list,
                      id,
                      key,
                    }).then((url) => {
                      saveVideoInfo(url)
                      setLoading(false)
                    })
                  })
                  .catch(() => {
                    reqList.forEach((req) => {
                      // eslint-disable-next-line no-underscore-dangle
                      req._xhr.abort()
                    })
                    message.destroy()
                    message.error('上传失败,请重试!')
                    cancelUpload({ Key: key, UploadId: id })
                    setLoading(false)
                  })
              })
              .catch(() => {
                setLoading(false)
              })
          }
        }
    
    

    请求过程中network面板如下:

    一次将所有的分片发出去,由于浏览器对同一个域名连接数量有限制(如:chrome是6个连接),这导致大量请求处于pending状态(也就是排队,hold在了浏览器,没有发出去),后面的请求可能因为排队而超时(超时的请求浏览会自动cancel了),只能将请求的超时时间设置的长一些(但是这个时间不好确定);而且还会阻塞了同域下的别的请求,这可能导致页面不能响应UI交互了,而且主进程有上传的时间段内大约70%的时间处理XHR请求

    基于上述问题,不能一次将请求全部发出去,那么需要确定什么时候发请求并且需要知道文件什么时候能全部上传完毕。可以使用发布订阅模式,一次发出一定数量的分片,当收到响应后,再逐一发送剩余的分片

    发布订阅模式又很多实际应运,再次不再啰嗦了

    我将使用Proxy来实现这一功能,代码如下(不涉及取消和重试的过程)

    const createUploaderProxy = (cb) => {
      const tmp = Object.create(null)
      tmp.failed = []  // 用来存储失败的分片,重试的时候使用
      tmp.done = []   // 用来存储成功的分片标识
      tmp.pieceList = []  // 用来存储待发送的分片
      tmp.multiConfig = Object.create(null)
      tmp.total = 0
    
      return new Proxy(
        { ...tmp },
        {
          set(target, prop, value, receiver) {
            if (prop === 'done' || prop === 'failed') {
              if (Array.isArray(value) && !value.length) {
                target[prop] = value
                return true
              }
    
              target[prop].push(value)
    
              if (target.pieceList.length) {
                const next = target.pieceList.shift()
                uploader.singlePiece(next, target.multiConfig, receiver)
                return true
              }
    
              if (target.failed.length + target.done.length === target.total) {
                const fName = target.name
                if (target.done.length === target.total) {
                  checkUploadStatus(target.done, target.multiConfig)
                    .then((url) => {
                      cb(url, true) // 所有分片上传成功
                      uploader.files[fName] = url
                    })
                    .catch(() => {
                      cb(fName, false) // 分片上传成功,但是获取文件在服务器上的地址失败
                    })
                } else {
                  cb(fName, false)  // 有分片上传失败
                }
              }
    
              return true
            }
    
            target[prop] = value
    
            return true
          },
        }
      )
    }
    
    const uploader = Object.create(null)
    
    uploader.files = Object.create(null)
    
    uploader.load = (
      file,
      config = {
        accepts: ['video/mp4', 'video/ogg', 'video/webm', 'video/quicktime'],
        pieceSize: 1024 * 1024 * 5,
        waterFlow: 6,
      },
      cb = () => {}
    ) => {
      let { name } = file
    
      const fileUploader = createUploaderProxy(cb)
    
      uploader.files[name] = {
        config,
        cb,
        uploader: fileUploader,
      }
    }
    
    uploader.singlePiece = (piece, fConfig, fileUploader) => {
      upload(piece, fConfig)
        .then((ret) => {
          fileUploader.done = ret
        })
        .catch(() => {
          fileUploader.failed = piece
        })
    }
    
    

    由上图可知,处于pending状态的请求数量一直是6个,不仅避免了排队超时的情况,同时还是释放浏览器资源,但是主进程有一大半的时间用在处理XHR请求上

    我们知道文件上传的过程中,不涉及页面的UI交互的,那么是不是可以在另一个单独的进程中处理呢?而web worker能够独立于主进程运行

  • web worker

    切记web worker 不能访问domwindow,但是可以访问location,还有同源的限制

    web worker的兼容性还不错,接下来将使用web worker来实现上述Proxy版本:

    /**
    worker.js
    */
    const createUploaderProxy = () => {
      const tmp = Object.create(null)
      tmp.failed = []
      tmp.done = []
      tmp.pieceList = []
      tmp.multiConfig = Object.create(null)
      tmp.total = 0
    
      return new Proxy(
        { ...tmp },
        {
          set(target, prop, value, receiver) {
            if (prop === 'done' || prop === 'failed') {
              if (Array.isArray(value) && !value.length) {
                target[prop] = value
                return true
              }
    
              target[prop].push(value)
    
              if (target.pieceList.length) {
                const next = target.pieceList.shift()
                uploader.singlePiece(next, target.multiConfig, receiver)
                return true
              }
    
              if (target.failed.length + target.done.length === target.total) {
                const fName = target.name
                if (target.done.length === target.total) {
                  checkUploadStatus(target.done, target.multiConfig)
                    .then((url) => {
                    	// 成功的时候用postMessage通知主进程
                      postMessage({
                        url,
                        success: true,
                      })
                      uploader.files[fName] = url
                    })
                    .catch(() => {
                      // 失败的时候用postMessage通知主进程
                      postMessage({
                        name: fName,
                        success: false,
                      })
                    })
                } else {
                  // 失败的时候用postMessage通知主进程
                  postMessage({
                    name: fName,
                    success: false,
                  })
                }
              }
    
              return true
            }
    
            target[prop] = value
    
            return true
          },
        }
      )
    }
    
    const uploader = Object.create(null)
    
    uploader.files = Object.create(null)
    
    uploader.load = (
      file,
      config = {
        accepts: ['video/mp4', 'video/ogg', 'video/webm', 'video/quicktime'],
        pieceSize: 1024 * 1024 * 5,
        waterFlow: 6,
      }
    ) => {
      let { name } = file
    
      const fileUploader = createUploaderProxy()
    
      uploader.files[name] = {
        config,
        cb,
        uploader: fileUploader,
      }
    }
    
    uploader.singlePiece = (piece, fConfig, fileUploader) => {
      upload(piece, fConfig)
        .then((ret) => {
          fileUploader.done = ret
        })
        .catch(() => {
          fileUploader.failed = piece
        })
    }
    
    onmessage = (event) => {
      const { data: { isFile, file } = {} } = event
    
      uploader.load(file)
    }
    
    /**
    index.js
    */
    
    if (window.Worker) {
    	// 创建worker
    	window.uploadWorker = new Worker('./worker.js')
      
      // 监听worker响应结果
      window.uploadWorker.onmessage = (event) => {
         console.log(event.data)
      }
    }
    
    // input的onchange事件
    const onChange = (event) => {
    	window.uploadWorker.postMessage({ file, isFile: true })
    }
    

    使用worker后network面板如下: 由上图可知,请求都是从worker中发出的,不占用主进程

  • 由于worker有同源的限制,而我们为提高页面载入速度,一般会将静态资源放到cdn上,这样页面路径和静态资源路径就不同源了,所以需要将worker打包生成独立文件,并copy到服务器上。

    如果使用webpack打包,经过babel转换后带有浏览器的一些信息,这样就不能作为worker了,需要作为worker打包,推荐使用worker-plugin

    	// 需要在index文件中增加如下代码
    	/**
        index.js
        */
        
        import 'worker-plugin/loader?name=upload!./uploadWorker.js
    

    使用worker-plugin后,打包产物会增加

    如果项目中含有nodejs,本地开发的时候需要能够访问到server中的worker,通过worker是放在client端,为方便调试,可以将client中的worker linkserver

    ln client/src/pages/videoUpload/uploadWorker.js  /server/src/static/worker.js
    

参考

File

Blob

Promise

proxy

web worker

Off The Main Thread

worker-plugin