前端大文件切片上传

21 阅读5分钟

背景

当一个大文件只用一次请求发送给后端,传输时间就会非常的长,一旦请求传输过程中出现问题,比如网络断开了,就要把整个文件重新传输。

大文件分片上传就通过切片技术将大文件分割成小块,每个小块可以作为独立的文件上传。其次,使用哈希算法来唯一标识文件,确保文件上传的完整性和可恢复性。

files: 通过input标签读过来的文件对象

formData: 用于和后端传输的对象

FileReader: 异步读取文件内容

上传流程: image.png 前后端完整的流程:

一、文件切片

对file对象进行切片,主要是使用file.slice(start, end)方法,start :分片的起始字节位置,end :分片的结束字节位置。切完片之后将每一片存储在Blod数组中返回。

const createChunks = (file: File): Blob[] => {
    let cur = 0;
    const chunks: Blob[] = [];
    while (cur < file.size) {
      const blob = file.slice(cur, cur + CHUNK_SIZE);
      chunks.push(blob);
      cur += CHUNK_SIZE;
    }
    return chunks;
  };

二、文件Hash值计算

如果所有切片都完整的参与hash计算,那计算量会非常的大,可能造成页面卡死(可考虑放进web worker里面)。这里通过采样的方式对计算做了优化:第一个和最后一个切片,完整参与计算。 中间的切片,只取前、中、后各2字节,这样计算量就会少很多。注意:如果需要真正唯一的文件指纹,就要全量计算hash。

    fileChunks.forEach((chunk, index) => {
        if (index === 0 || index === fileChunks.length - 1) {
          // 第一个和最后一个切片,完整参与计算
          chunks.push(chunk);
        } else {
          // 中间切片,只取前、中、后各2字节
          chunks.push(chunk.slice(0, 2));
          chunks.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2));
          chunks.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE));
        }
      });

FileReader对象的readAsArrayBuffer方法可以读取二进制内容。在reader.onload回调中可以拿到读取的内容,再内容通过CRM已经有的CryptoJS 库实现文件的hash计算。由于读取内容的过程是异步的,所以要把hash计算过程放进Promise里面。

      const reader = new FileReader();

      reader.onload = (e) => {
        md5.update(CryptoJS.lib.WordArray.create(e.target?.result as ArrayBuffer));
        resolve(md5.finalize().toString(CryptoJS.enc.Hex));
      };
      
      reader.readAsArrayBuffer(new Blob(chunks));

完整的代码:

 const calculateHash = async (fileChunks: Blob[]): Promise<string> => {
    return new Promise((resolve) => {
      const chunks: Blob[] = [];
      const md5 = CryptoJS.algo.MD5.create();
      fileChunks.forEach((chunk, index) => {
        if (index === 0 || index === fileChunks.length - 1) {
          // 第一个和最后一个切片,完整参与计算
          chunks.push(chunk);
        } else {
          // 中间切片,只取前、中、后各2字节
          chunks.push(chunk.slice(0, 2));
          chunks.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2));
          chunks.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE));
        }
      });

      const reader = new FileReader();

      reader.onload = (e) => {
        md5.update(CryptoJS.lib.WordArray.create(e.target?.result as ArrayBuffer));
        resolve(md5.finalize().toString(CryptoJS.enc.Hex));
      };
      reader.readAsArrayBuffer(new Blob(chunks));
    });
  };

使用Web worker处理Hash计算

主线程和worker之间的通信是通过postmessage(data)方法,主线程和worker线程都是通过监听onmessage方法来接收对方传过来的数据。

一、新建处理hash计算的脚本文件

importScripts('https://cdn.bootcdn.net/crypto-js/4.2.0/crypto-js.min.js');

self.onmessage = (e) => {
  // 接收主线程传过来的文件切片
  const { fileChunks } = e.data;
  try {
    const md5 = CryptoJS.algo.MD5.create();
    const reader = new FileReader();
    reader.onload = (res) => {
      md5.update(CryptoJS.lib.WordArray.create(res.target?.result as ArrayBuffer));
      const hash = md5.finalize().toString(CryptoJS.enc.Hex);
      // 把hash值计算的结果传回给主线程
      self.postMessage({ success: true, hash });
    };
    reader.readAsArrayBuffer(new Blob(fileChunks));
  } catch (error) {
    self.postMessage({ success: false, error: error });
  }
};

二、主线程创建worker线程,加载脚本,传递文件切片

const calculateHashByWorker = async (fileChunks: Blob[]): Promise<string> => {
    return new Promise((resolve, reject) => {
      // 创建worker线程,加载脚本
      const worker = new Worker(new URL('./hashWorker.ts', import.meta.url));
      // 发送数据到 Worker线程
      worker.postMessage({ fileChunks });
      // 接收worker线程计算好的hash值
      worker.onmessage = (e) => {
        // 关闭worker线程
        worker.terminate();
        if (e.data.success) {
          resolve(e.data.hash);
        } else {
          reject(e.data.error);
        }
      };
    });
  };

三、发送上传切片请求(并发请求限制)

定义上传配置和带自动重试的上传函数。每个分片失败后最多重试 3 次,每次重试间隔递增(1s、2s、3s),避免瞬时重试压垮服务。成功则返回响应,彻底失败则抛出错误。

let index = 0;
const max = 5; // 最大并发数
const maxRetries = 3; // 最大重试次数

async function uploadWithRetry(chunk, partNumber, totalCount, hash, filename, retries = 0) {
  const formData = new FormData();
  formData.append('file', chunk);
  formData.append('partNumber', String(partNumber));
  formData.append('totalCount', String(totalCount));
  formData.append('unicode', hash);
  formData.append('filename', filename);

  try {
    return await axios.post('http://localhost:8080/BigFile/', formData, { timeout: 60000 });
  } catch (error) {
    if (retries < maxRetries) {
      await new Promise(resolve => setTimeout(resolve, 1000 * (retries + 1)));
      return uploadWithRetry(chunk, partNumber, totalCount, hash, filename, retries + 1);
    }
    throw error; // 超过重试次数,抛出错误
  }
}

遍历所有分片,为每个分片创建一个上传任务(Promise),并立即加入任务池 taskPool。通过 .finally() 确保无论成功或最终失败,任务完成后都会从池中移除,防止内存泄漏或死锁。

const taskPool = [];

while (index < chunks.length) {
  const partNumber = index + 1;
  const task = uploadWithRetry(chunks[index], partNumber, chunks.length, hash, file.name);

  task.finally(() => {
    const idx = taskPool.indexOf(task);
    if (idx !== -1) taskPool.splice(idx, 1);
  });

  taskPool.push(task);

当并发任务数达到上限(5 个)时,暂停启动新任务,等待任意一个任务完成(Promise.race 返回最快结束的任务)。这样始终保持最多 5 个请求在进行,实现平滑的并发控制。

 if (taskPool.length >= max) {
    await Promise.race(taskPool);
  }
 index++;

主循环结束后,等待剩余任务全部完成。如果所有分片都成功(包括重试后成功),则继续后续流程;只要有一个分片最终失败(超过重试次数),Promise.all 会 reject,整个上传失败。

await Promise.all(taskPool);

完整的代码:

let index = 0;
const max = 5; // 最大并发数
const maxRetries = 3; // 最大重试次数

// 带重试的上传函数(返回一个 Promise)
async function uploadWithRetry(
  chunk: Blob,
  partNumber: number,
  totalCount: number,
  hash: string,
  filename: string,
  retries = 0
): Promise<any> {
  const formData = new FormData();
  formData.append('file', chunk);
  formData.append('partNumber', String(partNumber));
  formData.append('totalCount', String(totalCount));
  formData.append('unicode', hash);
  formData.append('filename', filename);

  try {
    const res = await axios.post('http://localhost:8080/BigFile/', formData, {
      timeout: 60000, // 可选:设置超时
    });
    return res;
  } catch (error) {
    if (retries < maxRetries) {
      console.warn(`分片 ${partNumber} 上传失败,第 ${retries + 1} 次重试...`, error.message);
      // 可选:指数退避延迟
      await new Promise(resolve => setTimeout(resolve, 1000 * (retries + 1)));
      return uploadWithRetry(chunk, partNumber, totalCount, hash, filename, retries + 1);
    } else {
      console.error(`分片 ${partNumber} 上传最终失败,已重试 ${maxRetries} 次`);
      throw error; // 最终失败,抛出错误
    }
  }
}

// 任务池
const taskPool: Promise<any>[] = [];

while (index < chunks.length) {
  const partNumber = index + 1;

  // 创建带重试逻辑的任务(对外仍是一个 Promise)
  const task = uploadWithRetry(
    chunks[index],
    partNumber,
    chunks.length,
    hash,
    file.name
  );

  // ✅ 重要:无论成功/失败,完成后都从任务池移除
  task.finally(() => {
    const idx = taskPool.indexOf(task);
    if (idx !== -1) {
      taskPool.splice(idx, 1);
    }
  });

  taskPool.push(task);

  // 控制并发:当达到最大并发数,等待任意一个完成(包括失败)
  if (taskPool.length >= max) {
    await Promise.race(taskPool); // race 会等第一个 settled(fulfilled/rejected)的 Promise
  }

  index++;
}

// 等待所有任务完成(如果任何任务最终失败,这里会 throw)
await Promise.all(taskPool);