摘要 :大文件上传的核心思路是「切片 + 断点续传 + 秒传」。前端将大文件按固定大小(如 5MB)切分成多个 chunk,逐个上传;用文件的 hash 值作为唯一标识,后端据此判断哪些分片已存在(秒传)或需要续传;切片进度存入 IndexedDB,支持刷新页面后恢复。一句话概括------把大象装冰箱分三步:切、传、合,中间断了能接着来。

一、为什么需要大文件上传方案
传统上传的三大痛点
直接用 <input type="file"> + FormData 上传大文件(1GB+),会遇到:
| 痛点 | 场景 | 后果 |
|---|---|---|
| 网络中断 | WiFi 切换 / 信号波动 / 走隧道 | 之前传的全白费,得从头再来 |
| 浏览器崩溃 / 关机 | 上传几 GB 占满内存 | 进度丢失,用户想砸电脑 |
| 服务端限制 | Nginx 默认 client_max_body_size=1MB |
直接返回 413 Request Entity Too Large |
| 无进度反馈 | 用户盯着转圈圈 | 体验极差,不知道是卡了还是还在传 |
| 并发瓶颈 | 多人同时传大文件 | 带宽被打满,所有人都在慢 |
解决思路:化整为零
css
传统方式: [======== 1GB 文件 ========] → 一次性 POST → 失败 = 全部重来 ❌
切片方式: [chunk1][chunk2][chunk3]...[chunkN] → 逐个 POST → 失败只重传那一片 ✅
↑ 每个 5MB,独立上传,独立记录进度
二、核心原理:切片上传
2.1 文件切片
使用 File 对象继承自 Blob 的 .slice() 方法,将大文件切成固定大小的分片:
javascript
const CHUNK_SIZE = 5 * 1024 * 1024; // 每块 5MB
/**
* 将文件切分成 chunk 数组
* @param {File} file - 原始文件
* @param {number} size - 每块大小(字节)
* @returns {Array<{index: number, chunk: Blob, hash: string}>}
*/
function createChunks(file, size = CHUNK_SIZE) {
const chunks = [];
const totalChunks = Math.ceil(file.size / size);
for (let i = 0; i < totalChunks; i++) {
const start = i * size;
const end = Math.min(start + size, file.size);
const chunk = file.slice(start, end);
chunks.push({
index: i, // 分片序号,从 0 开始
chunk: chunk, // Blob 数据
start: start,
end: end,
});
}
return chunks;
}
// 使用示例
// const fileInput = document.getElementById('fileInput').files[0];
// const chunks = createChunks(fileInput); // 1GB 文件 → 200 个 chunk
2.2 文件 Hash 计算(唯一标识)
Hash 是整个方案的「身份证」------同一个文件无论切多少次,hash 都一样;不同文件 hash 不同。常用 SparkMD5 库计算:
javascript
import SparkMD5 from 'spark-md5';
/**
* 计算文件 hash(增量读取,避免内存爆炸)
* @param {File} file
* @returns {Promise<string>} hex 格式的 md5
*/
async function calculateFileHash(file) {
return new Promise((resolve, reject) => {
const chunkSize = 2 * 1024 * 1024; // 每次 2MB 读入内存
const chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
reader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end()); // 最终 hash
}
};
reader.onerror = reject;
function loadNext() {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
reader.readAsArrayBuffer(file.slice(start, end));
}
loadNext();
});
}
为什么不用整个文件一次性读? 一个 1GB 的文件如果
readAsArrayBuffer(file)会占用 ~1GB 内存,浏览器直接卡死。增量读取每次只拿 2MB 到内存中,算完就释放。
2.3 逐片上传
javascript
/**
* 上传单个分片
*/
async function uploadChunk(fileHash, chunkData, onProgress) {
const formData = new FormData();
formData.append('fileHash', fileHash); // 文件唯一标识
formData.append('chunkHash', `${fileHash}-${chunkData.index}`); // 分片标识
formData.append('chunkIndex', chunkData.index);
formData.append('totalChunks', chunkData.total);
formData.append('file', chunkData.chunk); // 分片 Blob 数据
formData.append('fileName', chunkData.fileName);
const response = await fetch('/api/upload/chunk', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
const result = await response.json();
// 回报进度
onProgress?.(chunkData.index + 1, chunkData.total);
return result;
}
/**
* 并发控制上传所有分片
* @param {string} fileHash
* @param {Array} chunks - createChunks 返回的数组
* @param {number} concurrency - 并发数,默认 3
* @param {Function} onProgress - 进度回调 (uploaded, total) => void
*/
async function uploadWithPool(fileHash, chunks, concurrency = 3, onProgress) {
let uploadedCount = 0;
const total = chunks.length;
// 用一个简单的并发池
const pool = new Set();
let index = 0;
return new Promise((resolve, reject) => {
function next() {
while (pool.size < concurrency && index < total) {
const currentIndex = index++;
const chunkData = {
...chunks[currentIndex],
total: total,
fileName: chunks[currentIndex].fileName,
};
const task = uploadChunk(fileHash, chunkData, () => {
uploadedCount++;
onProgress?.(uploadedCount, total);
})
.then(() => pool.delete(task))
.catch(reject);
pool.add(task);
}
if (pool.size === 0 && index >= total) {
resolve(); // 全部完成
}
// 任一任务完成后,尝试填充下一个
if (pool.size > 0) {
Promise.race(pool).then(next, reject);
}
}
next();
});
}
三、断点续传
3.1 原理
断点续传的核心问题:重新打开页面后,怎么知道之前传到哪了?
答案:问服务器。
css
客户端 服务端
│ │
├── 1. 计算文件 hash ──────────────→ │
│ │
├── 2. POST /api/upload/check ───→ │
│ { fileHash: "abc123" } │
│ │
├── 3. ← 返回已上传的分片列表 ─────│
│ { uploaded: [0,1,2,4,5] } │
│ (说明第 3 号分片丢了/没传完) │
│ │
├── 4. 只传缺失的分片 [3,6,7...] ─→ │
│ │
├── 5. 全部到齐 → 通知合并 ───────→ │
│ │
├── 6. ← 返回文件 URL ─────────────│
3.2 完整实现
javascript
class ResumableUploader {
constructor(options = {}) {
this.chunkSize = options.chunkSize || 5 * 1024 * 1024;
this.concurrency = options.concurrency || 3;
this.uploadUrl = options.uploadUrl || '/api/upload/chunk';
this.checkUrl = options.checkUrl || '/api/upload/check';
this.mergeUrl = options.mergeUrl || '/api/upload/merge';
this.onProgress = options.onProgress || (() => {});
}
/**
* 主入口:开始上传
*/
async upload(file) {
this.file = file;
this.fileName = file.name;
this.fileSize = file.size;
// Step 1: 计算文件 hash
this.fileHash = await calculateFileHash(file);
// Step 2: 检查已上传的分片(秒传判断)
const { uploaded, exists } = await this.checkUploadedChunks();
// 秒传:文件已完整存在
if (exists) {
this.onProgress(100, 100, '秒传成功');
return { url: exists, message: '文件已存在(秒传)' };
}
// Step 3: 创建分片列表,排除已上传的
const allChunks = createChunks(file, this.chunkSize).map(c => ({
...c,
fileName: this.fileName,
}));
const pendingChunks = allChunks.filter(c => !uploaded.includes(c.index));
console.log(`共 ${allChunks.length} 片,已传 ${uploaded.length} 片,待传 ${pendingChunks.length} 片`);
if (pendingChunks.length === 0) {
// 所有分片都已上传,只需通知合并
return this.notifyMerge(allChunks.length);
}
// Step 4: 上传缺失分片
await uploadWithPool(this.fileHash, pendingChunks, this.concurrency,
(done, total) => {
const actualDone = done + uploaded.length;
this.onProgress(actualDone, allChunks.length, '上传中...');
}
);
// Step 5: 通知后端合并
return this.notifyMerge(allChunks.length);
}
/**
* 检查已上传的分片
*/
async checkUploadedChunks() {
const res = await fetch(this.checkUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileHash: this.fileHash,
fileName: this.fileName,
totalChunks: Math.ceil(this.fileSize / this.chunkSize),
}),
});
if (!res.ok) throw new Error('检查上传状态失败');
return res.json();
// 返回格式: { uploaded: [0,1,2], exists: null } 或 { uploaded: [], exists: "https://..." }
}
/**
* 通知后端合并分片
*/
async notifyMerge(totalChunks) {
this.onProgress(totalChunks, totalChunks, '正在合并...');
const res = await fetch(this.mergeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileHash: this.fileHash,
fileName: this.fileName,
totalChunks: totalChunks,
fileSize: this.fileSize,
}),
});
if (!res.ok) throw new Error('合并失败');
return res.json();
// 返回格式: { url: "https://cdn.example.com/files/xxx" }
}
}
// 使用示例
// const uploader = new ResumableUploader({
// onProgress: (done, total, status) => {
// console.log(`进度: ${done}/${total} (${Math.round(done/total*100)}%) ${status}`);
// }
// });
//
// uploader.upload(fileInput.files[0])
// .then(result => console.log('上传完成:', result.url))
// .catch(err => console.error('上传失败:', err));
四、进阶优化
4.1 Web Worker 加速切片 & Hash 计算
切片和 hash 计算是 CPU 密集型操作,放主线程会阻塞 UI。用 Web Worker 把活儿甩到后台:
javascript
// worker/hash-worker.js ------ 在 Web Worker 中计算 hash
self.importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js');
self.onmessage = async function(e) {
const { file, chunkSize } = e.data;
const spark = new SparkMD5.ArrayBuffer();
const chunks = Math.ceil(file.size / chunkSize);
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const blob = file.slice(start, end);
const buffer = await blob.arrayBuffer();
spark.append(buffer);
// 向主线程报告 hash 计算进度
self.postMessage({ type: 'progress', current: i + 1, total: chunks });
}
self.postMessage({ type: 'done', hash: spark.end() });
};
// 主线程调用
function calculateHashInWorker(file) {
return new Promise((resolve) => {
const worker = new Worker('./worker/hash-worker.js');
worker.onmessage = (e) => {
if (e.data.type === 'done') {
resolve(e.data.hash);
worker.terminate();
}
};
worker.postMessage({ file, chunkSize: 2 * 1024 * 1024 });
});
}
4.2 IndexedDB 持久化上传记录
页面刷新或关闭后再打开,从 IndexedDB 恢复上传状态:
javascript
// db.js ------ IndexedDB 操作封装
const DB_NAME = 'FileUploadDB';
const DB_VERSION = 1;
const STORE_NAME = 'uploadRecords';
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'fileHash' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** 保存上传记录 */
async function saveUploadRecord(record) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
/** 获取上传记录 */
async function getUploadRecord(fileHash) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).get(fileHash);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
}
/** 删除已完成的上传记录 */
async function removeUploadRecord(fileHash) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORENAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(fileHash);
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
// 集成到 Uploader 中:
// 上传前 → saveUploadRecord({ fileHash, fileName, fileSize, uploadedChunks: [], status: 'uploading' })
// 每传完一片 → 更新 uploadedChunks
// 页面加载时 → 扫描未完成的记录,提示用户"是否继续上传?"
4.3 WebSocket 实时进度推送
HTTP 轮询太浪费,用 WebSocket 让服务端主动推进度给前端:
javascript
class UploadWithWebSocket {
constructor(uploadUrl, wsUrl) {
this.uploadUrl = uploadUrl;
this.wsUrl = wsUrl;
this.ws = null;
}
connect(fileHash) {
this.ws = new WebSocket(`${this.wsUrl}?fileHash=${fileHash}`);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'progress':
console.log(`服务端确认收到分片 ${data.chunkIndex}`);
break;
case 'merge_complete':
console.log('合并完成,文件 URL:', data.url);
break;
case 'error':
console.error('服务端错误:', data.message);
break;
}
};
}
disconnect() {
this.ws?.close();
}
}
4.4 错误处理与重试策略
javascript
/**
* 带指数退避的重试上传
*/
async function uploadChunkWithRetry(uploadFn, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await uploadFn();
} catch (err) {
lastError = err;
if (attempt < maxRetries) {
// 指数退避:1s → 2s → 4s
const delay = Math.pow(2, attempt) * 1000;
console.warn(`分片上传失败,${delay}ms 后重试 (${attempt + 1}/${maxRetries})`);
await new Promise(r => setTimeout(r, delay));
}
}
}
throw lastError;
}
五、完整组件设计
5.1 React 组件示例
jsx
import React, { useState, useRef, useCallback } from 'react';
import { ResumableUploader } from './uploader';
function LargeFileUploader({
accept = '*', // 接受的文件类型
maxSize = Infinity, // 最大文件大小(字节)
chunkSize = 5 * 1024 * 1024,
concurrency = 3,
onUploadSuccess, // (url) => void
onUploadError, // (error) => void
}) {
const [file, setFile] = useState(null);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('idle'); // idle | hashing | uploading | merging | success | error
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef(null);
const abortRef = useRef(null);
const handleFile = useCallback((selectedFile) => {
if (selectedFile.size > maxSize) {
alert(`文件过大,最大支持 ${formatSize(maxSize)}`);
return;
}
setFile(selectedFile);
setProgress(0);
setStatus('idle');
}, [maxSize]);
const handleUpload = useCallback(async () => {
if (!file) return;
const uploader = new ResumableUploader({
chunkSize,
concurrency,
onProgress: (done, total, msg) => {
setProgress(Math.round((done / total) * 100));
setStatus(msg.includes('hash') ? 'hashing'
: msg.includes('合并') ? 'merging'
: 'uploading');
},
});
// 支持取消
abortRef.current = () => {
// 实际项目中可用 AbortController 取消 fetch
setStatus('idle');
setProgress(0);
};
try {
setStatus('hashing');
const result = await uploader.upload(file);
setStatus('success');
onUploadSuccess?.(result.url);
} catch (err) {
setStatus('error');
onUploadError?.(err);
}
}, [file, chunkSize, concurrency, onUploadSuccess, onUploadError]);
// 拖拽处理
const handleDragOver = (e) => { e.preventDefault(); setDragOver(true); };
const handleDragLeave = (e) => { e.preventDefault(); setDragOver(false); };
const handleDrop = (e) => {
e.preventDefault();
setDragOver(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) handleFile(droppedFile);
};
return (
<div
className={`uploader ${dragOver ? 'drag-over' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={(e) => handleFile(e.target.files[0])}
style={{ display: 'none' }}
multiple={false}
/>
{!file ? (
<div className="uploader-prompt" onClick={() => inputRef.current?.click()}>
<p>点击或拖拽文件到此区域</p>
<p className="hint">支持大文件上传,断网可续传</p>
</div>
) : (
<div className="uploader-active">
<div className="file-info">
<span className="file-name">{file.name}</span>
<span className="file-size">{formatSize(file.size)}</span>
</div>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progress}%` }} />
<span className="progress-text">{progress}%</span>
</div>
<div className="status-text">{statusTextMap[status]}</div>
<div className="actions">
{status !== 'uploading' && status !== 'success' && (
<button onClick={handleUpload}>开始上传</button>
)}
{(status === 'uploading' || status === 'hashing') && (
<button onClick={abortRef.current}>取消</button>
)}
<button onClick={() => { setFile(null); setStatus('idle'); }}>
重新选择
</button>
</div>
</div>
)}
</div>
);
}
const statusTextMap = {
idle: '准备就绪',
hashing: '正在计算文件指纹...',
uploading: '正在上传...',
merging: '正在合并文件...',
success: '上传完成!',
error: '上传失败,请重试',
};
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(2) + ' GB';
}
5.2 组件 Props / 事件 / 状态一览
| 类别 | 名称 | 类型 | 说明 |
|---|---|---|---|
| Props | accept |
string |
文件类型过滤,如 '.pdf,.docx' |
maxSize |
number |
最大文件字节数 | |
chunkSize |
number |
分片大小,默认 5MB | |
concurrency |
number |
并发上传数,默认 3 | |
| Events | onUploadSuccess |
(url: string) => void |
上传成功回调,返回文件 URL |
onUploadError |
(error: Error) => void |
上传失败回调 | |
| State | status |
enum |
idle → hashing → uploading → merging → success/error |
progress |
number |
0~100 百分比 |
六、前后端协议设计
6.1 统一接口规范
| 接口 | 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|---|
/api/upload/check |
POST | { fileHash, fileName, totalChunks } |
{ uploaded: number[], exists?: string } |
查询已上传分片;若文件完整则返回 url(秒传) |
/api/upload/chunk |
POST | FormData: fileHash, chunkIndex, chunkHash, file, totalChunks, fileName |
{ success: true, index: number } |
上传单个分片 |
/api/upload/merge |
POST | { fileHash, fileName, totalChunks, fileSize } |
{ url: string } |
通知后端合并所有分片 |
6.2 分片命名规则
makefile
文件 hash: a1b2c3d4e5f6...
分片命名: {fileHash}-{index}
示例:
a1b2c3d4-0 ← 第 1 片
a1b2c3d4-1 ← 第 2 片
a1b2c3d4-2 ← 第 3 片
...
a1b2c3d4-199 ← 第 200 片(假设 1GB 文件,每片 5MB)
后端存储时可以用这个名作为临时文件名,合并时按 index 顺序拼接。
七、方案对比
| 维度 | 普通 FormData 上传 | 切片上传(基础版) | 切片+断点续传(完整版) |
|---|---|---|---|
| 文件大小限制 | 受浏览器/服务端限制(通常 < 100MB) | 理论无上限 | 理论无上限 |
| 断网恢复 | ❌ 从头再来 | ❌ 从头再来 | ✅ 只传剩余分片 |
| 进度显示 | ❌ 只有整体完成/失败 | ✅ 分片级进度 | ✅ 分片级 + 可恢复 |
| 秒传 | ❌ | ❌ | ✅ hash 匹配直接返回 URL |
| 实现复杂度 | 低(3 行代码) | 中(~200 行) | 高(~600 行+) |
| 适用场景 | 头像、小附件 | 内网稳定环境 | 公网、大文件、弱网 |
八、面试高频问题
Q1:为什么切片大小一般选 5MB?
- 太小(如 10KB):请求数爆炸,HTTP 头部开销占比高,反而更慢
- 太大(如 100MB):单次传输时间长,一旦失败重传代价大
- 5MB 是经验平衡点:单次传输约 2~5 秒(普通宽带),失败重传代价可控,总请求数也合理(1GB ≈ 200 个请求)
Q2:hash 计算太慢怎么办?
- Web Worker:不阻塞 UI(如上 4.1 所示)
- 增量计算:不要一次读整个文件,分块读入(如上 2.2 所示)
- 抽样 hash:只取文件头部 + 尾部 + 中间各 2MB 来算(牺牲精度换速度,适合非严格场景)
- Web Crypto API :浏览器原生
crypto.subtle.digest('SHA-256', buffer)比 SparkMD5 更快且不需要引入库
Q3:如何保证分片的顺序性?
- 每个分片带
chunkIndex序号 - 后端收到后存为
{fileHash}-{index}临时文件 - 合并时按 index 从小到大拼接:
cat 0 1 2 3 ... > final_file - 或者用 preallocated file + seek write,每个分片写到对应 offset 位置
Q4:如何处理用户重复选择同一个文件?
- 用
file.name + file.size + file.lastModified做 quick check - 如果一致且 hash 已算过,跳过 hash 步骤直接走 check 接口
- 这就是「同一文件秒传」的另一种形式
Q5:如果后端合并时部分分片丢失怎么办?
- merge 接口在合并前先检查所有分片是否存在
- 缺失则返回 409 + 缺失的 index 列表
- 前端补传缺失分片后再次请求 merge
九、记忆口诀
bash
大文件上传三步走:切、传、合,断了能续不用愁。
切片五兆刚刚好,hash 当作身份证。
先问后端传了啥,缺谁补谁不白忙。
Worker 算 hash 不卡页,IndexedDB 存进度。
WebSocket 推消息,指数退避防重试。
一句话总结:
切片 + hash 标识 + 断点查询 + 补传缺失 + 后端合并 = 完整的大文件上传方案