一、前端大文件上传文件的痛点

1、文件过大会导致带宽资源紧张,请求速度下降 ;
2、如果上传过程中服务中断、网络中断 、页面崩溃,可能会导致文件重新开始上传。

二、痛点的分析

前端选择文件后上传,后端在处理文件过程中,首先会将文件加载到运行内存中,之后再调用相应的API进行写入硬盘内存的操作,完成整个文件的上传。

但这样直接上传文件,可能会因为某个环节出了问题导致整个流程的雪崩,所以大文件直接上传是不可取的。

解决问题最好办法是分片断点续传,该方式主要是针对大文件(比如100M以上的文件)

三、断点续传的原理

顾名思义就是断点续传

什么是断点?

在文件上传过程中,将一个要上传的文件分成N块,然后使用多线程并发多块上传,因为某种原因导致上传被中断或暂停,此时中断或暂停的位置就成为断点

前端每上传一片,将会被加载到运行内存中,加载完毕后再写入硬盘,此时运行内存的临时变量会被释放,然后此临时变量会被下一片占用,再进行写入,释放...

什么是续传?

意思是指从中断的位置继续上传剩下的部分文件,而不是从头开始上传。

上传完毕后,在服务端进行合并(合并的操作是在后端进行的,前端只是调用接口,合并的方式是由后端决定的,到底是上传一片就合并一片,或者是上传所有的之后整体进行合并)。

断点续传的实现

1)分片的实现

方式:
html5z之前的方式是flashactiveX
html5提供了文件二进制流进行分割的slice方法。

const chunks = Math.ceil(file.size / eachSize)

文件的分片,一般在2-5M之间。这一步得到了每一片文件的内容、每一块的序号、每一块的大小、总块数等数据。

2)续传的实现

  1. 续传首先要确定需要继续上传的是哪一个文件,而确定一个文件的方式是对文件进行加密,只要某个文件内容发生变化,就需要重新上传。可以对文件进行MD5加密作为文件唯一的标识符,MD5加密是不可逆的。
    要注意:对整个大文件进行加密,可能会导致页面崩溃,需要对文件进行分片加密。
    spark-md5插件支持文件分片加密
    基于elementUI的spark-md5的使用
  2. 在第一片文件上传之前,需要用文件名称 + 此文件唯一标识符 +当前片数来查询文件是否上传过。通过服务器返回的已经上传的结果,我们可以通过分片的结果获取剩余部分进行上传。如果页面重新加载或者上传中断,只需要在重新上传之前在哪一片中断便可以继续上传。
  3. 在上传完毕后,请求合并接口(合并接口也可以不请求,后端拿到所有文件后自己进行合并),在服务端将文件进行合并,此时整个文件上传结束。
  4. 在上传过程中,可以根据服务器返回的当前上传成功的片数和总片数对前端进度条进行展示

element-ui中Upload spark-md5的使用

//js部分
import chunkedUpload from './chunkedUpload'
export default {
  data() {
    return {
      uploadData: {
        //这里面放额外携带的参数
      },
      //文件上传的路径
      uploadUrl: process.env.BASE_API + '/oss/oss/uploadChunkFile', //文件上传的路径
      chunkedUpload: chunkedUpload // 分片上传自定义方法,在头部引入了
    }
  },
  methods: {
    onError(err, file, fileList) {
      this.$store.getters.chunkedUploadXhr.forEach(item => {
        item.abort()
      })
      this.$alert('文件上传失败,请重试', '错误', {
        confirmButtonText: '确定'
      })
    },
    beforeRemove(file) {
      // 如果正在分片上传,则取消分片上传
      if (file.percentage !== 100) {
        this.$store.getters.chunkedUploadXhr.forEach(item => {
          item.abort()
        })
      }
    }
  }
}
//chunkedUpload.js
import SparkMD5 from 'spark-md5'
import axios from 'axios'
import store from '@/store'
// 如果上传错误,获取报错信息
function getError(action, option, xhr) {
  let msg
  if (xhr.response) {
    msg = `${xhr.response.error || xhr.response}`
  } else if (xhr.responseText) {
    msg = `${xhr.responseText}`
  } else {
    msg = `fail to post ${action} ${xhr.status}`
  }
  const err = new Error(msg)
  err.status = xhr.status
  err.method = 'post'
  err.url = action
  return err
}
// 上传成功完成合并之后,获取服务器返回的信息
function getBody(xhr) {
  const text = xhr.responseText || xhr.response
  if (!text) {
    return text
  }
  try {
    return JSON.parse(text)
  } catch (e) {
    return text
  }
}

// 分片上传的自定义请求,以下请求会覆盖element的默认上传行为
export default function upload(option) {
  if (typeof XMLHttpRequest === 'undefined') {
    return
  }
  const spark = new SparkMD5.ArrayBuffer()// md5的ArrayBuffer加密类
  const fileReader = new FileReader()// 文件读取类
  const action = option.action // 文件上传上传路径
  const chunkSize = 1024 * 1024 * 30 // 单个分片大小
  let md5 = ''// 文件的唯一标识
  const optionFile = option.file // 需要分片的文件
  let fileChunkedList = [] // 文件分片完成之后的数组
  const percentage = [] // 文件上传进度的数组,单项就是一个分片的进度

  // 文件开始分片,push到fileChunkedList数组中, 并用第一个分片去计算文件的md5
  for (let i = 0; i < optionFile.size; i = i + chunkSize) {
    const tmp = optionFile.slice(i, Math.min((i + chunkSize), optionFile.size))
    if (i === 0) {
      fileReader.readAsArrayBuffer(tmp)
    }
    fileChunkedList.push(tmp)
  }

  // 在文件读取完毕之后,开始计算文件md5,作为文件唯一标识
  fileReader.onload = async(e) => {
    spark.append(e.target.result)
    md5 = spark.end() + new Date().getTime()
    console.log('文件md5为--------', md5)
    // 将fileChunkedList转成FormData对象,并加入上传时需要的数据
    fileChunkedList = fileChunkedList.map((item, index) => {
      const formData = new FormData()
      if (option.data) {
        // 额外加入外面传入的data数据
        Object.keys(option.data).forEach(key => {
          formData.append(key, option.data[key])
        })
        // 这些字段看后端需要哪些,就传哪些,也可以自己追加额外参数
        formData.append(option.filename, item, option.file.name)// 文件
        formData.append('chunkNumber', index + 1)// 当前文件块
        formData.append('chunkSize', chunkSize)// 单个分块大小
        formData.append('currentChunkSize', item.size)// 当前分块大小
        formData.append('totalSize', optionFile.size)// 文件总大小
        formData.append('identifier', md5)// 文件标识
        formData.append('filename', option.file.name)// 文件名
        formData.append('totalChunks', fileChunkedList.length)// 总块数
      }
      return { formData: formData, index: index }
    })

    // 更新上传进度条百分比的方法
    const updataPercentage = (e) => {
      let loaded = 0// 当前已经上传文件的总大小
      percentage.forEach(item => {
        loaded += item
      })
      e.percent = loaded / optionFile.size * 100
      option.onProgress(e)
    }

    // 创建队列上传任务,limit是上传并发数
    function sendRequest(chunks, limit = 3) {
      return new Promise((resolve, reject) => {
        const len = chunks.length
        let counter = 0
        let isStop = false
        const start = async() => {
          if (isStop) {
            return
          }
          const item = chunks.shift()
          console.log()
          if (item) {
            const xhr = new XMLHttpRequest()
            const index = item.index
            // 分片上传失败回调
            xhr.onerror = function error(e) {
              isStop = true
              reject(e)
            }
            // 分片上传成功回调
            xhr.onload = function onload() {
              if (xhr.status < 200 || xhr.status >= 300) {
                isStop = true
                reject(getError(action, option, xhr))
              }
              if (counter === len - 1) {
                // 最后一个上传完成
                resolve()
              } else {
                counter++
                start()
              }
            }
            // 分片上传中回调
            if (xhr.upload) {
              xhr.upload.onprogress = function progress(e) {
                if (e.total > 0) {
                  e.percent = e.loaded / e.total * 100
                }
                percentage[index] = e.loaded
                console.log(index)
                updataPercentage(e)
              }
            }
            xhr.open('post', action, true)
            if (option.withCredentials && 'withCredentials' in xhr) {
              xhr.withCredentials = true
            }
            const headers = option.headers || {}
            for (const item in headers) {
              if (headers.hasOwnProperty(item) && headers[item] !== null) {
                xhr.setRequestHeader(item, headers[item])
              }
            }
            // 文件开始上传
            xhr.send(item.formData)
            //这里是把所有分片上传的xhr存到全局中,如果用户手动取消上传,或者上传出现错误,则要调用xhr.abort()把store中所有xhr的停止,不然文件还会继续上传
            store.commit('SET_CHUNKEDUPLOADXHR', xhr)
          }
        }
        while (limit > 0) {
          setTimeout(() => {
            start()
          }, Math.random() * 1000)
          limit -= 1
        }
      })
    }

    try {
      // 调用上传队列方法 等待所有文件上传完成
      await sendRequest(fileChunkedList, 3)
      // 这里的参数根据自己实际情况写
      const data = {
        identifier: md5,
        filename: option.file.name,
        totalSize: optionFile.size
      }
      // 给后端发送文件合并请求
      const fileInfo = await axios({
        method: 'post',
        url: '/api/oss/oss/mergeChunkFile',
        data: data
      })
      // 这个8200是我们oss存储成功的code,根据自己实际情况可以变
      if (fileInfo.data.code === 8200) {
        const success = getBody(fileInfo.request)
        option.onSuccess(success)
        return
      }
    } catch (error) {
      option.onError(error)
    }
  }
}

前端通过spark-md5.js计算本地文件md5

这里提供了两个方法;一种是用SparkMD5.hashBinary( ) 直接将整个文件的二进制码传入直接返回文件的md5、这种方法对于小文件会比较有优势——简单并且速度快。
另一种方法是利用js中File对象的slice( )方法(File.prototype.slice( ))将文件分片后逐个传入spark.appendBinary( )方法来计算、最后通过spark.end( )方法输出结果,很明显,这种方法对于大型文件会非常有利——不容易出错,并且能够提供计算的进度信息

第一种方式:

var running = false;    //running用于判断是否正在计算md5
            function doNormalTest( input ) {    //这里假设直接将文件选择框的dom引用传入
                
                if (running) {    // 如果正在计算、不允许开始下一次计算
                    return;
                }
 
                var fileReader = new FileReader(),    //创建FileReader实例
                    time;
 
                fileReader.onload = function (e) {    //FileReader的load事件,当文件读取完毕时触发
                    running = false;
 
                    // e.target指向上面的fileReader实例
                    if (file.size != e.target.result.length) {    //如果两者不一致说明读取出错
                       alert("ERROR:Browser reported success but could not read the file until the end.");
                    } else {
                        console.log(Finished loading!success!!);
                         return SparkMD5.hashBinary(e.target.result);    //计算md5并返回结果
                         
                    }
                };
 
                fileReader.onerror = function () {    //如果读取文件出错,取消读取状态并弹框报错
                    running = false;
                    alert("ERROR:FileReader onerror was triggered, maybe the browser aborted due to high memory usage.");
                };
 
                running = true;
                fileReader.readAsBinaryString( input.files[0] );    //通过fileReader读取文件二进制码
            };

第二种方式
 

function doIncrementalTest( input ) {    //这里假设直接将文件选择框的dom引用传入
                if (running) {
                    return;
                }
 
                //这里需要用到File的slice( )方法,以下是兼容写法
                var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
                    file = input.files[0],
                    chunkSize = 2097152,                           // 以每片2MB大小来逐次读取
                    chunks = Math.ceil(file.size / chunkSize),
                    currentChunk = 0,
                    spark = new SparkMD5(),    //创建SparkMD5的实例
                    time,
                    fileReader = new FileReader();
 
                fileReader.onload = function (e) {
 
                    console("Read chunk number (currentChunk + 1) of  chunks ");
 
                    spark.appendBinary(e.target.result);                 // append array buffer
                    currentChunk += 1;
 
                    if (currentChunk < chunks) {
                        loadNext();
                    } else {
                        running = false;
                        console.log("Finished loading!");
                        return spark.end();     // 完成计算,返回结果
                    }
                };
 
                fileReader.onerror = function () {
                    running = false;
                    console.log("something went wrong");
                };
 
                function loadNext() {
                    var start = currentChunk * chunkSize,
                        end = start + chunkSize >= file.size ? file.size : start + chunkSize;
 
                    fileReader.readAsBinaryString(blobSlice.call(file, start, end));
                }
 
                running = true;
                loadNext();
            }