大文件上传:分片、断点续传和秒传的实现方案
处理大文件上传是个常见需求。直接上传大文件会遇到很多问题:网络不稳定、内存占用高、上传失败后又要从头开始。我们需要一套完整的解决方案。
核心思路
我们的做法是把大文件切成很多小片,分别上传这些小片,最后在服务器上把它们拼起来。这样做有几个好处:
分片上传:大文件变多个小文件,上传更稳定
断点续传:上传中断后,可以从断点继续
秒传:如果服务器已经有相同文件,可以瞬间完成
文件选择和队列管理
当用户选择一批文件时,不能同时上传所有文件。那样会拖慢浏览器,导致页面卡顿。
我们需要一个上传队列来控制同时上传的文件数量。
状态初始化和加入队列
// hooks/useFileUpload.ts
const addFiles = useCallback(async (fileList: FileList) => {
// 把每个文件包装成对象
const newFiles: FileItem[] = Array.from(fileList).map((file) => ({
id: crypto.randomUUID(), // 生成唯一ID
file, // 原始文件对象
status: "pending", // 初始状态:等待中
progress: 0, // 进度从0开始
name: file.name,
size: file.size,
// 其他属性...
}));
// 更新界面显示的文件列表
setFiles((prev) => [...prev, ...newFiles]);
// 加入上传队列
queueRef.current.push(...newFiles);
// 开始处理队列
processQueue();
}, []);文件级并发控制
我们用一个调度器来控制同时上传的文件数量:
// hooks/useFileUpload.ts
// 设置最大并发数
const MAX_CONCURRENT_FILES = 3; // 同时最多上传3个文件
const processQueue = useCallback(async () => {
// 循环条件:队列有文件 且 当前上传数没到上限
while (
queueRef.current.length > 0 &&
uploadingCountRef.current < MAX_CONCURRENT_FILES
) {
const fileItem = queueRef.current.shift(); // 从队列取一个文件
if (fileItem) {
uploadingCountRef.current++; // 增加正在上传的计数
// 开始上传这个文件
uploadFile(fileItem).finally(() => {
// 上传完成(无论成功失败)
uploadingCountRef.current--; // 减少计数
processQueue(); // 继续处理队列中的下一个
});
}
}
}, [uploadFile]);这样无论用户选择多少文件,最多同时只上传3个,避免卡顿。
文件预处理:计算MD5
为了实现秒传和断点续传,我们需要给文件一个唯一标识。用文件名不行,因为用户可以改名字。我们采用MD5作为文件的“指纹”。
为什么不用整个文件计算MD5?
对于大文件(比如几个GB),一次性读入内存计算MD5会:
占用大量内存
卡住浏览器界面
可能导致浏览器崩溃
增量计算MD5
更好的做法是分块读取计算:
// libs/md5.ts
import SparkMD5 from 'spark-md5';
async function calculateMD5(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
// 每次读取2MB
const chunkSize = 2 * 1024 * 1024;
let currentChunk = 0;
const totalChunks = Math.ceil(file.size / chunkSize);
fileReader.onload = (event) => {
if (event.target?.result) {
// 把这一块数据加入MD5计算
spark.append(event.target.result as ArrayBuffer);
currentChunk++;
// 如果还有下一块,继续读取
if (currentChunk < totalChunks) {
loadNextChunk();
} else {
// 所有块都读完,得到最终MD5
resolve(spark.end());
}
}
};
fileReader.onerror = () => {
reject(new Error('文件读取失败'));
};
function loadNextChunk() {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
fileReader.readAsArrayBuffer(chunk);
}
// 开始读取第一块
loadNextChunk();
});
}使用Web Worker优化
MD5计算很耗时,如果在主线程计算,用户界面还是会卡。可以用Web Worker在后台线程计算:
// worker/md5.worker.ts
self.onmessage = async (event) => {
const { file } = event.data;
// 在worker里计算MD5
const md5 = await calculateMD5(file);
// 把结果发回主线程
self.postMessage({ md5 });
};
// 在主线程中使用
const worker = new Worker('md5.worker.ts');
worker.postMessage({ file });
worker.onmessage = (event) => {
const { md5 } = event.data;
// 拿到MD5,继续下一步
};预检查:秒传和断点续传
拿到文件的MD5后,先问服务器:“这个文件你见过吗?”
发送检查请求
// hooks/useFileUpload.ts
async function checkFile(md5: string, filename: string) {
const response = await fetch('/api/upload/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ md5, filename })
});
return response.json();
}服务器端检查逻辑
// app/api/upload/check/route.ts
export async function POST(request: Request) {
const { md5, filename } = await request.json();
// 1. 检查是否已有完整文件(秒传)
const existingFile = await checkFileInIndex(md5);
if (existingFile) {
return Response.json({
exists: true, // 文件已存在
path: existingFile.path,
uploadedChunks: [] // 没有需要上传的分片
});
}
// 2. 检查是否有部分上传的分片(断点续传)
const tempDir = `./temp/${md5}`;
let uploadedChunks: number[] = [];
if (await fs.exists(tempDir)) {
// 读取临时目录,找出已上传的分片
const files = await fs.readdir(tempDir);
uploadedChunks = files
.filter(name => name.endsWith('.chunk'))
.map(name => parseInt(name.split('.')[0]))
.sort((a, b) => a - b);
}
return Response.json({
exists: false,
uploadedChunks // 返回已上传的分片编号
});
}分片上传
如果文件不能秒传,就开始分片上传。
文件分片
// hooks/useFileUpload.ts
function createChunks(file: File, chunkSize: number = 5 * 1024 * 1024) {
const chunks: Blob[] = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
chunks.push(file.slice(start, end));
start = end;
}
return chunks;
}分片级并发控制
对于单个大文件,它的分片也不能同时上传,需要控制并发:
// hooks/useFileUpload.ts
async function uploadChunksWithConcurrency(
file: File,
md5: string,
chunksToUpload: number[],
uploadedChunks: number[] = []
) {
const concurrency = 3; // 同时上传3个分片
let index = 0; // 当前处理的索引
// 上传单个分片
const uploadChunk = async (chunkIndex: number) => {
const chunkSize = 5 * 1024 * 1024; // 5MB
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('md5', md5);
formData.append('chunkIndex', chunkIndex.toString());
formData.append('totalChunks', Math.ceil(file.size / chunkSize).toString());
await fetch('/api/upload/chunk', {
method: 'POST',
body: formData
});
// 更新进度
updateProgress(md5, chunkIndex, uploadedChunks.length);
};
// 并发上传
const workers = Array.from({ length: concurrency }, async () => {
while (index < chunksToUpload.length) {
const chunkIndex = chunksToUpload[index];
index++;
await uploadChunk(chunkIndex);
}
});
await Promise.all(workers);
}分片合并
所有分片上传完成后,通知服务器合并文件。
客户端发送合并请求
// hooks/useFileUpload.ts
async function mergeFile(md5: string, filename: string, totalChunks: number) {
const response = await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
md5,
filename,
totalChunks
})
});
return response.json();
}服务器端流式合并
用Node.js的流(Stream)来合并,避免内存溢出:
// app/api/upload/merge/route.ts
import fs from 'fs/promises';
import { createReadStream, createWriteStream } from 'fs';
import path from 'path';
export async function POST(request: Request) {
const { md5, filename, totalChunks } = await request.json();
const tempDir = `./temp/${md5}`;
const finalPath = `./uploads/${md5}_${filename}`;
// 创建写入流
const writeStream = createWriteStream(finalPath);
try {
// 按顺序合并所有分片
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(tempDir, `${i}.chunk`);
await new Promise((resolve, reject) => {
const readStream = createReadStream(chunkPath);
// 把读流连接到写流,{ end: false } 让写流保持打开
readStream.pipe(writeStream, { end: false });
readStream.on('end', () => {
// 这个分片读完,删除临时文件
fs.unlink(chunkPath).then(resolve).catch(reject);
});
readStream.on('error', reject);
});
}
// 所有分片都写完了,关闭写流
writeStream.end();
// 等待写流完全关闭
await new Promise((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
// 删除临时目录
await fs.rmdir(tempDir);
// 更新文件索引
await addToIndex(md5, finalPath);
return Response.json({ success: true, path: finalPath });
} catch (error) {
writeStream.destroy();
return Response.json({ success: false, error: String(error) }, { status: 500 });
}
}安全的文件索引更新
多个文件同时完成时,更新索引需要防止冲突:
// libs/file-index.ts
let indexWritePromise: Promise<void> = Promise.resolve();
export async function addToIndex(md5: string, filePath: string): Promise<void> {
// 把新操作链接到Promise链末尾,确保顺序执行
const newOperation = indexWritePromise
.then(async () => {
// 1. 读取现有索引
const indexData = await fs.readFile('./index.json', 'utf-8');
const index = JSON.parse(indexData || '{}');
// 2. 添加新记录
index[md5] = filePath;
// 3. 写回文件
await fs.writeFile('./index.json', JSON.stringify(index, null, 2));
})
.catch((error) => {
console.error('更新索引失败:', error);
throw error;
});
// 更新全局Promise链
indexWritePromise = newOperation;
// 返回新操作的Promise
return newOperation;
}完整的上传流程
1. 用户选择文件
// 在组件中
const handleFileSelect = (event: react.ChangeEvent<htmlInputElement>) => {
if (event.target.files) {
addFiles(event.target.files);
}
};2. 计算MD5和预检查
async function processFile(fileItem: FileItem) {
try {
// 1. 计算MD5
const md5 = await calculateMD5(fileItem.file);
// 2. 检查服务器状态
const checkResult = await checkFile(md5, fileItem.file.name);
if (checkResult.exists) {
// 秒传成功
fileItem.status = 'success';
fileItem.progress = 100;
return;
}
// 3. 准备上传分片
const totalChunks = Math.ceil(fileItem.file.size / (5 * 1024 * 1024));
const chunksToUpload = Array.from({ length: totalChunks }, (_, i) => i)
.filter(i => !checkResult.uploadedChunks.includes(i));
if (chunksToUpload.length === 0) {
// 所有分片都已上传,直接合并
await mergeFile(md5, fileItem.file.name, totalChunks);
fileItem.status = 'success';
fileItem.progress = 100;
return;
}
// 4. 上传剩余分片
fileItem.status = 'uploading';
await uploadChunksWithConcurrency(
fileItem.file,
md5,
chunksToUpload,
checkResult.uploadedChunks
);
// 5. 合并分片
await mergeFile(md5, fileItem.file.name, totalChunks);
fileItem.status = 'success';
fileItem.progress = 100;
} catch (error) {
fileItem.status = 'error';
fileItem.error = String(error);
}
}界面显示和进度更新
用户需要看到上传进度:
// hooks/useFileUpload.ts
const updateProgress = useCallback((fileId: string, uploadedChunks: number, totalChunks: number) => {
setFiles(prev => prev.map(file => {
if (file.id === fileId) {
const progress = Math.round((uploadedChunks / totalChunks) * 100);
return { ...file, progress };
}
return file;
}));
}, []);错误处理和重试
网络可能不稳定,需要重试机制:
// hooks/useFileUpload.ts
async function uploadWithRetry(
fn: () => Promise<any>,
maxRetries: number = 3
): Promise<any> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (i < maxRetries - 1) {
// 等待一段时间再重试(指数退避)
const delay = Math.min(1000 * Math.pow(2, i), 10000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}总结
实现大文件上传的关键点:
队列管理:控制同时上传的文件数,避免卡顿
分片处理:大文件切成小片,上传更稳定
MD5计算:增量计算,避免内存问题
预检查:实现秒传和断点续传
并发控制:文件级和分片级都要控制
流式合并:服务器端用流避免内存溢出
错误处理:重试机制保证可靠性
这套方案能很好地处理各种大小的文件上传,用户体验好,服务器压力也小。实际项目中可以根据具体需求调整参数,比如分片大小、并发数等。
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!