#浏览器分片上传&&计算MD5踩坑日记

一、项目技术栈

React+Ts+Antd(二次封装,使用方法基本与antd一致)

二、需求背景

近期平台需要实现一个上传安装包的功能,安装包大小在1-10GB左右,并且可能存在多个包。需要前端进行分片上传,涉及到多个文件同时选择点击上传同时或者按照队列进行上传到服务端。细节实现例如多个文件选择、进度条等实现不做概述了。

三、处理思路

作为一个本科且经验不足的菜鸟,拿到需求的第一时间当然是上网查看各位大牛们的文章了

观摩了大概三篇分片上传的文章,万变不离其宗,实现的方法基本都是大同小异,下面贴上我的第一版分片及计算MD5的代码。spark-md5是用于计算文件md5的开源依赖,直接照搬前辈们的用法。

分片&&计算MD5
ini 复制代码
import SparkMD5 from 'spark-md5';
/**
 * 将目标文件分片 并 计算文件Hash
 * @param {File} targetFile 目标上传文件
 * @param {number} baseChunkSize 上传分块大小,单位Mb
 * @returns {chunkList:ArrayBuffer,fileHash:string}
 */
export async function sliceFile(targetFile: RcFile, baseChunkSize = 20) {
  return new Promise((resolve, reject) => {
    if (!targetFile) {
      return;
    }
    let fileName = targetFile?.name;
    let fileSize = targetFile.size;
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
    let chunkSize = baseChunkSize * 1024 * 1024;
    let targetChunkCount = targetFile && Math.ceil(targetFile.size / chunkSize);
    let currentChunkCount = 0;
    let chunkList: (string | ArrayBuffer | null)[] = [];
    let spark = new SparkMD5.ArrayBuffer();
    let fileReader = new FileReader();
    let fileHash = null;

    fileReader.onload = (e) => {
      const curChunk = e?.target?.result;
      spark.append(curChunk as ArrayBuffer);
      currentChunkCount++;
      chunkList.push(curChunk as ArrayBuffer);
      if (currentChunkCount >= targetChunkCount) {
        fileHash = spark.end();
        resolve({ chunkList, fileHash, fileName, fileSize });
      } else {
        loadNext();
      }
    };

    fileReader.onerror = () => {
      reject(null);
    };

    const loadNext = () => {
      const start = chunkSize * currentChunkCount;
      let end = start + chunkSize;
      if (end > targetFile.size) {
        end = targetFile.size;
      }
      fileReader.readAsArrayBuffer(blobSlice.call(targetFile, start, end));
    };

    loadNext();
  });
}

上面代码为给单个文件分片及计算md5,返回文件分片后的各种参数。以下是上述sliceFile方法的使用代码:

javascript 复制代码
//上传开始,全量解析文件列表
  function upload() {
    ......
    //fileList已选择的文件列表
    const promises = fileList.map((file) => {
      return sliceFile(file.originFileObj as RcFile);
    });
    ......
    //因为前端校验啥的,需要全部解析完毕
    Promise.all(promises)
      .then((res: Array<ShardFileInterface>) => {
           //分片完成,根据自己的需求进行后续的上传操作
          requestUploadFile(res);
      })
      .catch((err) => {
        notification.error({
          message: '上传失败',
          description: err,
        });
      });
  }

省略部分代码,只展示此需求的主体部分。后续上传操作就是根据所有文件分片的列表进行依次上传了,一个文件上传完成后上传下一个,为了降低浏览器负载和顾及产品在私有化场景下的网络可能比较差,一次性上传的分片不建议大于6片!!!!接口过多会导致浏览器请求阻塞。

中断请求

正当我喜滋滋的和后端联调的时候,他说我要是不想上传了咋整,你这几个分片请求还在传啊,阻塞了页面其它请求了啊。哦对,别忘了这回事儿,加上取消请求的逻辑。

总之下面代码是加上了取消逻辑的分片请求,省略了很多根据自身需求写的代码,

ini 复制代码
let signal: AbortSignal | undefined;

function initController() {
    controller = new AbortController();
    signal = controller.signal;
 }
......
//参数写法是自己axios封装的用法,参考逻辑就好
const params = {
      file: new Blob([fileInfo.file]),
      filename: fileInfo.fileName,
      data: {
        filename: fileInfo.fileName,
        md5: fileInfo.md5,
        start: fileInfo.start,
        fileSize: fileInfo.fileSize,
        md5List: fileInfo.md5List,
        isLast: fileInfo.isLast,
        timeStamp: timeRef.current,
      },
};
const result = await upLoadSliceFile(params, signal);
if (!isNil(result) && result.code === 200) {
      if (!result.data) {
        controller?.abort();
        initController();
        message.error(result.msg);
      }
      return result.data;
} else if (result.code) {
      controller?.abort();
      initController();
      message.error(result.msg);
} else {
        ......
}
......

完美运行,提交测试!!!!!!!

------------------------------------------------------这是一条分割线---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

四、问题点&&解决

一个完美的清明假期过去了,寻常的周日补班下班之后,我骑着电动车哼着歌等红绿灯。收到一条项目群内@我的消息如下:

Damnnnnnnnnnnn,

总之心情很damn,回家打开我八百年没打开的向日葵连公司电脑自己先看看,上传一个6个多GB的包看一眼,草(这里泛指一种碧绿色的植物)!!!

破案了,我的锅,看了几眼自己的代码,我当时采用的一次性分片的方法,也就是说我一下干出了6个G的分片列表,这个列表是Arraybuffer格式,而Arraybuffer数据是存储在内存的,按照我的逻辑,上传完成之前这6个G都不会释放的,这当然是万万不可的,要是一个内存稍小的电脑,这还不直接干崩溃。

javascript 复制代码
import SparkMD5 from 'spark-md5';
/**
 * 将目标文件分片 并 计算文件Hash
 * @param {File} targetFile 目标上传文件
 * @param {number} baseChunkSize 上传分块大小,单位Mb
 * @returns {chunkList:ArrayBuffer,fileHash:string}
 */
export async function sliceFile(targetFile: RcFile, baseChunkSize = 20) {
    ......
    //当前以收集的分片
    let chunkList: (string | ArrayBuffer | null)[] = [];
    ......

    //FilerReader onload事件
    fileReader.onload = (e) => {
      //当前读取的分块结果 ArrayBuffer
      const curChunk = e?.target?.result;
       ......
      chunkList.push(curChunk as ArrayBuffer);
      //判断分块是否全部读取成功
      if (currentChunkCount >= targetChunkCount) {
        //全部读取,获取文件hash
        fileHash = spark.end();
        resolve({ chunkList, fileHash, fileName, fileSize });
      } else {
        loadNext();
      }
    };
    ......
}

问题出现了,还好是在测试阶段。着手准备优化,优化思路:改变分片逻辑,但是前期我们该用的逻辑还是得用(这里指sliceFile中计算文件MD5和文件的各种信息),我只需要在每次发请求的时候去对文件进行字节到字节的切割和格式转换就ok了

前面我忘了,中间我也忘了,总之狠狠的敲了代码。

新的一天新的面貌,弃用之前的所有方法(换皮不换心),核心代码如下:把第一版的分片和计算md5优化为两个方法,第一个方法获取文件的MD5、总分片数、文件大小(除了MD5其它的参数是留着备用的)。第二个方法是分片方法,接受参数为文件以及当前上传的片数索引,获取当前应该上传的分片以及开始位置结束位置。

ini 复制代码
import { ShardFileInterface } from '@/pages/Upgrade/Modal/CreateVersionModal/VersionSliceUpload/VersionSliceUpload';
import SparkMD5 from 'spark-md5';

/**
 * 将目标文件分片 并 计算文件Hash
 * @param {File} targetFile 目标上传文件
 * @param {number} baseChunkSize 上传分块大小,单位Mb
 * @returns {chunkList:ArrayBuffer;fileHash:string;fileName: string; fileSize: string}
 */
export async function getFileSliceInfo(targetFile: RcFile, baseChunkSize = 20) {
  return new Promise((resolve, reject) => {
    if (!targetFile) {
      return;
    }
    let fileName = targetFile?.name;
    let fileSize = targetFile.size;
    //初始化分片方法,兼容问题
    //@ts-ignore
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
    let chunkSize = baseChunkSize * 1024 * 1024;
    let targetChunkCount = targetFile && Math.ceil(targetFile.size / chunkSize);
    let currentChunkCount = 0;
    let spark = new SparkMD5.ArrayBuffer();
    let fileReader = new FileReader();
    let fileHash = null;

    fileReader.onload = (e) => {
      const curChunk = e?.target?.result;
      spark.append(curChunk as ArrayBuffer);
      currentChunkCount++;
      if (currentChunkCount >= targetChunkCount) {
        fileHash = spark.end();
        resolve({ targetChunkCount, fileHash, fileName, fileSize });
      } else {
        loadNext();
      }
    };

    //FilerReader onerror事件
    fileReader.onerror = () => {
      reject(null);
    };

    const loadNext = () => {
      const start = chunkSize * currentChunkCount;
      let end = start + chunkSize;
      if (end > targetFile.size) {
        end = targetFile.size;
      }
      fileReader.readAsArrayBuffer(blobSlice.call(targetFile, start, end));
    };
    loadNext();
  });
}


/**
 * 获取当前分片
 */
export async function getCurrentBatchList(targetFile: RcFile, beginIndex: number, baseChunkSize = 20) {
  return new Promise((resolve, reject) => {
    if (!targetFile) {
      return;
    }
    //@ts-ignore
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
    let chunkSize = baseChunkSize * 1024 * 1024;
    let fileReader = new FileReader();
    const loadNext = () => {
      const start = chunkSize * beginIndex;
      let end = start + chunkSize;
      if (end > targetFile.size) {
        end = targetFile.size;
      }
      fileReader.readAsArrayBuffer(blobSlice.call(targetFile, start, end));
    };
    loadNext();
    fileReader.onload = (e) => {
      const curChunk = e?.target?.result;
      resolve({ curChunk, start: beginIndex * chunkSize, end: beginIndex * chunkSize + chunkSize });
    };
    fileReader.onerror = () => {
      reject(null);
    };

  });
}



  //上传开始,全量解析文件列表
  function upload() {
    if (fileList.length === 0) {
      message.error('请选择文件');
      return;
    }
    ......
    //解析文件列表及分片操作,此操作耗时较长
    const promises = fileList.map((file) => {
      return getFileSliceInfo(file.originFileObj as RcFile, SLICE_CHUNK_SIZE);
    });
    Promise.all(promises)
      .then((res: Array<ShardFileInterface>) => {
           ......
          requestUploadFile(res);
           ......
      })
      .catch((err) => {
        notification.error({
          message: '上传失败',
          description: err,
        });
      });
  }


/**
 * 上传文件
 * @param allFileSliceInfoList 所有文件切片列表
 */
 async function requestUploadFile(allFileSliceInfoList: ShardFileInterface[]) {
    //文件索引
    let fileIndex = 0;
    //标识是否是初始化失败
    const md5List = allFileSliceInfoList.map((item) => item.fileHash);
    allFileMd5List.current = md5List;
    //文件列表依次上传
    for (const singleFileSliceInfoList of allFileSliceInfoList) {
      fileIndex++;
      const data = fileUploadResultRef.current
        ? await requestUploadSingleFile(singleFileSliceInfoList, md5List, fileIndex === allFileSliceInfoList.length)
        : false;
      if (!data) {
        ......
        break;
      }
      if (fileIndex === allFileSliceInfoList.length && fileUploadResultRef.current) {
        //上传完成操作
        ......
      }
    }
}


/**
 * 上传单个文件
 * @param fileSliceInfoList 当前上传文件的切片列表
 * @param md5List 所有文件的md5列表
 * @param isLast 是否是文件列表最后一个文件
 */
async function requestUploadSingleFile(fileSliceInfoList: ShardFileInterface, md5List: Array<string>, isLast: boolean) {
    //并发限制
    const concurrencyLimit = 4;
    //上传结果
    ......
    const currentOriginFile = fileList.find((item) => item.name === fileSliceInfoList.fileName);
    if (!currentOriginFile?.originFileObj) {
      return false;
    } else {
      for (let currentBatchIndex = 0; currentBatchIndex < fileSliceInfoList.targetChunkCount; currentBatchIndex += concurrencyLimit) {
        // 获取当前批次的分片列表
        let promises = [];
        for (let i = 0; i < concurrencyLimit; i++) {
          promises.push(getCurrentBatchList(currentOriginFile?.originFileObj, currentBatchIndex + i, SLICE_CHUNK_SIZE));
        }
        let batchChunks: Array<{ curChunk: ArrayBuffer; start: number; end: number }> = [];
        await Promise.all(promises).then((res: Array<{ curChunk: ArrayBuffer; start: number; end: number }>) => {
          batchChunks = res;
        });
        let result = 1;
        await Promise.all(
          batchChunks.map(async (chunkInfo) => {
            const data = result
              ? await requestUploadChunk({
                  file: chunkInfo.curChunk,
                  start: chunkInfo.start,
                  md5: fileSliceInfoList.fileHash,
                  fileName: fileSliceInfoList.fileName,
                  fileSize: fileSliceInfoList.fileSize,
                  md5List: md5List,
                  isLast: isLast,
                })
              : UPLOAD_RESULT.FAIL;
            ......
          }),
        ).then(() => {
        .......
        //接口失败或者该文件需要秒传
        if (result === UPLOAD_RESULT.FAIL) {
          ......
          resultFlag = false;
          break;
        } else if (result === UPLOAD_RESULT.CANCEL) {
          ......
          resultFlag = true;
          break;
        }
      }
    }
    return resultFlag;
}

/**
 * 分片上传
 * @param fileInfo
*/
const requestUploadChunk = async (fileInfo: RequestUploadInterface) => {
    const params = {
      file: new Blob([fileInfo.file]),
      filename: fileInfo.fileName,
      data: {
        filename: fileInfo.fileName,
        md5: fileInfo.md5,
        start: fileInfo.start,
        fileSize: fileInfo.fileSize,
        md5List: fileInfo.md5List,
        isLast: fileInfo.isLast,
        timeStamp: timeRef.current,
      },
    };
    //分片上传接口
    const result = await upLoadSliceFile(params, signal);
    if (!isNil(result) && !eq(result, '请求错误') && result.code === 200) {
      if (!result.data) {
        controller?.abort();
        initController();
        message.error(result.msg);
      }
      return result.data;
    } else if (result.code) {
      controller?.abort();
      initController();
      message.error(result.msg);
    } else {
        ......
    }
};

五、总结

总而言之,今天也是小陈在武汉摸鱼的一天,看到此处的同志们摸鱼的时候图个乐呵就行了,代码和解决思路仅代表我个人想法。仅仅是我个人摸鱼寻思把自己的踩坑日记记录下来,望各位共勉!!!

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax