Skip to Content
路漫漫其修远兮,吾将上下而求索

大文件切片上传实现详解

概述

本文档详细说明了大文件切片上传、进度显示和断点续传的完整实现方案。该方案将大文件分割成多个小切片,逐个上传,支持断点续传和实时进度显示。

技术架构

前端技术栈

  • 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 }; }

功能

  • 接收单个切片文件
  • 根据 uploadIdchunkIndex 保存到对应目录
  • 文件命名: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; }

断点续传机制

工作原理

  1. 文件标识:使用 fileHash(文件名+大小+修改时间)作为唯一标识
  2. 进度存储
    • 前端:localStorage 存储已上传的切片索引
    • 后端:文件系统存储已上传的切片文件
  3. 续传流程
    用户重新上传相同文件 生成相同的 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}

总结

优点

  1. 支持大文件:通过切片上传,不受单次请求大小限制
  2. 断点续传:网络中断后可继续上传
  3. 进度显示:实时显示上传进度
  4. 错误恢复:单个切片失败不影响整体
  5. 可取消:支持用户主动取消上传

注意事项

  1. ⚠️ 服务器存储:需要足够的磁盘空间存储切片
  2. ⚠️ 清理机制:需要定期清理未完成的切片文件
  3. ⚠️ 并发控制:避免过多并发请求导致服务器压力
  4. ⚠️ 文件哈希:当前实现不是真正的文件哈希,可能误判

改进方向

  1. 🔄 实现真正的文件哈希(MD5/SHA-256)
  2. 🔄 添加并发上传控制
  3. 🔄 实现自动重试机制
  4. 🔄 添加切片完整性校验
  5. 🔄 实现服务器端清理任务(清理过期切片)

使用示例

// 创建上传器 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); } }

最佳实践

  1. 默认启用 Worker:对于大文件(>10MB),默认使用 Worker
  2. 提供降级方案:检测 Worker 支持,不支持时自动降级
  3. 显示准备状态:在 Worker 处理文件时,显示”正在准备文件…”
  4. 内存控制:不要一次性加载所有切片到内存
  5. 及时清理:上传完成后,及时终止 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 优化文件上传的优势:

  1. 不阻塞主线程:页面始终保持流畅
  2. 更好的用户体验:不会出现卡顿、无响应
  3. 支持真正的文件哈希:可以计算 SHA-256,不影响 UI
  4. 可扩展性强:可以添加更多后台处理逻辑

适用场景

  • 大文件上传(>50MB)
  • 需要计算真正的文件哈希
  • 对用户体验要求高的场景

不适用场景

  • 小文件(<5MB):Worker 开销可能大于收益
  • 不支持 Worker 的旧浏览器(需要降级处理)

文档版本:v1.1
最后更新:2025-01-15
作者:HLS Edu Team

Last updated on