前端切片上传、上传进度、断点续传、秒传

发布于:2025-04-04 ⋅ 阅读:(21) ⋅ 点赞:(0)

一:整体流程

1.1. 核心流程图解

已存在
部分存在
不存在
开始上传
文件分片
计算文件Hash
查询服务器状态
秒传成功
上传剩余分片
上传全部分片
合并文件
上传完成

1.2. 关键实现步骤

  1. 文件分片与哈希计算
    • 将大文件切割为多个固定大小的块(如 1MB/片)
    • 使用 SparkMD5 计算文件唯一指纹
  2. 断点续传实现
    • 通过文件哈希查询服务器已上传分片
    • 仅上传缺失的分片
  3. 并发控制策略
    • 使用 Promise 实现请求队列
    • 限制最大并发数(默认 3 个并行请求)
  4. 进度同步机制
    • 基于已上传分片数计算整体进度
    • 通过回调函数实时更新进度

二:核心代码实现解析

2.1. 文件分片与哈希计算

/**
 * 将目标文件分片 并 计算文件Hash
 * @param {File} targetFile 目标上传文件
 * @param {number} baseChunkSize 上传分块大小,单位Mb
 * @returns {chunkList:ArrayBuffer,fileHash:string}
 */
async function sliceFile(targetFile, baseChunkSize = 1) {
  return new Promise((resolve, reject) => {
    // 初始化分片方法
    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 = [];
    let spark = new SparkMD5.ArrayBuffer();
    let fileReader = new FileReader();
    let fileHash = null;

    // 检查文件是否存在
    if (!targetFile) {
      return reject(new Error('文件不存在'));
    }

    fileReader.onload = e => {
      const curChunk = e.target.result;
      // 将当前分快追加到 spark 对象中
      spark.append(curChunk);
      currentChunkCount++;
      chunkList.push(curChunk);

      // 判断分快是否全部读取成功
      if (currentChunkCount >= targetChunkCount) {
        // 全部读取完成,计算文件Hash
        fileHash = spark.end();
        resolve({
          chunkList,
          fileHash
        });
      } else {
        loadNext();
      }
    };

    fileReader.onerror = () => reject(new Error('文件读取失败'));

    // 未全部读取完成,读取下一个分快
    const loadNext = () => {
      // 计算分快的起始位置和终止位置
      const start = chunkSize * currentChunkCount;
      const end = start + chunkSize;

      if (end > targetFile.size) end = targetFile.size;

      // 读取文件,触发 fileReader.onload 事件
      fileReader.readAsArrayBuffer(blobSlice.call(targetFile, start, end));
    };

    loadNext();
  });
}

关键点说明:

  • 使用 File.slice() 实现文件切割
  • 分片大小建议根据实际场景调整(视频文件可适当增大)
  • MD5 计算全程增量更新,避免内存溢出

2.2. 分片上传队列控制

/**
 * 将文件分片数据发送到服务器
 * @param {Array<Object>} postFormData 包含 FormData 的对象数组,由 uploadChunk 函数生成
 * @param {number} limit 并发上传的最大数量,默认为 3
 * @param {string} uploadUrl 上传文件的服务器地址
 * @returns {Promise<boolean>} 上传成功返回 true
 * @throws {Error} 上传失败抛出错误
 * 
 * 实现了以下功能:
 * 1. 控制并发上传数量
 * 2. 失败自动重试,最多重试 3 次
 * 3. 跟踪上传进度
 * 4. 所有分片上传完成后返回结果
 */
function postToServer(postFormData, limit = 3, uploadUrl) {
  return new Promise((resolve, reject) => {
    if (!postFormData || !postFormData.length) {
      resolve(true);
      return;
    }

    if (!uploadUrl) {
      reject(new Error('上传URL不能为空'));
      return;
    }

    let len = postFormData.length;
    let counter = 0;
    let isStop = false;

    const startPost = async () => {
      // 检查是否还有数据需要上传
      if (postFormData.length === 0 || isStop) {
        return;
      }

      const formDatas = postFormData.shift();
      if (!formDatas) return;

      try {
        await requestInstance.post(uploadUrl, formDatas.formData);
        counter++;
        formDatas.progress = Math.ceil(counter / len * 100);

        // 所有请求都已结束,返回结果
        if (counter === len) {
          resolve(true);
          return;
        }

        // 请求还未结束,继续启动任务
        startPost();
      } catch (error) {
        if (formDatas.error >= 3) {
          isStop = true;
          reject(new Error('上传失败,已重试3次'));
          return;
        }
        formDatas.error++;
        // 将错误的内容放到数据列表中,然后立马重试
        postFormData.unshift(formDatas);
        startPost();
      }
    };

    // 限制并发数,启动上传任务
    const actualLimit = Math.min(limit, postFormData.length);
    for (let index = 0; index < actualLimit; index++) {
      startPost();
    }
  });
}

并发控制要点:

  • 使用计数器控制最大并行数
  • 任务队列动态管理
  • 错误自动重试机制(默认最多重试3次)

2.3. 断点续传实现逻辑

/**
 * @param {File} file 目标上传文件
 * @param {number} baseChunkSize 上传分块大小,单位Mb
 * @param {string} uploadUrl 上传文件的后端接口地址
 * @param {string} vertifyUrl 验证文件上传的接口地址
 * @param {string} mergeUrl 请求进行文件合并的接口地址
 * @param {Function} progress_cb 更新上传进度的回调函数
 * @returns {Promise}
 */
async function uploadFile(
  file,
  baseChunkSize,
  uploadUrl,
  vertifyUrl,
  mergeUrl,
  progress_cb
) {
  try {
    if (!file) {
      throw new Error('文件不能为空');
    }

    if (!progress_cb || typeof progress_cb !== 'function') {
      progress_cb = progress => {
        console.log(`上传进度: ${progress}%`);
      };
    }

    const { chunkList, fileHash } = await sliceFile(file, baseChunkSize);
    let allChunkList = chunkList;
    // 需要上传的分片
    let neededFileList = [...allChunkList]; // 默认所有分片都需要上传
    let progress = 0;

    if (vertifyUrl) {
      try {
        const { data } = await requestInstance.post(vertifyUrl, {
          fileHash,
          totalCount: allChunkList.length,
          extname: '.' + file.name.split('.').pop()
        });

        const { needFileList, message } = data;
        if (message) console.info(message);

        // 无待上传文件,秒传
        if (needFileList && needFileList.length === 0) {
          progress_cb(100);
          return { success: true, message: '文件秒传成功' };
        }

        // 部分上传成功,更新需要上传的分片
        if (needFileList) {
          neededFileList = needFileList;
        }
      } catch (error) {
        console.error('验证文件上传状态失败:', error);
        // 验证失败时继续上传所有分片
      }
    }

    // 断点续传

    // 同步上传进度
    progress =
      (allChunkList.length - neededFileList.length) / allChunkList.length * 100;
    progress_cb(progress);

    // 上传
    if (neededFileList.length) {
      const postFormData = uploadChunk(neededFileList, fileHash);
      await postToServer(postFormData, 3, uploadUrl);

      // 发送请求,通知后端合并
      const extname = '.' + file.name.split('.').pop();
      try {
        await requestInstance.post(mergeUrl, { fileHash, extname });
      } catch (error) {
        console.error('文件合并请求失败:', error);
        throw new Error('文件合并失败');
      }

      // 上传完成后更新进度为100%
      progress_cb(100);
      return { success: true, message: '文件上传成功' };
    }

    return { success: true, message: '文件上传完成' };
  } catch (error) {
    console.error('文件上传过程中发生错误:', error);
    progress_cb(0); // 重置进度
    throw error;
  }
}