RxJS 实战:使用 RxJS 实现大文件分片上传
概述
大文件上传是 Web 开发中的常见需求。直接上传大文件可能会遇到以下问题:
- 超时:文件太大,上传时间过长,导致请求超时
- 内存占用:大文件占用大量内存
- 网络中断:网络不稳定时,需要重新上传整个文件
- 用户体验差:无法显示上传进度,用户不知道上传状态
分片上传(Chunk Upload)是解决这些问题的有效方案。本章将介绍如何使用 RxJS 实现大文件分片上传,包括断点续传、进度显示、并发控制等功能。
分片上传的基本概念
分片上传是指将大文件分割成多个小片段(Chunk),逐个上传,最后在服务器端合并。主要优势包括:
- 避免超时:每个分片较小,上传时间短
- 断点续传:网络中断后,只需上传未完成的分片
- 进度显示:可以显示每个分片和整体的上传进度
- 并发控制:可以控制同时上传的分片数量
实现思路
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. 上传单个分片
使用 HttpRequest 的 reportProgress 选项来跟踪上传进度:
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 避免阻塞主线程。
注意事项
- localStorage 限制:localStorage 有大小限制(通常 5-10MB),大文件的进度信息可能无法完全保存
- 服务器支持:需要服务器支持分片上传和合并接口
- 文件完整性:合并后需要验证文件完整性(如 MD5)
- 内存占用:大文件分片仍会占用内存,需要注意
总结
使用 RxJS 实现大文件分片上传是一个完整的解决方案,它提供了:
- 分片上传:将大文件分割成小片段上传
- 断点续传:支持从上次中断处继续上传
- 进度显示:实时显示上传进度
- 并发控制:控制同时上传的分片数量
- 错误处理:单个分片失败不影响其他分片
通过合理使用 RxJS 操作符(mergeMap、scan、takeUntil 等),我们可以构建一个功能完整、性能优良的大文件上传系统。