以下是针对大文件上传的完整解决方案及实践代码,涵盖切片上传、错误处理、断点续传、并行控制、秒传等核心问题:
一、技术方案设计
- 文件切片:按固定大小(如 5MB)或动态数量切分文件。
- 唯一标识:计算文件 Hash(Web Worker 加速)作为唯一标识。
- 断点续传:记录已上传切片,刷新后恢复进度。
- 并行上传:控制并发请求数量(如 3-5 个并行)。
- 错误重试:单个切片失败后自动重试(最多 3 次)。
- 秒传逻辑:上传前检查服务器是否已存在完整文件。
二、代码实现
1. 前端核心代码(React + TypeScript)
import React, { useRef, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { limit } from 'p-limit';
// 切片大小(如 5MB)
const CHUNK_SIZE = 5 * 1024 * 1024;
interface Chunk {
index: number;
hash: string;
chunk: Blob;
}
const BigFileUploader = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [fileHash, setFileHash] = useState('');
// 生成文件 Hash(Web Worker)
const calculateFileHash = async (file: File): Promise<string> => {
return new Promise((resolve) => {
const worker = new Worker('/hash-worker.js');
worker.postMessage({ file });
worker.onmessage = (e) => {
resolve(e.data.hash);
};
});
};
// 文件切片
const createFileChunks = (file: File): Blob[] => {
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push(file.slice(cur, cur + CHUNK_SIZE));
cur += CHUNK_SIZE;
}
return chunks;
};
// 上传切片(带重试)
const uploadChunkWithRetry = async (chunk: Chunk, retries = 3) => {
try {
const formData = new FormData();
formData.append('chunk', chunk.chunk);
formData.append('hash', chunk.hash);
formData.append('filename', fileInputRef.current!.files![0].name);
formData.append('fileHash', fileHash);
await fetch('/api/upload', { method: 'POST', body: formData });
// 记录成功上传的切片
const uploaded = JSON.parse(localStorage.getItem(fileHash) || '[]');
localStorage.setItem(fileHash, JSON.stringify([...uploaded, chunk.hash]));
} catch (error) {
if (retries > 0) {
await uploadChunkWithRetry(chunk, retries - 1);
} else {
throw error;
}
}
};
// 开始上传
const handleUpload = async () => {
const file = fileInputRef.current?.files?.[0];
if (!file) return;
setUploading(true);
// 1. 计算文件 Hash(秒传检查)
const hash = await calculateFileHash(file);
setFileHash(hash);
// 秒传验证
const existRes = await fetch(`/api/check?hash=${hash}`);
const { exist } = await existRes.json();
if (exist) {
setProgress(100);
setUploading(false);
return;
}
// 2. 切片并过滤已上传的
const chunks = createFileChunks(file);
const uploaded = JSON.parse(localStorage.getItem(hash) || '[]');
const filteredChunks = chunks
.map((chunk, index) => ({
index,
hash: `${hash}-${index}`,
chunk,
}))
.filter((chunk) => !uploaded.includes(chunk.hash));
// 3. 并行上传(控制并发)
const concurrencyLimit = limit(3);
const uploadPromises = filteredChunks.map((chunk) =>
concurrencyLimit(() => uploadChunkWithRetry(chunk))
);
try {
await Promise.all(uploadPromises);
// 4. 通知合并文件
await fetch('/api/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, hash }),
});
setProgress(100);
} catch (error) {
alert('上传失败');
} finally {
setUploading(false);
}
};
return (
<div>
<input type="file" ref={fileInputRef} />
<button onClick={handleUpload} disabled={uploading}>
{uploading ? `上传中... ${progress}%` : '开始上传'}
</button>
</div>
);
};
export default BigFileUploader;
2. Web Worker 代码(hash-worker.js)
self.importScripts('https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js');
self.onmessage = async (e) => {
const { file } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
const reader = new FileReader();
// 计算文件 Hash
reader.readAsArrayBuffer(file);
reader.onload = (e) => {
spark.append(e.target.result);
const hash = spark.end();
self.postMessage({ hash });
};
};
3. 服务端伪代码(Node.js + Express)
import express from 'express';
import fs from 'fs';
import path from 'path';
const app = express();
app.use(express.json());
// 检查文件是否存在(秒传)
app.get('/api/check', (req, res) => {
const { hash } = req.query;
const filePath = path.join('uploads', `${hash}.complete`);
res.json({ exist: fs.existsSync(filePath) });
});
// 上传切片
app.post('/api/upload', async (req, res) => {
const { hash, filename, fileHash } = req.body;
const chunk = req.files.chunk;
// 保存切片
const chunkDir = path.join('uploads', fileHash);
if (!fs.existsSync(chunkDir)) fs.mkdirSync(chunkDir);
await chunk.mv(path.join(chunkDir, hash));
res.status(200).end();
});
// 合并文件
app.post('/api/merge', async (req, res) => {
const { filename, hash } = req.body;
const chunkDir = path.join('uploads', hash);
const chunks = fs.readdirSync(chunkDir);
// 按索引排序后合并
chunks.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
await Promise.all(
chunks.map((chunkPath) =>
fs.promises.appendFile(
path.join('uploads', `${hash}.complete`),
fs.readFileSync(path.join(chunkDir, chunkPath))
)
);
// 清理临时文件
fs.rmdirSync(chunkDir, { recursive: true });
res.status(200).end();
});
三、关键问题解决方案
1. 切片策略选择
- 按大小切(推荐):固定每个切片为 5MB,适合大多数场景。
- 按数量切:当文件大小波动极大时使用(如 100 个切片)。
2. 错误重试机制
-
每个切片独立重试计数器(最多 3 次)。
-
失败后延迟 1 秒重试:
const uploadChunkWithRetry = async (chunk: Chunk, retries = 3) => { try { // ...上传逻辑 } catch (error) { if (retries > 0) { await new Promise(resolve => setTimeout(resolve, 1000)); await uploadChunkWithRetry(chunk, retries - 1); } } };
3. 断点续传实现
- 使用
localStorage记录已上传的切片 Hash。 - 刷新后通过 Hash 过滤已上传的切片。
4. Web Worker 优化
- 将耗时的 Hash 计算移至 Worker 线程。
- 使用 SparkMD5 加速大文件 Hash 计算。
5. 秒传逻辑
- 上传前计算文件 Hash 并查询服务端。
- 若文件已存在,直接跳过上传。
四、性能优化建议
- 动态切片大小:根据网络状况调整切片大小(如弱网时减小切片)。
- 进度计算:基于已上传切片数量计算总进度。
- 暂停/恢复:记录当前上传状态,允许手动暂停。
- 内存管理:使用
FileReader.readAsArrayBuffer分块读取文件。
通过此方案,可实现高效可靠的大文件上传,覆盖生产环境中的常见问题。实际部署时需根据业务需求调整参数(如切片大小、并发数)。