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) ​

核心属性 ​

typescript
export class ChunkUploader {
  private chunkSize: number;        // 切片大小(默认 5MB)
  private aborted = false;          // 是否已取消上传
}

构造函数 ​

typescript
constructor(chunkSize = 5 * 1024 * 1024) {
  this.chunkSize = chunkSize;  // 默认 5MB,可自定义
}

文件哈希生成 ​

typescript
private async generateFileHash(file: File): Promise<string> {
  // 使用文件名、大小、修改时间生成唯一标识
  return `${file.name}-${file.size}-${file.lastModified}`;
}

说明:

  • 这不是真正的 MD5/SHA 哈希,而是基于文件元数据的唯一标识
  • 优点:生成速度快,无需读取文件内容
  • 缺点:如果文件内容相同但元数据不同,会被视为不同文件
  • 改进建议:可以使用 crypto.subtle.digest() 生成真正的文件哈希

上传主流程 ​

typescript
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) ​

typescript
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[] }
  • 合并成功后清除,避免占用存储空间

取消上传 ​

typescript
abort() {
  this.aborted = true;
}

2. 页面组件 (web-front/src/app/(dashboard)/upload/page.tsx) ​

状态管理 ​

typescript
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);

上传处理 ​

typescript
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);
  }
};

取消上传 ​

typescript
<Button 
  className="w-full" 
  variant="destructive"
  onClick={() => {
    uploaderRef.current?.abort();  // 调用取消方法
    setUploading(false);
    setStatus("error");
    setErrorMessage("用户取消上传");
  }}
>
  取消上传
</Button>

进度显示 ​

typescript
{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) ​

typescript
@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) ​

typescript
@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) ​

typescript
@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):

typescript
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 接口,后端检查是否有已上传的切片
    ↓
    返回已上传的切片索引列表
    ↓
    前端跳过已上传的切片,只上传缺失的切片

实现细节 ​

前端断点续传 ​

typescript
// 初始化时获取已上传的切片
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;
  }
  
  // 只上传未完成的切片
  // ...
}

后端断点续传 ​

typescript
// 检查是否已有上传记录
const existingDir = this.videosService.findExistingUpload(dto.fileHash);

if (existingDir) {
  // 读取已上传的切片文件
  const uploadedChunks = this.videosService.getUploadedChunks(existingDir);
  return { uploadId: existingDir, uploadedChunks };
}

进度显示 ​

进度计算 ​

typescript
// 每个切片上传成功后更新进度
if (onProgress) {
  onProgress(Math.round(((i + 1) / chunks) * 100));
}

说明:

  • 进度 = (已上传切片数 + 1) / 总切片数 * 100
  • 包括已跳过的切片(断点续传)

UI 展示 ​

typescript
<Progress value={progress} />
<span>{progress}%</span>

错误处理 ​

前端错误处理 ​

typescript
try {
  await uploader.upload({ ... });
} catch (error: any) {
  setStatus("error");
  setErrorMessage(error.message || "上传失败,请重试");
}

切片上传失败 ​

typescript
try {
  await request.post('/videos/upload/chunk', formData, {
    timeout: 60000,
  });
} catch (error) {
  console.error(`Chunk ${i} upload failed:`, error);
  throw error;  // 抛出错误,停止上传
}

性能优化建议 ​

1. 并发上传 ​

当前实现是串行上传,可以改为并发:

typescript
// 并发上传(示例)
const uploadPromises = chunks.map((chunk, i) => {
  if (uploadedChunks.includes(i)) return Promise.resolve();
  return uploadChunk(chunk, i);
});

await Promise.all(uploadPromises);

注意:需要控制并发数量,避免服务器压力过大。

2. 真正的文件哈希 ​

typescript
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. 重试机制 ​

typescript
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. 压缩切片 ​

对于某些文件类型,可以在上传前压缩:

typescript
// 示例:压缩图片切片(如果需要)
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. 🔄 实现服务器端清理任务(清理过期切片)

使用示例 ​

typescript
// 创建上传器
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) ​

typescript
// 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) ​

typescript
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 线程时间线:
[切片文件] → [计算哈希] → [准备切片]
            ↑ 不阻塞主线程

优势:

  • ✅ 页面始终保持流畅
  • ✅ 可以显示"正在准备文件..."的提示
  • ✅ 用户体验好,不会误以为页面卡死

使用示例 ​

基础用法 ​

typescript
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}%`);
  },
});

降级处理 ​

typescript
// 如果浏览器不支持 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,再传递
typescript
// ✅ 正确: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:

typescript
// next.config.ts
export default {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.module.rules.push({
        test: /\.worker\.ts$/,
        use: { loader: 'worker-loader' },
      });
    }
    return config;
  },
};

或者使用内联 Worker(推荐,避免路径问题):

typescript
// 创建内联 Worker
const workerCode = `...`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);

3. 内存管理 ​

问题:大文件的所有切片同时加载到内存

解决方案:

  • 不要一次性准备所有切片
  • 使用流式处理,准备一个上传一个
typescript
// ✅ 推荐:流式处理
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. 错误处理 ​

typescript
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 释放资源

完整示例 ​

typescript
// 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