20个例子掌握RxJS——第十章使用 RxJS 实现大文件分片上传

17 阅读4分钟

RxJS 实战:使用 RxJS 实现大文件分片上传

概述

大文件上传是 Web 开发中的常见需求。直接上传大文件可能会遇到以下问题:

  1. 超时:文件太大,上传时间过长,导致请求超时
  2. 内存占用:大文件占用大量内存
  3. 网络中断:网络不稳定时,需要重新上传整个文件
  4. 用户体验差:无法显示上传进度,用户不知道上传状态

分片上传(Chunk Upload)是解决这些问题的有效方案。本章将介绍如何使用 RxJS 实现大文件分片上传,包括断点续传、进度显示、并发控制等功能。

分片上传的基本概念

分片上传是指将大文件分割成多个小片段(Chunk),逐个上传,最后在服务器端合并。主要优势包括:

  1. 避免超时:每个分片较小,上传时间短
  2. 断点续传:网络中断后,只需上传未完成的分片
  3. 进度显示:可以显示每个分片和整体的上传进度
  4. 并发控制:可以控制同时上传的分片数量

实现思路

1. 文件分片

将文件按照指定大小(如 2MB)分割成多个分片:

private createChunks(file: File): ChunkInfo[] {
  const chunks: ChunkInfo[] = [];
  const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);
  
  for (let i = 0; i < totalChunks; i++) {
    const start = i * this.CHUNK_SIZE;
    const end = Math.min(start + this.CHUNK_SIZE, file.size);
    const blob = file.slice(start, end);
    
    chunks.push({
      index: i,
      start,
      end,
      blob,
      uploaded: false,
      progress: 0
    });
  }
  
  return chunks;
}

2. 上传单个分片

使用 HttpRequestreportProgress 选项来跟踪上传进度:

private uploadChunk(chunk: ChunkInfo, fileId: string, file: File): Observable<{ index: number; progress: number }> {
  // 如果已经上传,直接返回
  if (chunk.uploaded) {
    return of({ index: chunk.index, progress: 100 });
  }
  
  const formData = new FormData();
  formData.append('file', chunk.blob);
  formData.append('chunkIndex', chunk.index.toString());
  formData.append('fileId', fileId);
  formData.append('fileName', file.name);
  formData.append('totalChunks', Math.ceil(file.size / this.CHUNK_SIZE).toString());
  
  const req = new HttpRequest('POST', `${this.API_BASE_URL}/api/upload/chunk`, formData, {
    reportProgress: true // 启用进度报告
  });
  
  return this.http.request(req).pipe(
    map((event: HttpEvent<any>) => {
      switch (event.type) {
        case HttpEventType.UploadProgress:
          if (event.total) {
            const progress = Math.round((100 * event.loaded) / event.total);
            return { index: chunk.index, progress };
          }
          return { index: chunk.index, progress: 0 };
        case HttpEventType.Response:
          return { index: chunk.index, progress: 100 };
        default:
          return { index: chunk.index, progress: 0 };
      }
    }),
    catchError(error => {
      console.error(`分片 ${chunk.index} 上传失败:`, error);
      throw { index: chunk.index, error };
    })
  );
}

3. 并发上传多个分片

使用 mergeMap 并发上传多个分片,并通过第二个参数限制并发数:

// 获取未上传的分片
const pendingChunks = chunks.filter(c => !c.uploaded);

// 创建分片上传流
const chunkStreams$ = from(pendingChunks).pipe(
  mergeMap(chunk => {
    return this.uploadChunk(chunk, fileId, file).pipe(
      takeUntil(this.currentUploadCancel$),
      catchError(error => {
        // 处理错误,继续上传其他分片
        console.error(`分片 ${chunk.index} 上传失败:`, error);
        if (this.uploadState) {
          const chunkToUpdate = this.uploadState.chunks.find(c => c.index === chunk.index);
          if (chunkToUpdate) {
            chunkToUpdate.progress = 0;
          }
        }
        return EMPTY;
      })
    );
  }, this.CONCURRENT_LIMIT), // 并发限制:最多同时上传 3 个分片
  takeUntil(this.destroy$)
);

4. 聚合进度

使用 scan 操作符聚合所有分片的上传进度:

chunkStreams$.pipe(
  scan((acc, chunkProgress) => {
    if (!this.uploadState) {
      return acc;
    }
    
    const chunk = this.uploadState.chunks.find(c => c.index === chunkProgress.index);
    if (chunk) {
      chunk.progress = chunkProgress.progress;
      if (chunkProgress.progress === 100) {
        chunk.uploaded = true;
      }
    }
    
    // 计算总进度
    const uploadedSize = this.uploadState.chunks.reduce((sum, c) => {
      if (c.uploaded) {
        return sum + c.blob.size;
      }
      return sum + (c.blob.size * c.progress / 100);
    }, 0);
    
    const uploadedChunks = this.uploadState.chunks.filter(c => c.uploaded).length;
    
    const progress = {
      loaded: uploadedSize,
      total: this.uploadState.file.size,
      percentage: Math.round((uploadedSize / this.uploadState.file.size) * 100),
      uploadedChunks,
      totalChunks: this.uploadState.chunks.length
    };
    
    // 更新状态
    if (this.uploadState && this.uploadState.status === 'uploading') {
      this.uploadState.progress = progress;
      this.saveUploadProgress(this.uploadState); // 保存进度到 localStorage
      this.cdr.detectChanges();
    }
    
    return progress;
  }, this.uploadState.progress)
)

5. 断点续传

使用 localStorage 保存上传进度,支持断点续传:

// 保存上传进度
private saveUploadProgress(state: UploadState): void {
  try {
    const dataToSave = {
      fileId: state.fileId,
      chunks: state.chunks.map(c => ({
        index: c.index,
        uploaded: c.uploaded,
        progress: c.progress
      })),
      progress: state.progress,
      status: state.status
    };
    localStorage.setItem(`${STORAGE_KEY_PREFIX}${state.fileId}`, JSON.stringify(dataToSave));
  } catch (e) {
    console.error('保存上传进度失败:', e);
  }
}

// 加载上传进度
private loadUploadProgress(fileId: string): Partial<UploadState> | null {
  const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${fileId}`);
  if (stored) {
    try {
      return JSON.parse(stored);
    } catch (e) {
      console.error('解析上传进度失败:', e);
    }
  }
  return null;
}

6. 合并分片

所有分片上传完成后,调用合并接口:

// 合并所有分片
private mergeChunks(fileId: string, fileName: string, totalChunks: number): Observable<any> {
  const params = new HttpParams()
    .set('fileId', fileId)
    .set('fileName', fileName)
    .set('totalChunks', totalChunks.toString());
  
  return this.http.post(`${this.API_BASE_URL}/api/upload/merge`, null, { params }).pipe(
    catchError(error => {
      console.error('合并分片失败:', error);
      return of({ success: true, message: '合并成功(模拟)' });
    })
  );
}

完整流程

1. 开始上传

startUpload(): void {
  const file = this.selectedFile;
  const fileId = this.generateFileId(file);
  
  // 创建分片
  let chunks = this.createChunks(file);
  
  // 尝试从 localStorage 恢复进度
  const savedProgress = this.loadUploadProgress(fileId);
  if (savedProgress && savedProgress.chunks) {
    // 恢复已上传的分片信息
    chunks = chunks.map(chunk => {
      const saved = savedProgress.chunks?.find(c => c.index === chunk.index);
      if (saved) {
        return {
          ...chunk,
          uploaded: saved.uploaded || false,
          progress: saved.progress || 0
        };
      }
      return chunk;
    });
  }
  
  // 初始化上传状态
  this.uploadState = {
    file,
    fileId,
    chunks,
    progress: { /* ... */ },
    status: 'uploading'
  };
  
  // 开始上传未完成的分片
  // ...
}

2. 暂停上传

pauseUpload(): void {
  if (this.uploadState && this.uploadState.status === 'uploading') {
    this.currentUploadCancel$.next(); // 取消当前上传
    this.currentUploadCancel$ = new Subject<void>(); // 创建新的取消 Subject
    this.uploadState.status = 'paused';
    this.saveUploadProgress(this.uploadState); // 保存进度
    this.cdr.detectChanges();
  }
}

3. 继续上传

resumeUpload(): void {
  if (this.uploadState && this.uploadState.status === 'paused') {
    this.startUpload(); // 从暂停处继续
  }
}

关键点解析

1. 并发控制

使用 mergeMap 的第二个参数限制并发数:

mergeMap(chunk => this.uploadChunk(chunk), 3) // 最多同时上传 3 个分片

2. 进度计算

总进度 = 所有分片的已上传大小 / 文件总大小

const uploadedSize = chunks.reduce((sum, c) => {
  if (c.uploaded) {
    return sum + c.blob.size; // 已上传的分片,使用完整大小
  }
  return sum + (c.blob.size * c.progress / 100); // 正在上传的分片,按进度计算
}, 0);

3. 错误处理

单个分片上传失败不影响其他分片:

catchError(error => {
  // 记录错误,继续上传其他分片
  console.error(`分片 ${chunk.index} 上传失败:`, error);
  return EMPTY; // 不中断流
})

4. 取消上传

使用 Subject 实现取消功能:

private currentUploadCancel$ = new Subject<void>();

// 上传时使用 takeUntil
this.uploadChunk(chunk).pipe(
  takeUntil(this.currentUploadCancel$)
)

// 取消时发出信号
cancelUpload(): void {
  this.currentUploadCancel$.next();
}

实际应用场景

1. 大文件上传

适用于上传视频、大型文档等大文件。

2. 断点续传

网络中断后,可以从上次中断的地方继续上传。

3. 进度显示

实时显示上传进度,提升用户体验。

4. 并发优化

通过控制并发数,平衡上传速度和服务器压力。

性能优化建议

1. 合理设置分片大小

根据网络环境和文件大小设置合理的分片大小:

  • 网络好:2-5MB
  • 网络一般:1-2MB
  • 网络差:500KB-1MB

2. 合理设置并发数

根据服务器性能设置合理的并发数:

  • 服务器性能好:3-5 个
  • 服务器性能一般:2-3 个
  • 服务器性能差:1-2 个

3. 压缩文件

对于可以压缩的文件(如图片),先压缩再上传。

4. 使用 Web Workers

对于大文件的分片处理,可以使用 Web Workers 避免阻塞主线程。

注意事项

  1. localStorage 限制:localStorage 有大小限制(通常 5-10MB),大文件的进度信息可能无法完全保存
  2. 服务器支持:需要服务器支持分片上传和合并接口
  3. 文件完整性:合并后需要验证文件完整性(如 MD5)
  4. 内存占用:大文件分片仍会占用内存,需要注意

总结

使用 RxJS 实现大文件分片上传是一个完整的解决方案,它提供了:

  • 分片上传:将大文件分割成小片段上传
  • 断点续传:支持从上次中断处继续上传
  • 进度显示:实时显示上传进度
  • 并发控制:控制同时上传的分片数量
  • 错误处理:单个分片失败不影响其他分片

通过合理使用 RxJS 操作符(mergeMapscantakeUntil 等),我们可以构建一个功能完整、性能优良的大文件上传系统。

码云地址:gitee.com/leeyamaster…