基于 Element Plus 的大文件分片上传,断点续传和秒传功能的实现

前言

Element Plus 中已提供了一个 upload 组件来实现普通文件上传的需求,但是遇到大文件上传的时候,就有点显得力不从心了:

  1. 由于文件大,上传一般都需要数分钟时间,用户一般都无法忍受干等这么长时间
  2. 若中途断网或浏览器崩溃,只能重新从头开始再上传一次,导致用户心态崩溃

本文主要就基于 Element plus 的 upload 组件来封装实现一个支持分片上传的上传控件。

原理解析

分片上传

其原理其实就是在客户端将文件分割成多个小的分片,然后再将这些分片一片一片的上传给服务端,服务端拿到所有分片后再将这些分片合并起来还原成原来的文件。

那服务端怎么知道我合并出来的文件是否和服务端上传的文件完全一样呢?这就需要用到文件的MD5值了。文件的MD5值就相当于是这个文件的"数字指纹",只有当两个文件内容完全一样时,他们的MD5值才会一样。所以在上传文件前,客户端需要先计算出文件的MD5值,并且把这MD5值传递给服务端。服务端在合并出文件后,在计算合并出的文件的MD5值,与客户端传递过来的进行比较,如果一致,则说明上传成功,若不一致,则说明上传过程中可能出现了丢包,上传失败。

断点续传

断点续传其实是利用分片上传的特性,上次上传中断时,已经有部分分片已上传到服务端,这部分就可以不用重复上传了。

文件秒传

文件秒传其实是利用文件的MD5值作为文件的身份标识,服务端发现要上传的文件的MD5与附件库中的某个文件的MD5值完全一样,则要上传的文件已在附件库中,不用再重复上传。

代码实现

计算文件 MD5

计算文件的 MD5 值,我们直接用现成的三方插件 SparkMD5 即可。

由于计算 MD5 是一个比较耗时的操作,我们用 Web Workers 把它放到后台去运行。

ts 复制代码
// file-md5-worker.ts
import { UploadRawFile } from 'element-plus';
import SparkMD5 from 'spark-md5';

type MD5MessageType = {
  file?: UploadRawFile;
  uid: number;
  cancel?: boolean;
};
// 正在处理的文件记录,当值为 false 时,表示被终止了。
const progressingFilesMap = new Map<number, boolean>();
// 用 web worker 来处理文件 MD5 的计算
self.addEventListener('message', (e: MessageEvent) => {
  const { file, uid, cancel } = e.data as MD5MessageType;
  if (cancel && progressingFilesMap.has(uid)) {
    // 将正在处理的文件标识设置成 false,以备在 getFileMd5 方法中进行终止
    progressingFilesMap.set(uid, false);
  } else if (file) {
    // 开始计算 DM5
    getFileMd5(file, uid)
      .then((md5) => {
        // 计算完成,发送通知
        self.postMessage({
          status: 'success',
          uid,
          md5,
        });
      })
      .catch((error) => {
        self.postMessage({
          status: 'failed',
          uid,
          error,
        });
      });
  }
});

/**
 * 获取文件MD5,采用分片的模式读取文件,最后合并生成 MD5
 * @param file
 * @returns {Promise<unknown>}
 */
const getFileMd5 = (file: UploadRawFile, uid: number) => {
  const fileReader = new FileReader();
  const chunkSize = 1024 * 1024; // 分片大小
  const chunks = Math.ceil(file.size / chunkSize); // 总分片数
  const updateProgress = getProgress(chunks, uid);
  
  let currentChunk = 0;
  const spark = new SparkMD5();

  progressingFilesMap.set(uid, true);

  return new Promise((resolve, reject) => {
    fileReader.onload = (e) => {
      spark.appendBinary(e.target!.result as string); // append binary string

      updateProgress(++currentChunk);

      if (progressingFilesMap.get(uid) === false) {
        // 被终止,则移除
        progressingFilesMap.delete(uid);
        reject('Be cancelled');
      } else if (currentChunk < chunks) {
        // 未结束,则处理下一个分片
        loadNext();
      } else {
        // 处理完成
        progressingFilesMap.delete(uid);
        resolve(spark.end());
      }
    };
    fileReader.onerror = (e) => {
      reject(e);
    };

    const loadNext = () => {
      const start = currentChunk * chunkSize;
      const end =
        start + chunkSize >= file.size ? file.size : start + chunkSize;
      fileReader.readAsBinaryString(file.slice(start, end));
    };

    loadNext();
  });
};

/**
 * 生成进度
 * @param totalChunk 总片数
 * @param uid 文件的 uid
 * @returns 更新进度,参数 processedChunk 为已处理的分片数
 */
const getProgress = (totalChunk: number, uid: number) => {
  let preTime = 0,
    percent = 0;

  return (processedChunk: number) => {
    const now = Date.now();
    // 控制触发间隔大于 500 毫秒
    // 当已经结束,就马上触发
    if (now - preTime > 500 || processedChunk >= totalChunk) {
      percent = Math.min(1, processedChunk / totalChunk) * 100;
      percent = parseFloat(percent.toFixed(1));
      // 发送进度通知
      self.postMessage({
        status: 'progress',
        uid,
        percent,
      });
      preTime = now;
    }
  };
};

在主程序中,我们使用方式如下:

ts 复制代码
const worker = new Worker(
  new URL('./file-md5.worker', import.meta.url),
  { type: 'module' }
);
// 监听 worker 的消息
worker.addEventListener('message', async (e: MessageEvent) => {
  const data = e.data;

  if (data.status === 'success') {
    // 计算成功,开始上传
  } else if (data.status === 'progress') {
    // 计算过程中,更新进度
  }
});
// 开始计算
worker.postMessage({
  file,
  uid: file.uid,
});
// 终止计算
worker.postMessage({
  uid,
  cancel: true,
});

分片上传

通过分析 Element Plus 的 upload 组件,我们可以通过个性化它的上传请求属性 http-request,在里面来实现整个分片的请求过程。

根据上面的原理分析,整个分片上传的过程应该是:

  1. 计算文件 MD5
  2. 发送查询文件上传状态请求
  3. 根据响应,上传还未上传的分片
  4. 所有分片上传结束后,发送一个结束上传的请求,告知服务端

upload 控件的配置如下:

html 复制代码
<template>
  <el-upload :action="action" v-model:file-list="fileList" :http-request="handleRequest" >
  </el-upload>
</template>

handleRequest 方法的实现如下:

ts 复制代码
const doUploadMap = new Map<number, (md5: string) => void>(); // 记录具体某个文件的上传执行方法
const md5Progress = ref<Record<number, number>>({}); // 记录文件的上传进度
const md5Map = new Map<number, string>(); // 记录文件的 MD5 值
  
const handleRequest = (options: UploadRequestOptions) => {
  const file = options.file as UploadRawFile;
  // 上传文件方法
  const doUploadFn = async (md5: string) => {
    // 将 MD5 值放到请求参数里
    options.data.fileMD5 = md5;
    
    const uploadFile = fileList.value.find((f) => f.uid === file.uid)!;
    // 查询文件上状态
    const {
      fileFinished,
      existFileParts,
      attachGuid,
      uploadDate,
      downloadUrl,
    } = (
      await queryFileStatus(options, {
        fileMD5: md5,
        fileName: file.name,
        fileSize: file.size,
        lastModifiedDate: new Date(file.lastModified).toISOString(),
        uploadGuid: file.uid,
      })
    ).result;

    if (fileFinished) {
      // 已上传过,直接结束(秒传)
      uploadFile.percentage = 100;
        
      updateFileStatusToSuccess(uploadFile, {
        attachGuid,
        downloadUrl,
        uploadDate,
      });
    } else {
      // 上传分片
      await uploadChunks(options, existFileParts.split(','));
      // 发送结束上传请求
      const { uploadFailed, failedMsg, result } = await finishedChunkUpload(
        options,
        {
          fileMD5: md5,
          fileName: file.name,
          fileSize: file.size,
          lastModifiedDate: new Date(file.lastModified).toISOString(),
          uploadGuid: file.uid,
          contentType: file.type,
        }
      );

      const { attachGuid, uploadDate, downloadUrl } = result;

      if (uploadFailed) {
        uploadFile.status = 'fail';
      } else {
        updateFileStatusToSuccess(uploadFile, {
          attachGuid,
          downloadUrl,
          uploadDate,
        });
      }
    }
  };
  
  // 获取文件 MD5
  const md5 = md5Map.get(file.uid);

  if (md5) {
    // MD5 已计算过,直接用
    md5Progress.value[file.uid] = 100;
    doUploadFn(md5);
  } else {
    // 通知 worker 计算 MD5
    worker.postMessage({
      file,
      uid: file.uid,
    });
    // 将上传方法缓存起来,以备 MD5 计算完成后调用
    doUploadMap.set(file.uid, doUploadFn);
  }
};

// worker 的监听
worker.addEventListener('message', async (e: MessageEvent) => {
  const data = e.data;
  if (data.status === 'success') {
    // MD5 计算完成, 开始上传
    md5Map.set(data.uid, data.md5);

    await doUploadMap.get(data.uid)?.(data.md5);
    doUploadMap.delete(data.uid);
  } else if (data.status === 'progress') {
    // 计算中,更新进度
    md5Progress.value[data.uid] = data.percent;
  }
});

其中查询文件上传状态方法 queryFileStatus 定义如下:

ts 复制代码
const queryFileStatus = (
  options: UploadRequestOptions,
  params: Record<string, any>
) => {
  const clonedOptions = cloneDeep(options);
  Object.assign(clonedOptions.data, params);
  clonedOptions.data.action = 'queryFileStatus';
  // 发送请求
  return ajax<{
    result: {
      fileFinished: boolean;
      existFileParts: string;
    }
  }>(clonedOptions);
};

结束上传方法 finishedChunkUpload 的定义如下:

ts 复制代码
const finishedChunkUpload = (
  options: UploadRequestOptions,
  params: Record<string, any>
) => {
  const clonedOptions = cloneDeep(options);
  Object.assign(clonedOptions.data, params);
  clonedOptions.data.action = 'finishUpload';
  // 发送请求
  return ajax<{
    uploadFailed: boolean;
    failedMsg?: string;
    result: {
      attachGuid: string;  
      uploadDate: string;  
      downloadUrl: string;
    };
  }>(clonedOptions);
};

上传分片方法 uploadChunks 定义如下:

ts 复制代码
const uploadChunks = (
  options: UploadRequestOptions,
  uploadedList: string[] = []
) => {
  const rawFile = options.file as UploadRawFile;
  // 创建分片列表
  const chunkList = createFileChunk(rawFile, realChunkSize.value);
  const totalChunkNum = chunkList.length;
  const uploadFile = fileList.value.find((f) => f.uid === file.uid)!;

  uploadFile.status = 'uploading';
  uploadFile.percentage = (uploadedList.length / totalChunkNum) * 100;

  return new Promise((resolve) => {
    // 构建分片的请求列表
    const requestList = chunkList
      .filter(({ index }) => !uploadedList.includes(index)) // 过滤掉已上传的分片(断点续传)
      .map(({ file, index }) => {
        const clonedOptions = cloneDeep(options);
        
        // 重写上传进度的计算实现
        clonedOptions.onProgress = (evt: UploadProgressEvent) => {
          const percent = evt.percent;
          const prePercent = chunkProgressMap.get(Number.parseInt(index, 10)) ?? 0;

          chunkProgressMap.set(Number.parseInt(index, 10), percent);
          uploadFile.percentage = Math.min(
            100,
            Number.parseFloat(
              (
                (percent - prePercent) / totalChunkNum +
                uploadFile.percentage!
              ).toFixed(1)
            )
          );
        };
        
        clonedOptions.onSuccess = () => {};
        clonedOptions.file = file as File;
        clonedOptions.data.chunk = index;
        clonedOptions.data.chunks = String(totalChunkNum);
        clonedOptions.data.chunkSize = String(realChunkSize.value);
        clonedOptions.data.action = 'chunk';
        
        // 返回请求处理方法
        return (index: number) =>
          ((ajax(clonedOptions) as XMLHttpRequest).onloadend = () => {
            notFinishedChunkSet.delete(index);
            chunkProgressMap.set(index, 100);
            // 完成后继续下一片的请求
            uploadNext();
          });
      });
      
    let curIndex = 0;
    // 控制最多同时只能上传 3 个分片
    const l = Math.min(requestList.length, 3);
    const notFinishedChunkSet = new Set<number>();
    const chunkProgressMap = new Map<number, number>();
    for (let i = 0; i < requestList.length; i++) {
      notFinishedChunkSet.add(i);
    }
    // 继续下一片的请求
    const uploadNext = () => {
      if (notFinishedChunkSet.size === 0) {
        resolve('finished');
      } else if (curIndex < requestList.length) {
        requestList[curIndex](curIndex);
        curIndex++;
      }
    };
    // 开始发送请求,可最多同时发送 3 个请求
    for (; curIndex < l; curIndex++) {
      requestList[curIndex](curIndex);
    }
  });
};

/**
 * 生成文件切片
 * @param {*} file 上传的文件
 * @param {*} size  分片大小
 * @return {*}
 */
const createFileChunk = (file: UploadRawFile, size: number) => {
  const fileChunkList: {
    file: Blob;
    index: string;
  }[] = [];
  let cur = 0,
    index = 0;
  while (cur < file.size) {
    fileChunkList.push({
      file: Object.assign(file.slice(cur, cur + size), {
        name: file.name,
        type: file.type,
        lastModified: file.lastModified,
      }),
      index: String(index),
    });
    cur += size;
    index++;
  }
  return fileChunkList;
};

总结

本文以 Element Plus 的 upload 组件为基础,介绍了大文件的分片上传、断点继续、秒传设计思路和实现代码。如果本文对你有收获,麻烦动动发财的小手,点点关注、点点赞!!!

如果有不对、可以优化的地方欢迎在评论区指出,谢谢。

相关推荐
VT.馒头1 小时前
【力扣】2625. 扁平化嵌套数组
前端·javascript·算法·leetcode·职场和发展·typescript
毎天要喝八杯水2 小时前
搭建vue前端后端环境
前端·javascript·vue.js
San30.3 小时前
从零构建坚固的前端堡垒:TypeScript 与 React 实战深度指南
前端·react.js·typescript
东东5164 小时前
果园预售系统的设计与实现spingboot+vue
前端·javascript·vue.js·spring boot·个人开发
怪兽毕设5 小时前
基于SpringBoot的选课调查系统
java·vue.js·spring boot·后端·node.js·选课调查系统
Amumu121385 小时前
Vue Router(一)
前端·javascript·vue.js
VT.馒头5 小时前
【力扣】2694. 事件发射器
前端·javascript·算法·leetcode·职场和发展·typescript
切糕师学AI5 小时前
VSCode 下如何检查 Vue 项目中未使用的依赖?
vue.js·vscode
我是伪码农6 小时前
Vue 1.30
前端·javascript·vue.js
利刃大大6 小时前
【Vue】默认插槽 && 具名插槽 && 作用域插槽
前端·javascript·vue.js