前端实现大文件分片上传,断点续传

581 阅读5分钟

分为以下几步:

image.png

  • 1.前端接收BGM并进行切片
  • 2.将每份切片都进行上传
  • 3.后端接收到所有切片,创建一个文件夹存储这些切片
  • 4.后端将此文件夹里的所有切片合并为完整的文件
  • 5.删除文件夹,因为切片不是我们最终想要的,可删除
  • 6.当服务器已存在某一个文件时,再上传需要实现“秒传”

文件切片

  • 为什么要切片?

一个文件过大,上传会非常的慢,而且还可能会中途失败导致前功尽弃。要重新上传文件,非常影响用户体验。文件切片以后,可以并发上传,速度比之前更快。而且如果中途失败可以做到不用上传已经上传过的。

  • 对文件如何切片?

通过Blob.prototype.slice方法即可对文件进行切分,分片尽量不要太大,一般最大50M即可。

  • 相关实现如下
const maxChunkSize = 52428800  // 最大容量块
const chunkSum = Math.ceil(file?.size / maxChunkSize)
                       
export const createFileChunks = async ({file, chunkSum, setProgress}) => {
  const fileChunkList = [];
  const chunkSize = Math.ceil(file?.size / chunkSum);
  let start = 0;
  for (let i = 0; i < chunkSum; i++) {
    const end = start + chunkSize;
    fileChunkList.push({
      index: i,
      filename: file?.name,
      file: file.slice(start, end)
    });
    start = end;
  }
  const result = await getFileHash({chunks: fileChunkList, setProgress});
  fileChunkList.map((item, index) => {
    item.key = result;
  });
  return fileChunkList;
};

具体实现方案

解决方案一: html部分采用el-upload组件,具体代码如下:

<el-upload
   class="upload-demo"
   action="https://jsonplaceholder.typicode.com/posts/"
   :on-change="handleChange"
   :http-request="putinMirror"
   :file-list="fileList">
   <el-button size="small" type="primary">点击上传</el-button>
   <!-- <div slot="tip" class="el-upload__tip">只能上传jpg/png文件,且不超过500kb</div> -->
</el-upload>

js部分代码如下:

export default {
    name: "JsPage",
    data() {
        return {
            fileList: [{
                name: 'food.jpeg',
                url: ''
                }, {
                name: 'food2.jpeg',
                url: ''
            }]
        }
    },
    mounted() {},
    methods: {
        handleChange(file, fileList) {
            console.log('文件上传change事件:',file, fileList)
            //this.fileList = fileList.slice(-3);
        },
        // 覆盖组件默认的上传行为,可以自定义上传的实现
        async putinMirror(file) {
            // 每个文件切片大小定为5MB
            let sliceSize = 0.5 * 1024 * 1024;
            // 文件大小限制为最大1个G,可根据需求修改
            let maxfilesizes  = 1 * 1024 * 1024 * 1024;
            console.log('putinMirror:',file)
            const blob = file.file;
            const fileSize = blob.size;// 文件大小
            const fileName = blob.name;// 文件名
            //计算文件切片总数,Math.ceil向上取整数
            const totalSlice = Math.ceil(fileSize / sliceSize);
            console.log('当前上传文件的详情信息',blob,totalSlice,fileSize / sliceSize)
            if(fileSize <= maxfilesizes){
                // 循环上传
                for (let i = 0; i < totalSlice; i++) {
                    let start = i * sliceSize;
                    let chunk = blob.slice(start, Math.min(fileSize, start + sliceSize));
                    console.log('每个切片的信息:',chunk)
                    const formData = new FormData();
                    formData.append("file", chunk);
                    formData.append("signal", blob.uid);
                    formData.append("name", fileName);
                    formData.append("size", fileSize);
                    formData.append("chunks", totalSlice);
                    formData.append("chunk", i+1);
                    let res = await this.uploadExcleAndZip(formData);//uploadExcleAndZip模拟接口上传,一个分片上传完成后再调用接口上传下一片
                    console.log(res);
                    if(res.errCode == 0){
                        //this.progress = ((i+1)/totalSlice).toFixed(1) * 100;//控制进度条
                        setTimeout(()=>{
                            if((i+1) == totalSlice){
                                this.$message({
                                    message: '上传成功',
                                    type: 'success'
                                });
                            }
                        }, 1000);
                    }
                }
            } else { // 文件大小超出最大限制
                this.$message({
                    message: '文件大小超出1G',
                    type: 'error'
                });
            }
        },
        uploadExcleAndZip(formData) {
            console.log('在这里模拟调接口:',formData)
            return {
                errCode:0
            }
        }
    }
}

秒传和断点续传

前端hash生成

断点续传、秒传功能都需要后端有办法能够判断我们的文件到底上没上传成功,用什么来判断呢?

当然使用文件的 hash 值,同一个文件的 hash 值计算出来是一样的。

我们可以使用使用 spark-md5 库来计算文件的 hash 值。

npm i spark-md5 -S
npm i @types/spark-md5 -D
复制代码

代码片段:

const handleFileUpload = async (file: File) => {
  // ...
  const spark = new SparkMD5.ArrayBuffer();
  for (let cur = 0; cur < file.size; cur += SIZE) {
    // ...
    spark.append(await file.slice(cur, cur + SIZE).arrayBuffer())
  }
  const hash = spark.end();
  // ...
};
复制代码

文件秒传

要实现断点续传,我就得实现文件秒传,文件秒传的本质就是通过 hash 值的查询判断后端是否已经存在了该文件,如果存在那就不传了,这就是秒传的本质。

前端需要在上传文件之前先调接口查询文件是否存在:

  • 如果存在则返回一个 true
  • 如果不存在则返回 false
  • 如果上传过一部分,则返回 number[],里面按照 chunkIndex 存着各个分片的 size,以方便进度计算和判断是否需要重新上传该分片

代码片段:

const filename = useRef('');
const fileChunks = useRef<FileChunk[]>([]);
复制代码
const verifyUpload = (filename: string, hash: string) => {
  return new Promise<number[]>((resolve, reject) => {
    request<number[]>({
      url: '/verify',
      method: 'POST',
      data: { filename, hash },
      headers: { 'Content-Type': 'application/json' },
    })
      .then((res) => {
        resolve(res.data);
      })
      .catch(err => {
        reject(err);
      });
  });
}

const handleFileUpload = async (hash: string) => {
  const verifyRes = await verifyUpload(filename.current, hash)
    .catch(e => {
      console.error(e);
    });
  if (verifyRes !== undefined) {
    if (typeof verifyRes === 'boolean') {
      if (verifyRes) {
        console.log('文件已经上传过,可以秒传');
        setProgress('100');
      } else {
        POOL.length = 0;
        sliceChunks(hash, fileChunks.current.map(() => 0));
      }
    } else {
      POOL.length = 0;
      sliceChunks(hash, verifyRes);
    }
  } else {
    console.log('验证失败');
  }
};

const handleFileChange = async (evt: ChangeEvent<HTMLInputElement>) => {
  const file = (evt.target.files as FileList)['0'];
  let chunkIndex = 0;
  totalSize.current = file.size;
  filename.current = file.name;
  const spark = new SparkMD5.ArrayBuffer();
  for (let cur = 0; cur < file.size; cur += SIZE) {
    fileChunks.current.push({
      chunkIndex: chunkIndex++,
      chunk: file.slice(cur, cur + SIZE),
    });
    spark.append(await file.slice(cur, cur + SIZE).arrayBuffer())
  }
  const hash = spark.end();
  progressArr.current = [];
  handleFileUpload(hash);
};
复制代码

前面的代码存储上传的文件的时候是用的文件原始的名称,中间使用的临时文件夹也是用的文件的原始名称,从这里开始就改造为使用 hash 值来作为名称了。

断点续传

断点续传就是在前面停止的传了一部分的基础上进行上传,之前传输的信息会通过 verifyUpload 接口进行告知,传完的切片就不会重新传了,没有传或者没有传完的切片就会重新传。

const handleFinishedUploadProgress = (size: number, chunkIndex: number) => {
  progressArr.current[chunkIndex] = size * 100;
  const curTotal = progressArr.current.reduce(
    (accumulator, currentValue) => accumulator + currentValue,
    0,
  );
  setProgress((curTotal / totalSize.current).toFixed(2));
};

const sliceChunks = async (hash: string, chunksSize: number[]) => {
  for (let i = 0; i < fileChunks.current.length; i++) {
    const fileChunk = fileChunks.current[i];
    const formData = new FormData();
    formData.append('filename', filename.current);
    formData.append('chunkIndex', String(fileChunk.chunkIndex));
    formData.append('hash', hash);
    formData.append('file', fileChunk.chunk);
    
    if (chunksSize[i] !== fileChunk.chunk.size) { // size一样的说明已经上传完毕了,只传size不一样的
      const uplaodTask = uploadFile(formData, i);
      uplaodTask.then(() => handleTask(uplaodTask));
      POOL.push(uplaodTask);
      if (POOL.length === MAX_POOL) {
        // 并发池跑完一个任务之后才会继续执行for循环,塞入一个新任务
        await Promise.race(POOL);
      }
    } else {
      handleFinishedUploadProgress(chunksSize[i], i);
    }
  }

  Promise.all(POOL)
    .then(() => {
      mergeFile(filename.current, hash);
    });
};