大文件切片上传实现详解
概述
本文档详细说明了大文件切片上传、进度显示和断点续传的完整实现方案。该方案将大文件分割成多个小切片,逐个上传,支持断点续传和实时进度显示。
技术架构
前端技术栈
- React + TypeScript
- ChunkUploader 类:封装切片上传逻辑
- localStorage:存储上传进度
- FormData:传输文件切片
后端技术栈
- NestJS + TypeScript
- Multer:处理文件上传
- File System (fs):文件操作和合并
核心流程
┌─────────────────────────────────────────────────────────────┐
│ 上传流程图 │
└─────────────────────────────────────────────────────────────┘
1. 用户选择文件
↓
2. 生成文件哈希 (fileHash)
↓
3. 初始化上传 (POST /videos/upload/init)
├─ 检查是否已有上传记录(断点续传)
├─ 创建 uploadId
└─ 返回已上传的切片列表
↓
4. 循环上传切片 (POST /videos/upload/chunk)
├─ 跳过已上传的切片
├─ 上传单个切片
├─ 保存进度到 localStorage
└─ 更新进度条
↓
5. 合并切片 (POST /videos/upload/merge)
├─ 后端合并所有切片
├─ 保存视频信息
└─ 返回视频 ID前端实现
1. ChunkUploader 类 (web-front/src/lib/chunk-uploader.ts)
核心属性
export class ChunkUploader {
private chunkSize: number; // 切片大小(默认 5MB)
private aborted = false; // 是否已取消上传
}构造函数
constructor(chunkSize = 5 * 1024 * 1024) {
this.chunkSize = chunkSize; // 默认 5MB,可自定义
}文件哈希生成
private async generateFileHash(file: File): Promise<string> {
// 使用文件名、大小、修改时间生成唯一标识
return `${file.name}-${file.size}-${file.lastModified}`;
}说明:
- 这不是真正的 MD5/SHA 哈希,而是基于文件元数据的唯一标识
- 优点:生成速度快,无需读取文件内容
- 缺点:如果文件内容相同但元数据不同,会被视为不同文件
- 改进建议:可以使用
crypto.subtle.digest()生成真正的文件哈希
上传主流程
async upload({ file, title, collectionId, coverFile, chunkSize, onProgress }: ChunkUploadOptions) {
// 1. 计算切片数量
const size = chunkSize || this.chunkSize;
const chunks = Math.ceil(file.size / size);
// 2. 生成文件哈希
const fileHash = await this.generateFileHash(file);
// 3. 初始化上传(检查断点续传)
const { data: initData } = await request.post('/videos/upload/init', {
filename: file.name,
fileHash,
totalChunks: chunks,
fileSize: file.size,
});
const uploadId = initData.uploadId;
const uploadedChunks = initData.uploadedChunks || []; // 已上传的切片索引
// 4. 循环上传每个切片
for (let i = 0; i < chunks; i++) {
// 检查是否已取消
if (this.aborted) {
throw new Error('上传已取消');
}
// 跳过已上传的切片(断点续传)
if (uploadedChunks.includes(i)) {
if (onProgress) {
onProgress(Math.round(((i + 1) / chunks) * 100));
}
continue;
}
// 切分文件
const start = i * size;
const end = Math.min(start + size, file.size);
const chunk = file.slice(start, end);
// 构建 FormData
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunkIndex', i.toString());
formData.append('uploadId', uploadId);
formData.append('fileHash', fileHash);
// 上传切片
await request.post('/videos/upload/chunk', formData, {
baseURL: 'http://localhost:4000',
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000, // 60秒超时
});
// 保存进度
this.saveProgress(fileHash, uploadId, i);
// 更新进度回调
if (onProgress) {
onProgress(Math.round(((i + 1) / chunks) * 100));
}
}
// 5. 合并切片
const formData = new FormData();
formData.append('uploadId', uploadId);
formData.append('filename', file.name);
formData.append('fileHash', fileHash);
formData.append('title', title);
if (collectionId) formData.append('collectionId', collectionId);
if (coverFile) formData.append('cover', coverFile);
const { data: mergeData } = await request.post('/videos/upload/merge', formData, {
baseURL: 'http://localhost:4000',
headers: { 'Content-Type': 'multipart/form-data' },
});
// 清除本地进度
this.clearProgress(fileHash);
return mergeData;
}进度保存(localStorage)
private saveProgress(fileHash: string, uploadId: string, chunkIndex: number) {
const key = `upload_${fileHash}`;
const data = JSON.parse(localStorage.getItem(key) || '{}');
data.uploadId = uploadId;
data.uploadedChunks = data.uploadedChunks || [];
if (!data.uploadedChunks.includes(chunkIndex)) {
data.uploadedChunks.push(chunkIndex);
}
localStorage.setItem(key, JSON.stringify(data));
}
private clearProgress(fileHash: string) {
localStorage.removeItem(`upload_${fileHash}`);
}说明:
- 每个切片上传成功后,立即保存到 localStorage
- 格式:
{ uploadId: string, uploadedChunks: number[] } - 合并成功后清除,避免占用存储空间
取消上传
abort() {
this.aborted = true;
}2. 页面组件 (web-front/src/app/(dashboard)/upload/page.tsx)
状态管理
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<"idle" | "uploading" | "processing" | "success" | "error">("idle");
const [errorMessage, setErrorMessage] = useState("");
const uploaderRef = useRef<ChunkUploader | null>(null);上传处理
const handleUpload = async () => {
if (!file || !title) return;
setUploading(true);
setStatus("uploading");
setProgress(0);
setErrorMessage("");
try {
// 创建上传器实例(1MB 切片)
const uploader = new ChunkUploader(1 * 1024 * 1024);
uploaderRef.current = uploader;
// 开始上传
await uploader.upload({
file,
title,
collectionId: selectedCollectionId,
coverFile: coverFile || undefined,
onProgress: (percent) => {
setProgress(percent);
if (percent === 100) {
setStatus("processing"); // 上传完成,等待服务器处理
}
},
});
setStatus("success");
uploaderRef.current = null;
// 3秒后重置表单
setTimeout(() => {
setFile(null);
setTitle("");
// ... 重置其他状态
setStatus("idle");
setProgress(0);
}, 3000);
} catch (error: any) {
console.error(error);
setStatus("error");
setErrorMessage(error.message || "上传失败,请重试");
uploaderRef.current = null;
} finally {
setUploading(false);
}
};取消上传
<Button
className="w-full"
variant="destructive"
onClick={() => {
uploaderRef.current?.abort(); // 调用取消方法
setUploading(false);
setStatus("error");
setErrorMessage("用户取消上传");
}}
>
取消上传
</Button>进度显示
{status !== "idle" && (
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span>
{status === "uploading" && "正在上传..."}
{status === "processing" && "服务器正在处理转码..."}
{status === "success" && "上传成功!"}
{status === "error" && "上传失败"}
</span>
<span>{progress}%</span>
</div>
<Progress value={progress} className={status === "success" ? "bg-green-100" : ""} />
</div>
)}后端实现
1. 初始化上传接口 (POST /videos/upload/init)
@Post('upload/init')
async initUpload(@Body() dto: InitUploadDto) {
const uploadId = uuidv4();
const uploadDir = `${chunksDir}/${uploadId}`;
// 检查是否已存在该文件的上传记录(断点续传)
const existingDir = this.videosService.findExistingUpload(dto.fileHash);
if (existingDir) {
// 返回已上传的切片列表
const uploadedChunks = this.videosService.getUploadedChunks(existingDir);
return { uploadId: existingDir, uploadedChunks };
}
// 创建新的上传目录
mkdirSync(uploadDir, { recursive: true });
// 保存文件哈希映射
this.videosService.saveUploadMapping(dto.fileHash, uploadId);
return { uploadId, uploadedChunks: [] };
}功能:
- 生成唯一的
uploadId - 检查是否已有上传记录(通过
fileHash) - 如果存在,返回已上传的切片索引列表
- 如果不存在,创建新目录并保存映射关系
2. 上传切片接口 (POST /videos/upload/chunk)
@Post('upload/chunk')
@UseInterceptors(
FileInterceptor('chunk', {
storage: diskStorage({
destination: (req, file, cb) => {
const uploadId = (req as any).uploadId || 'temp';
const dir = `${chunksDir}/${uploadId}`;
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
const chunkIndex = (req as any).chunkIndex || '0';
cb(null, `chunk-${chunkIndex}`);
},
}),
}),
)
async uploadChunk(
@UploadedFile() file: Express.Multer.File,
@Body('uploadId') uploadId: string,
@Body('chunkIndex') chunkIndex: string,
) {
const fs = require('fs');
const path = require('path');
const correctDir = path.join(chunksDir, uploadId);
const correctPath = path.join(correctDir, `chunk-${chunkIndex}`);
if (!existsSync(correctDir)) {
mkdirSync(correctDir, { recursive: true });
}
// 确保文件在正确位置
if (file.path !== correctPath) {
fs.renameSync(file.path, correctPath);
}
return { success: true, chunkIndex };
}功能:
- 接收单个切片文件
- 根据
uploadId和chunkIndex保存到对应目录 - 文件命名:
chunk-0,chunk-1,chunk-2, … - 目录结构:
uploads/chunks/{uploadId}/chunk-{index}
3. 合并切片接口 (POST /videos/upload/merge)
@Post('upload/merge')
@UseInterceptors(
FileInterceptor('cover', {
storage: diskStorage({
destination: coverDir,
filename: (req, file, cb) => {
const uniqueSuffix = uuidv4();
const ext = extname(file.originalname);
cb(null, `${uniqueSuffix}${ext}`);
},
}),
}),
)
async mergeChunks(
@Body('uploadId') uploadId: string,
@Body('filename') filename: string,
@Body('fileHash') fileHash: string,
@Body('title') title: string,
@Body('collectionId') collectionId?: string,
@UploadedFile() cover?: Express.Multer.File,
) {
// 合并所有切片
const mergedFile = await this.videosService.mergeChunks(uploadId, filename);
// 处理视频(转码等)
const video = await this.videosService.processVideo(
mergedFile,
title || filename,
collectionId,
cover?.filename,
);
return { videoId: video.id };
}合并逻辑(videos.service.ts):
async mergeChunks(uploadId: string, filename: string): Promise<Express.Multer.File> {
const chunksDir = path.join('./uploads/chunks', uploadId);
const chunks = fs.readdirSync(chunksDir)
.filter(f => f.startsWith('chunk-'))
.sort((a, b) => {
const aIndex = parseInt(a.replace('chunk-', ''));
const bIndex = parseInt(b.replace('chunk-', ''));
return aIndex - bIndex;
});
const outputPath = path.join('./uploads/temp', `${uploadId}-${filename}`);
const writeStream = fs.createWriteStream(outputPath);
// 按顺序合并所有切片
for (const chunk of chunks) {
const chunkPath = path.join(chunksDir, chunk);
const chunkData = fs.readFileSync(chunkPath);
writeStream.write(chunkData);
}
writeStream.end();
// 清理切片文件
fs.rmSync(chunksDir, { recursive: true, force: true });
return {
path: outputPath,
filename: `${uploadId}-${filename}`,
originalname: filename,
} as Express.Multer.File;
}断点续传机制
工作原理
- 文件标识:使用
fileHash(文件名+大小+修改时间)作为唯一标识 - 进度存储:
- 前端:localStorage 存储已上传的切片索引
- 后端:文件系统存储已上传的切片文件
- 续传流程:
用户重新上传相同文件 ↓ 生成相同的 fileHash ↓ 调用 init 接口,后端检查是否有已上传的切片 ↓ 返回已上传的切片索引列表 ↓ 前端跳过已上传的切片,只上传缺失的切片
实现细节
前端断点续传
// 初始化时获取已上传的切片
const { data: initData } = await request.post('/videos/upload/init', {
filename: file.name,
fileHash,
totalChunks: chunks,
fileSize: file.size,
});
const uploadedChunks = initData.uploadedChunks || [];
// 循环时跳过已上传的切片
for (let i = 0; i < chunks; i++) {
if (uploadedChunks.includes(i)) {
// 跳过,但更新进度
if (onProgress) {
onProgress(Math.round(((i + 1) / chunks) * 100));
}
continue;
}
// 只上传未完成的切片
// ...
}后端断点续传
// 检查是否已有上传记录
const existingDir = this.videosService.findExistingUpload(dto.fileHash);
if (existingDir) {
// 读取已上传的切片文件
const uploadedChunks = this.videosService.getUploadedChunks(existingDir);
return { uploadId: existingDir, uploadedChunks };
}进度显示
进度计算
// 每个切片上传成功后更新进度
if (onProgress) {
onProgress(Math.round(((i + 1) / chunks) * 100));
}说明:
- 进度 =
(已上传切片数 + 1) / 总切片数 * 100 - 包括已跳过的切片(断点续传)
UI 展示
<Progress value={progress} />
<span>{progress}%</span>错误处理
前端错误处理
try {
await uploader.upload({ ... });
} catch (error: any) {
setStatus("error");
setErrorMessage(error.message || "上传失败,请重试");
}切片上传失败
try {
await request.post('/videos/upload/chunk', formData, {
timeout: 60000,
});
} catch (error) {
console.error(`Chunk ${i} upload failed:`, error);
throw error; // 抛出错误,停止上传
}性能优化建议
1. 并发上传
当前实现是串行上传,可以改为并发:
// 并发上传(示例)
const uploadPromises = chunks.map((chunk, i) => {
if (uploadedChunks.includes(i)) return Promise.resolve();
return uploadChunk(chunk, i);
});
await Promise.all(uploadPromises);注意:需要控制并发数量,避免服务器压力过大。
2. 真正的文件哈希
private async generateFileHash(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}3. 重试机制
async uploadChunkWithRetry(chunk: Blob, index: number, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
await request.post('/videos/upload/chunk', formData);
return;
} catch (error) {
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
}4. 压缩切片
对于某些文件类型,可以在上传前压缩:
// 示例:压缩图片切片(如果需要)
const compressedChunk = await compressImage(chunk);目录结构
uploads/
├── chunks/ # 切片存储目录
│ └── {uploadId}/ # 每个上传任务的目录
│ ├── chunk-0
│ ├── chunk-1
│ └── ...
├── temp/ # 合并后的临时文件
│ └── {uploadId}-{filename}
└── covers/ # 封面图片
└── {uuid}.{ext}总结
优点
- ✅ 支持大文件:通过切片上传,不受单次请求大小限制
- ✅ 断点续传:网络中断后可继续上传
- ✅ 进度显示:实时显示上传进度
- ✅ 错误恢复:单个切片失败不影响整体
- ✅ 可取消:支持用户主动取消上传
注意事项
- ⚠️ 服务器存储:需要足够的磁盘空间存储切片
- ⚠️ 清理机制:需要定期清理未完成的切片文件
- ⚠️ 并发控制:避免过多并发请求导致服务器压力
- ⚠️ 文件哈希:当前实现不是真正的文件哈希,可能误判
改进方向
- 🔄 实现真正的文件哈希(MD5/SHA-256)
- 🔄 添加并发上传控制
- 🔄 实现自动重试机制
- 🔄 添加切片完整性校验
- 🔄 实现服务器端清理任务(清理过期切片)
使用示例
// 创建上传器
const uploader = new ChunkUploader(1 * 1024 * 1024); // 1MB 切片
// 开始上传
await uploader.upload({
file: selectedFile,
title: "我的视频",
collectionId: "collection-id",
coverFile: coverImage,
onProgress: (percent) => {
console.log(`上传进度: ${percent}%`);
},
});
// 取消上传
uploader.abort();Web Worker 优化方案
为什么需要 Web Worker?
问题:
- 大文件切片操作在主线程执行,会阻塞 UI 渲染
- 文件哈希计算(特别是真正的 SHA-256)需要读取整个文件,耗时较长
- 用户在上传大文件时,页面可能出现卡顿、无响应
解决方案:
- 使用 Web Worker 在后台线程处理文件切片和哈希计算
- 主线程只负责 UI 更新和网络请求
- 保持页面流畅,提升用户体验
实现方案
1. Web Worker 文件 (chunk-uploader.worker.ts)
// web-front/src/lib/chunk-uploader.worker.ts
self.onmessage = async (e: MessageEvent<ChunkWorkerMessage>) => {
const { type, file, chunkIndex, start, end } = e.data;
try {
switch (type) {
case 'slice': {
// 在 Worker 中切片文件
const chunk = file.slice(start, end);
self.postMessage({
type: 'chunk-ready',
chunkIndex,
chunk,
});
break;
}
case 'hash': {
// 生成真正的文件哈希(SHA-256)
const hash = await generateFileHash(file);
self.postMessage({
type: 'hash-ready',
hash,
});
break;
}
}
} catch (error) {
self.postMessage({
type: 'error',
error: error.message,
});
}
};
async function generateFileHash(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}2. 使用 Worker 的上传器 (chunk-uploader-with-worker.ts)
export class ChunkUploaderWithWorker {
private worker: Worker | null = null;
private useWorker: boolean;
constructor(chunkSize = 5 * 1024 * 1024, useWorker = true) {
this.useWorker = useWorker;
}
/**
* 在 Worker 中生成文件哈希
*/
private async generateFileHashWithWorker(file: File): Promise<string> {
return new Promise((resolve, reject) => {
if (!this.worker) {
this.worker = this.createWorker();
}
const handleMessage = (e: MessageEvent<ChunkWorkerResponse>) => {
if (e.data.type === 'hash-ready') {
this.worker?.removeEventListener('message', handleMessage);
resolve(e.data.hash!);
} else if (e.data.type === 'error') {
this.worker?.removeEventListener('message', handleMessage);
reject(new Error(e.data.error));
}
};
this.worker.addEventListener('message', handleMessage);
this.worker.postMessage({ type: 'hash', file });
});
}
/**
* 在 Worker 中切片文件
*/
private async sliceChunkWithWorker(
file: File,
chunkIndex: number,
start: number,
end: number
): Promise<Blob> {
return new Promise((resolve, reject) => {
if (!this.worker) {
this.worker = this.createWorker();
}
const handleMessage = (e: MessageEvent<ChunkWorkerResponse>) => {
if (e.data.type === 'chunk-ready' && e.data.chunkIndex === chunkIndex) {
this.worker?.removeEventListener('message', handleMessage);
resolve(e.data.chunk!);
} else if (e.data.type === 'error') {
this.worker?.removeEventListener('message', handleMessage);
reject(new Error(e.data.error));
}
};
this.worker.addEventListener('message', handleMessage);
this.worker.postMessage({
type: 'slice',
file,
chunkIndex,
start,
end,
});
});
}
async upload({ file, title, collectionId, coverFile, chunkSize, onProgress }: ChunkUploadOptions) {
const size = chunkSize || this.chunkSize;
const chunks = Math.ceil(file.size / size);
// 在 Worker 中生成文件哈希
const fileHash = this.useWorker
? await this.generateFileHashWithWorker(file)
: `${file.name}-${file.size}-${file.lastModified}`;
// ... 初始化上传 ...
// 批量准备切片(在 Worker 中并行处理)
const chunkPromises: Promise<{ index: number; blob: Blob }>[] = [];
for (let i = 0; i < chunks; i++) {
if (uploadedChunks.includes(i)) continue;
const start = i * size;
const end = Math.min(start + size, file.size);
// 在 Worker 中准备切片(异步,不阻塞)
chunkPromises.push(
this.sliceChunkWithWorker(file, i, start, end).then((blob) => ({
index: i,
blob,
}))
);
}
// 等待所有切片准备完成
const preparedChunks = await Promise.all(chunkPromises);
// 按顺序上传切片
for (const { index, blob } of preparedChunks.sort((a, b) => a.index - b.index)) {
// ... 上传逻辑 ...
}
}
}性能对比
不使用 Worker(主线程)
主线程时间线:
[选择文件] → [切片文件] → [计算哈希] → [上传] → [完成]
↑ 阻塞 UI ↑ 阻塞 UI问题:
- 大文件(>100MB)切片时,页面可能卡顿 1-3 秒
- 计算 SHA-256 哈希时,页面可能无响应 2-5 秒
- 用户体验差,可能误以为页面崩溃
使用 Worker(后台线程)
主线程时间线:
[选择文件] → [上传] → [完成]
↑ 流畅
Worker 线程时间线:
[切片文件] → [计算哈希] → [准备切片]
↑ 不阻塞主线程优势:
- ✅ 页面始终保持流畅
- ✅ 可以显示”正在准备文件…”的提示
- ✅ 用户体验好,不会误以为页面卡死
使用示例
基础用法
import { ChunkUploaderWithWorker } from '@/lib/chunk-uploader-with-worker';
// 创建使用 Worker 的上传器
const uploader = new ChunkUploaderWithWorker(1 * 1024 * 1024, true); // 启用 Worker
await uploader.upload({
file: selectedFile,
title: "我的视频",
useWorker: true, // 也可以在调用时指定
onProgress: (percent) => {
console.log(`上传进度: ${percent}%`);
},
});降级处理
// 如果浏览器不支持 Worker,自动降级到主线程
const uploader = new ChunkUploaderWithWorker(1 * 1024 * 1024, true);
// 检测 Worker 支持
if (typeof Worker === 'undefined') {
console.warn('Web Worker not supported, falling back to main thread');
uploader.useWorker = false;
}注意事项
1. File 对象传递限制
问题:File 对象不能直接通过 postMessage 传递
解决方案:
- 在 Worker 中直接使用
file.slice()(File 对象在 Worker 中可用) - 或者先读取为 ArrayBuffer,再传递
// ✅ 正确:File 对象在 Worker 中可用
self.onmessage = (e) => {
const { file, start, end } = e.data;
const chunk = file.slice(start, end); // 可以直接使用
};
// ❌ 错误:不能直接传递 File 对象
worker.postMessage({ file }); // 某些浏览器可能不支持2. Next.js 中的 Worker 配置
在 Next.js 中,需要配置 webpack 来支持 Worker:
// next.config.ts
export default {
webpack: (config, { isServer }) => {
if (!isServer) {
config.module.rules.push({
test: /\.worker\.ts$/,
use: { loader: 'worker-loader' },
});
}
return config;
},
};或者使用内联 Worker(推荐,避免路径问题):
// 创建内联 Worker
const workerCode = `...`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);3. 内存管理
问题:大文件的所有切片同时加载到内存
解决方案:
- 不要一次性准备所有切片
- 使用流式处理,准备一个上传一个
// ✅ 推荐:流式处理
for (let i = 0; i < chunks; i++) {
const chunk = await sliceChunkWithWorker(file, i, start, end);
await uploadChunk(chunk, i);
// 切片上传后可以释放内存
}
// ❌ 不推荐:一次性准备所有切片
const allChunks = await Promise.all(chunkPromises); // 可能内存溢出4. 错误处理
try {
await uploader.upload({ ... });
} catch (error) {
if (error.message.includes('Worker')) {
// Worker 相关错误,降级到主线程
uploader.useWorker = false;
await uploader.upload({ ... });
} else {
// 其他错误
console.error('Upload failed:', error);
}
}最佳实践
- 默认启用 Worker:对于大文件(>10MB),默认使用 Worker
- 提供降级方案:检测 Worker 支持,不支持时自动降级
- 显示准备状态:在 Worker 处理文件时,显示”正在准备文件…”
- 内存控制:不要一次性加载所有切片到内存
- 及时清理:上传完成后,及时终止 Worker 释放资源
完整示例
// upload/page.tsx
const handleUpload = async () => {
setStatus("preparing"); // 新增:准备状态
try {
const uploader = new ChunkUploaderWithWorker(1 * 1024 * 1024, true);
uploaderRef.current = uploader;
await uploader.upload({
file,
title,
useWorker: true,
onProgress: (percent) => {
if (percent === 0) {
setStatus("preparing"); // 准备阶段
} else {
setStatus("uploading"); // 上传阶段
}
setProgress(percent);
},
});
setStatus("success");
} catch (error) {
setStatus("error");
setErrorMessage(error.message);
}
};
// UI 显示
{status === "preparing" && (
<div className="text-sm text-muted-foreground">
正在准备文件,请稍候...
</div>
)}总结
使用 Web Worker 优化文件上传的优势:
- ✅ 不阻塞主线程:页面始终保持流畅
- ✅ 更好的用户体验:不会出现卡顿、无响应
- ✅ 支持真正的文件哈希:可以计算 SHA-256,不影响 UI
- ✅ 可扩展性强:可以添加更多后台处理逻辑
适用场景:
- 大文件上传(>50MB)
- 需要计算真正的文件哈希
- 对用户体验要求高的场景
不适用场景:
- 小文件(<5MB):Worker 开销可能大于收益
- 不支持 Worker 的旧浏览器(需要降级处理)
文档版本:v1.1
最后更新:2025-01-15
作者:HLS Edu Team
Last updated on