前端大文件上传全攻略(秒传、分片上传、断点续传、并发控制、进度展示)
在一次面试中,我被问到"有没有做过大文件上传"。虽然实际项目中没有遇到,但这是一个前端高频面试题,涉及文件处理、并发控制、性能优化等多个知识点。
为了更系统地理解和展示专业性,我专门实现并总结了大文件上传的常见功能:
- 秒传(快速上传)
- 分片上传(Chunk Upload)
- 断点续传
- 并发控制
- 上传进度 & UI 展示
1、秒传(快速上传)
为什么需要秒传?
如果服务端已经存在相同文件,再次上传会造成存储浪费和重复操作 。解决办法是:利用文件 Hash 值作为唯一标识,判断文件是否存在。
实现思路
- 前端计算文件 Hash;
- 将 Hash 传给服务端;
- 服务端比对:存在 → 秒传成功;不存在 → 进入分片上传。
Hash 计算方式
spark-md5
:轻量、适合小文件。hash-wasm
:基于WASM
,支持多种算法(MD5、SHA-1、SHA-256
等),对大文件更高效。
问题 :大文件Hash
计算耗时,会造成页面卡顿。
解决方案:
- 切片 + Web Worker :文件切片后在
Worker
中计算,避免阻塞主线程; - 抽样 Hash:只取部分内容计算,性能好,但准确性略差。
代码:
tsx
// 秒传逻辑
const handleSecondUpload = async (file: File) => {
const start = performance.now(); // ⏱ 开始计时
const fileHash = await calculateHash(file);
const end = performance.now(); // ⏱ 结束计时
console.log('duration', end - start); // 计算hash 耗时
if (!fileHash) return;
// 请求接口把计算后的文件Hash传递给后端
const checkRes = await fetch('/api/checkFile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash })
})
const { exist, uploadedIndexes } = await checkRes.json();
if (exist) {
console.log('文件已存在,秒传成功');
setProgress(100);
return;
}
}
tsx
// 生成文件 hash
const calculateHash = (file: File, chunkSize = 2 * 1024 * 1024) => {
return new Promise<string>((resolve, reject) => {
// 在 web worker 中计算Hash
const worker = new Worker(new URL('@/utils/hashWorker.ts', import.meta.url), {
type: 'module' // ✅ 指定为模块 Worker
});
worker.postMessage({ file, chunkSize: 2 * 1024 * 1024 }); // 2MB 一片
worker.onmessage = e => {
const { progress, hash } = e.data;
if (progress) {
console.log(`计算进度: ${progress.toFixed(2)}%`);
setProgress(progress.toFixed(2));
}
if (hash) {
console.log('计算完成,文件 hash:', hash);
resolve(hash);
worker.terminate();
}
};
});
};
ts
// hashWorker.ts --> 计算hash
import { createMD5 } from 'hash-wasm'; // 基于 WASM 实现
self.onmessage = async (e: any) => {
const { file, chunkSize } = e.data;
const chunk = chunkSize || 2 * 1024 * 1024; // 默认 2MB 分片
let offset = 0;
// 创建 hash-wasm 的 MD5 实例
const md5 = await createMD5();
const reader = new FileReader();
// 读取切片
const loadNext = () => {
const slice = file.slice(offset, offset + chunk);
reader.readAsArrayBuffer(slice);
};
reader.onload = (event: any) => {
md5.update(new Uint8Array(event.target.result)); // 更新 hash
offset += chunk;
// 发送进度
self.postMessage({ progress: Math.min(100, (offset / file.size) * 100) });
if (offset < file.size) {
loadNext(); // 读取下一片
} else {
// 计算完成,返回 hash
const hash = md5.digest(); // 默认输出 hex
self.postMessage({ hash });
}
};
// 开始读取第一片
loadNext();
};
2、分片上传(Chunk Upload)
为什么需要分片?
浏览器和网络对单次请求大小有限制,文件过大容易失败。
解决方法:将文件切成小块(例如 5MB/片),逐个上传。
代码:
tsx
// 进行文件切片
interface Chunk {
index: number;
data: Blob;
}
const CHUNK_SIZE = 5 * 1024 * 1024; // 每个分片 5MB
const handleChunkSlice = (file: File) => {
const chunks: Chunk[] = [];
let cur = 0;
let index = 0;
while (cur < file.size) {
chunks.push({ index, data: file.slice(cur, cur + CHUNK_SIZE) });
cur += CHUNK_SIZE;
index++;
}
return chunks;
};
这里切片之前其实还有一步,请求后端接口,获取已经上传的切片,假如后端返回了已经上传切片索引
tsx
// 后端返回的已经上传片段索引
const uploadedIndexes = [0, 1, 3, 4, 7, 9, 10, 12, 15, 23, 35];
3、断点续传
为什么需要断点续传?
上传中途失败时,如果每次都要从头开始,体验极差。
解决办法:记录已上传的分片索引,下次只传未完成部分。
代码:
tsx
let completed = 0; // 上传进度
let currentIndex = 0; // 当前上传到哪一片
const uploadedIndexes = [0, 1, 3, 4, 7, 9, 10, 12, 15, 23, 35]; // 已经上传过的切片
const uploadNext = async (chunks: Chunk[]) => {
if (currentIndex >= chunks.length) return;
const chunk = chunks[currentIndex];
currentIndex++;
// 断点续传:如果这个分片已上传,直接跳过
if (uploadedIndexes?.includes(chunk.index)) {
console.log(`跳过已上传分片 ${chunk.index}`);
completed++;
setProgress(Math.round((completed / chunks.length) * 100));
return uploadNext(); // 继续下一个
}
try {
await uploadChunk(chunk, fileHash); // 调用真实接口上传分片
completed++;
setProgress(Math.round((completed / chunks.length) * 100));
} catch (error) {
console.error(`分片 ${chunk.index} 上传失败`, error);
// 可在这里实现重试逻辑,
}
// 递归上传下一个
await uploadNext();
};
tsx
/**
* @description 上传切片
* @param {string} fileHash 上传文件hash,使用文件名的话,文件名可能修改
*/
const uploadChunk = (chunk: Chunk[], fileHash: sting) => {
if (!fileHash) return;
const formData = new FormData();
formData.append('file', chunk); // 分片内容
formData.append('index', String(chunk.index)); // 分片索引
formData.append('fileHash', fileHash); // 原文件名
const res = await fetch('/uploadChunk', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error(`分片 ${chunk.index} 上传失败`);
console.log(`分片 ${chunk.index} 上传成功`);
};
4、并发控制
为什么需要并发控制?
一次性发出过多请求会导致:
- 浏览器报错(并发过高);
- 服务端压力过大。
解决方案 :限制并发数量,例如同时只上传 3 个分片。根据循环会生成 3 条promise链
,然后uploadNext
内通过递归不断发起请求。每条链的请求只有等上一次请求结束后,才能继续下一次请求。所以总的并发量就是 3 个。
代码:
tsx
const maxConcurrency = 3; // 并发数量,可调整
// 初始化并发链
for (let i = 0; i < Math.min(maxConcurrency, chunks.length); i++) {
promises.push(uploadNext());
}
await Promise.all(promises);
5、上传进度 & UI 展示
上传进度计算公式:
matlab
进度 = (已完成分片数 ÷ 总分片数) × 100%
需要考虑的情况:
- 秒传直接完成;
- 断点续传跳过分片;
- 并发上传同时完成多个分片。
前端可以使用进度条(Progress Bar)或百分比文字来实时展示。