Skip to content

大文件分片上传

更新: 2025/3/13 18:56:30

一、整体功能目标

实现一个大文件分片上传的功能,将大文件切割成小块(分片),并为每个分片计算哈希值(用于校验或去重),最终返回分片信息列表。


二、实现步骤与思考过程

1. 初步实现分片功能(单线程版本)

步骤:

  • HTML 前端部分:设计一个简单的文件上传界面,用户选择文件后触发 onchange 事件,获取文件对象。
  • 分片逻辑(cutFile.js)
    1. 定义分片大小 CHUNK*SIZE 为 5MB(1024 * 1024 _ 5)。
    2. 计算分片数量 chunkCount = Math.ceil(file.size / CHUNK_SIZE)。
    3. 通过循环调用 createChunk 函数,依次生成每个分片的元信息(包括 start、end、index 和 hash)。
    4. createChunk 使用 FileReader 读取文件的指定片段(通过 file.slice),并用 SparkMD5 计算哈希值。
  • 结果:返回一个包含所有分片信息的数组。

思考与问题:

  • 问题 1:性能瓶颈 大文件分片时,createChunk 是异步操作,且单线程顺序执行。假设文件大小为 100MB,分片大小为 5MB,需要 20 个分片,循环中的 await 会导致总耗时是每个分片处理时间的累加。对于大文件(如几个 GB),耗时会显著增加,用户体验变差。
  • 解决思路:初步实现功能是可行的,但性能优化是下一步需要考虑的重点。可以考虑并行处理来减少总耗时。

2. 优化版本:引入 Web Worker(多线程并行处理)

步骤:

  • 引入多线程思想
    1. 将分片任务分配给多个线程(Web Worker),通过 THREAD_COUNT(设为 4)控制线程数量。
    2. 计算每个线程需要处理的分片数量 workerChunkCount = Math.ceil(chunkCount / THREAD_COUNT)。
    3. 为每个线程分配任务范围(startIndex 和 endIndex),并通过 worker.postMessage 将文件对象和分片参数传递给 worker.js。
  • worker.js 的实现
    1. 接收主线程传递的数据(file、CHUNK_SIZE、startIndex、endIndex)。
    2. 在子线程中并行调用 createChunk,使用 Promise.all 等待所有分片处理完成。
    3. 将结果通过 postMessage 返回给主线程。
  • 主线程的协调
    1. 使用 result 数组保存所有分片信息,注意不能直接 push(...e.data),因为线程返回顺序不确定,需要按索引赋值。
    2. 通过 finishCount 计数器判断所有线程是否完成,当 finishCount === THREAD_COUNT 时,resolve 返回结果。
    3. 每个线程完成后调用 worker.terminate() 释放资源。

思考与问题:

  • 问题 2:线程分配不均

    如果分片数量不能被线程数整除,最后一个线程可能处理的分片较少。例如,10 个分片分配给 4 个线程,前三个线程处理 3 个分片,最后一个线程只处理 1 个分片,导致资源利用不均衡。

    • 解决方法:通过 Math.ceil(chunkCount / THREAD_COUNT) 计算每个线程的最大分片数,并动态调整 endIndex 不超过 chunkCount,确保分配合理。
  • 问题 3:线程返回顺序不固定

    不同线程完成时间不同,直接 push 数据会导致分片顺序混乱,无法与文件原始顺序对应。

    • 解决方法:在主线程中根据 startIndex 和 endIndex 计算偏移量,将子线程返回的分片数据赋值到 result 的正确位置。

      js
      for (let i = startIndex; i < endIndex; i++) {
        result[i] = e.data[i - startIndex];
      }
  • 问题 4:文件对象的传递

    worker.postMessage 传递 file 对象时,浏览器会自动序列化 File 对象,但需要确保子线程能正确操作文件分片。

    • 解决方法:File 对象支持 slice 方法,且可以通过 postMessage 传递,子线程中直接使用即可,无需额外处理。
html
// index.html

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>大文件切片上传</title>
    <style>
      .file-upload {
        margin: 20px;
      }
      .file-upload input[type="file"] {
        display: none;
      }
      .file-upload label {
        padding: 10px 20px;
        background-color: #f0f0f0;
        border: 1px solid #ccc;
        cursor: pointer;
        border-radius: 4px;
        margin-right: 10px;
      }
      .file-upload span {
        color: #666;
      }
    </style>
  </head>
  <body>
    <div class="file-upload">
      <label for="fileInput">选择文件</label>
      <input type="file" id="fileInput" />
      <span>未选择任何文件</span>
    </div>

    <script type="module" src="main.js"></script>
  </body>
</html>
js
// main.js

import { cutFile } from "./cutFile.js";

const inpFile = document.querySelector("input[type=file]");

inpFile.onchange = async (e) => {
  const file = e.target.files[0];
  console.time("cutFile");
  const chunks = await cutFile(file);
  console.timeEnd("cutFile");
  console.log(chunks);
};
js
// cutFile.js

import { createChunk } from "./createChunk.js";

const CHUNK_SIZE = 1024 * 1024 * 5; // 5MB

export async function cutFile(file) {
  //1. 分片的关键在于:一片占用多少尺寸(比如说5MB)
  //2. 分片函数createChunk是耗时的,所以是异步的

  const result = [];
  const chunkCount = Math.ceil(file.size / CHUNK_SIZE); // 计算文件会被分成多少片
  for (let i = 0; i < chunkCount; i++) {
    const chunk = await createChunk(file, 1, CHUNK_SIZE);
    result.push(chunk);
  }
  return result;
}
js
// createChunk.js

import SparkMD5 from "./sparkmd5.js";
export function createChunk(file, index, chunkSize) {
  return new Promise((resolve) => {
    const start = index * chunkSize;
    const end = start + chunkSize;
    const spark = SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();
    fileReader.onload = (e) => {
      spark.append(e.target.result);
      resolve({
        start,
        end,
        index,
        hash: spark.end(),
      });
    };
    fileReader.readAsArrayBuffer(file.slice(start, end));
  });
}
js
// sparkmd5.js (简化的 MD5 计算实现)

class SparkMD5 {
  constructor() {
    this.state = {
      buffer: [],
      hash: null,
    };
  }

  // 模拟 append 方法,将 ArrayBuffer 数据添加到缓冲区
  append(arrayBuffer) {
    this.state.buffer.push(arrayBuffer);
  }

  // 模拟 end 方法,生成一个简单的哈希值
  end() {
    if (this.state.hash) return this.state.hash;

    // 这里简化处理,实际 MD5 算法需要复杂的位运算
    // 为了演示,我们用一个简单的字符串表示哈希值
    const buffer = this.state.buffer;
    let hash = 0;
    for (let i = 0; i < buffer.length; i++) {
      const arr = new Uint8Array(buffer[i]);
      for (let j = 0; j < arr.length; j++) {
        hash = (hash * 31 + arr[j]) & 0xffffffff;
      }
    }
    this.state.hash = hash.toString(16).padStart(8, "0");
    return this.state.hash;
  }

  // ArrayBuffer 专用类
  static ArrayBuffer() {
    return new SparkMD5();
  }
}

export default SparkMD5;

到此其实分片的功能已经实现了:

但是耗时还是有些长,并且分片的运算在造成主线程的卡顿(虽然读取文件不在主线程,但是在读取之后对这些文件的二进制进行 MD5 编码的这个运算是在主线程的)

解决方法,引入 webWorker,分线程进行处理,但是要分几个线程呢?(线程不是越多越好,开很多线程会造成大量的资源浪费,在线程之间切换的和线程之间通信都需要耗费很多时间)

比如我们直接定义我们开四个线程

js
const THREAD_COUNT = 4;

所以我们不再循环分片的数量,而是根据线程数量来循环

js
// cutFile.js

import { createChunk } from "./createChunk.js";

const CHUNK_SIZE = 1024 * 1024 * 5; // 5MB
const THREAD_COUNT = 4;

export function cutFile(file) {
  return new Promise((resolve) => {
    //1. 分片的关键在于:一片占用多少尺寸(比如说5MB)
    //2. 分片函数createChunk是耗时的,所以是异步的

    const result = [];
    const chunkCount = Math.ceil(file.size / CHUNK_SIZE); // 计算文件会被分成多少片
    const workerChunkCount = Math.ceil(chunkCount / THREAD_COUNT); // 计算每个线程需要处理多少片
    let finishCount = 0; // 记录完成的线程数量
    for (let i = 0; i < THREAD_COUNT; i++) {
      // 创建一个新的 Worker 线程
      const worker = new Worker("./worker.js", {
        type: "module",
      });
      // 计算每个线程的开始索引和结束索引
      const startIndex = i * workerChunkCount;
      const endIndex = startIndex + workerChunkCount;
      // 防止最后一个线程超出 chunkCount
      if (endIndex > chunkCount) {
        endIndex = chunkCount;
      }
      worker.postMessage({
        file,
        CHUNK_SIZE,
        startIndex,
        endIndex,
      });
      worker.onmessage = (e) => {
        //   result.push(...e.data); 不能这样直接写,因为不能保证线程处理后返回的顺序是正确的
        for (let i = startIndex; i < endIndex; i++) {
          result[i] = e.data[i - startIndex];
        }
        worker.terminate(); // 终止 Worker 线程
        finishCount++;
        if (finishCount === THREAD_COUNT) {
          resolve(result);
          console.log("分片完成");
        }
      };
    }
  });
}
js
// worker.js

import { createChunk } from "./createChunk.js";

onmessage = async (e) => {
  const promise = [];
  const { file, CHUNK_SIZE, startIndex, endIndex } = e.data;
  for (let i = startIndex; i < endIndex; i++) {
    promise.push(createChunk(file, i, CHUNK_SIZE));
  }
  const chunks = await Promise.all(promise);
  postMessage(chunks);
};

发现需要的时间大大缩短