大文件分片上传
更新: 2025/3/13 18:56:30
一、整体功能目标
实现一个大文件分片上传的功能,将大文件切割成小块(分片),并为每个分片计算哈希值(用于校验或去重),最终返回分片信息列表。
二、实现步骤与思考过程
1. 初步实现分片功能(单线程版本)
步骤:
- HTML 前端部分:设计一个简单的文件上传界面,用户选择文件后触发 onchange 事件,获取文件对象。
- 分片逻辑(cutFile.js)
- 定义分片大小 CHUNK*SIZE 为 5MB(1024 * 1024 _ 5)。
- 计算分片数量 chunkCount = Math.ceil(file.size / CHUNK_SIZE)。
- 通过循环调用 createChunk 函数,依次生成每个分片的元信息(包括 start、end、index 和 hash)。
- createChunk 使用 FileReader 读取文件的指定片段(通过 file.slice),并用 SparkMD5 计算哈希值。
- 结果:返回一个包含所有分片信息的数组。
思考与问题:
- 问题 1:性能瓶颈 大文件分片时,createChunk 是异步操作,且单线程顺序执行。假设文件大小为 100MB,分片大小为 5MB,需要 20 个分片,循环中的 await 会导致总耗时是每个分片处理时间的累加。对于大文件(如几个 GB),耗时会显著增加,用户体验变差。
- 解决思路:初步实现功能是可行的,但性能优化是下一步需要考虑的重点。可以考虑并行处理来减少总耗时。
2. 优化版本:引入 Web Worker(多线程并行处理)
步骤:
- 引入多线程思想
- 将分片任务分配给多个线程(Web Worker),通过 THREAD_COUNT(设为 4)控制线程数量。
- 计算每个线程需要处理的分片数量 workerChunkCount = Math.ceil(chunkCount / THREAD_COUNT)。
- 为每个线程分配任务范围(startIndex 和 endIndex),并通过 worker.postMessage 将文件对象和分片参数传递给 worker.js。
- worker.js 的实现
- 接收主线程传递的数据(file、CHUNK_SIZE、startIndex、endIndex)。
- 在子线程中并行调用 createChunk,使用 Promise.all 等待所有分片处理完成。
- 将结果通过 postMessage 返回给主线程。
- 主线程的协调
- 使用 result 数组保存所有分片信息,注意不能直接 push(...e.data),因为线程返回顺序不确定,需要按索引赋值。
- 通过 finishCount 计数器判断所有线程是否完成,当 finishCount === THREAD_COUNT 时,resolve 返回结果。
- 每个线程完成后调用 worker.terminate() 释放资源。
思考与问题:
问题 2:线程分配不均
如果分片数量不能被线程数整除,最后一个线程可能处理的分片较少。例如,10 个分片分配给 4 个线程,前三个线程处理 3 个分片,最后一个线程只处理 1 个分片,导致资源利用不均衡。
- 解决方法:通过 Math.ceil(chunkCount / THREAD_COUNT) 计算每个线程的最大分片数,并动态调整 endIndex 不超过 chunkCount,确保分配合理。
问题 3:线程返回顺序不固定
不同线程完成时间不同,直接 push 数据会导致分片顺序混乱,无法与文件原始顺序对应。
解决方法:在主线程中根据 startIndex 和 endIndex 计算偏移量,将子线程返回的分片数据赋值到 result 的正确位置。
jsfor (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);
};
发现需要的时间大大缩短
