OSS大文件直传:基于Web Worker的分片上传解决方案
简介
在Web应用中,文件上传是一个常见但充满挑战的功能,特别是当需要处理大文件时,传统的表单提交方式往往面临超时、内存占用过高、用户体验差等问题。本文将详细介绍如何利用现代Web技术,特别是Web Worker和分片上传,结合阿里云OSS(对象存储服务)的直传能力,实现一个高效、可靠且用户友好的大文件上传解决方案。
该方案主要解决以下问题:
- 大文件上传过程中浏览器内存占用过高的问题
- 上传中断后需要重新上传的问题
- 重复文件上传的资源浪费问题
- 上传过程中的并发控制与错误重试问题
技术架构
graph TD A[用户选择文件] --> B[文件分片处理] B --> C[Web Worker计算MD5] C --> D{服务端查询文件是否存在} D -->|存在| E[秒传] D -->|不存在| F[获取OSS上传凭证] F --> G[分片并发上传] G --> H{所有分片上传成功?} H -->|是| I[合并分片] H -->|否| J[失败分片重试] J --> G I --> K[上传完成] E --> K
核心技术点
Web Worker:利用浏览器的多线程能力,将耗时的MD5计算过程放在后台线程中执行,避免阻塞主线程,保持页面的响应性。
文件分片:将大文件切分为多个小块,分别上传,有效降低单次上传失败的影响范围。
OSS直传:减轻服务器带宽和存储负担,文件内容直接从浏览器传输到OSS,服务器仅处理控制流程。
并发控制:限制同时上传的分片数量,避免过多的并发请求导致浏览器或网络压力过大。
断点续传:记录已上传的分片信息,支持从中断处继续上传。
秒传功能:基于文件MD5判断服务端是否已存在相同文件,实现秒传。
自动重试:对上传失败的分片实现自动重试机制,提高上传成功率。
技术选型思考
在实现大文件上传功能时,我经历了以下思考过程:
为什么选择客户端分片而非服务端分片?
服务端分片需要先将完整文件上传至服务器,然后由服务器进行分片处理和OSS上传,这会导致:
- 服务器带宽和存储资源消耗大
- 文件需要两次传输(客户端→服务器→OSS)
- 大文件上传至服务器过程中仍可能遇到超时问题
而客户端分片则可以:
- 减轻服务器负担,文件直接从浏览器传输到OSS
- 实现真正的断点续传,提升用户体验
- 充分利用客户端计算资源
为什么需要计算文件MD5?
文件MD5值作为文件的唯一标识符,具有以下作用:
- 实现秒传功能:相同MD5的文件可直接标记为上传成功
- 作为断点续传的依据:结合MD5和分片索引记录上传进度
- 校验文件完整性:确保上传的文件没有被篡改
为什么使用Web Worker计算MD5?
MD5计算是CPU密集型操作,对大文件进行MD5计算会占用主线程大量时间,导致:
- 页面卡顿,交互响应慢
- 可能触发浏览器"脚本运行时间过长"的警告
通过Web Worker将计算过程转移到后台线程:
- 保持页面流畅响应
- 充分利用多核CPU的并行计算能力
- 提高整体计算效率
实现细节
1. Web Worker实现MD5计算
Worker文件:首先,我们需要创建一个专门用于计算MD5的Web
// md5-worker.js
// 作用:实际执行MD5计算
// 导入SparkMD5库,用于计算MD5值
// 在Web Worker中使用importScripts加载外部脚本
self.importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js');
// 监听主线程消息
self.onmessage = function(event) {
// 接收主线程传来的文件分片和文件大小信息
const { chunks, fileSize } = event.data;
// 创建SparkMD5.ArrayBuffer实例,专门用于处理ArrayBuffer格式的文件内容
// SparkMD5.ArrayBuffer是SparkMD5的特殊版本,针对二进制数据(ArrayBuffer)优化
// 文件内容会通过FileReader.readAsArrayBuffer()方法读取为ArrayBuffer格式
// 这一步是避免Worker结构化克隆的关键呢!!!
const spark = new self.SparkMD5.ArrayBuffer();
let currentChunk = 0; // 当前处理的分片索引
let percentLoaded = 0; // 当前处理进度百分比
// 处理单个分片的函数
function processChunk() {
// 创建FileReader实例,用于读取Blob/File对象
const reader = new FileReader();
// 读取完成后的回调函数
reader.onload = function(e) {
// MD5计算的核心部分:
// 1. 这里通过FileReader读取到的ArrayBuffer数据(e.target.result)
// 2. 使用spark.append()方法将分片内容添加到MD5计算器中
// spark.append()就是实际执行MD5计算的方法,它会:
// - 接收ArrayBuffer数据
// - 将数据添加到增量计算器中
// - 立即计算当前分片的哈希值并与之前结果合并
spark.append(e.target.result); // 这一行就是执行MD5计算的关键
currentChunk++; // 处理下一个分片
// 计算并上报处理进度
if (currentChunk < chunks.length) {
const newPercentLoaded = Math.round((currentChunk / chunks.length) * 100);
if (newPercentLoaded > percentLoaded) {
percentLoaded = newPercentLoaded;
// 向主线程报告进度,使用postMessage进行线程间通信
self.postMessage({
type: 'progress',
percentLoaded: percentLoaded
});
}
// 递归处理下一个分片,继续MD5计算
processChunk();
} else {
// 所有分片处理完毕,完成最终MD5值计算
// spark.end()方法是MD5计算的最后一步,汇总之前所有分片的计算结果
// 返回最终的MD5哈希值字符串
const md5 = spark.end();
// 将计算好的MD5结果发送回主线程
self.postMessage({
type: 'complete',
md5: md5
});
// 释放资源,避免内存泄漏
spark.destroy();
}
};
// 读取错误处理
reader.onerror = function() {
// 向主线程报告错误
self.postMessage({
type: 'error',
message: 'MD5计算过程中出现错误'
});
};
// 以ArrayBuffer形式读取分片,适合二进制数据处理
// 这里将文件分片读取为ArrayBuffer格式,为MD5计算做准备
reader.readAsArrayBuffer(chunks[currentChunk]);
}
// 开始处理第一个分片,启动整个计算流程
// 虽然spark.append()是执行实际MD5计算的方法,但它需要在processChunk()函数中被调用
// processChunk()函数负责读取文件分片并将数据传递给spark.append()
// 整个MD5计算流程是:processChunk() -> reader.readAsArrayBuffer() -> reader.onload -> spark.append()
// 因此这里调用processChunk()才真正开始了整个计算过程
processChunk();
};
面试要点:Web Worker实现MD5计算
为什么使用Web Worker计算MD5?
- MD5计算是CPU密集型操作,在主线程执行会阻塞UI渲染,导致页面卡顿
- 通过Web Worker将计算放在后台线程,保持页面响应性
- 利用多核CPU并行计算能力,提高整体性能
SparkMD5库的优势
- 支持增量计算文件MD5,避免一次性加载整个文件到内存
- 提供对ArrayBuffer的直接支持,处理二进制数据效率高
- 针对大文件处理进行了优化
关键实现细节
- 使用
FileReader.readAsArrayBuffer()
读取二进制数据,而非Base64等格式 - 采用递归方式处理分片,避免同时加载多个分片
- 通过
postMessage
进行线程间通信,定期报告进度 - 计算完成后主动释放资源,防止内存泄漏
- 使用
异常处理策略
- 捕获读取错误并向主线程报告
- 区分进度、完成和错误三种消息类型
- Worker完成任务后自行终止,避免资源浪费
2. 文件分片处理实现
以下是主线程中实现文件分片和使用Web Worker计算MD5的代码:
/**
* 文件分片和MD5计算类
* 负责将大文件切分为小块并计算MD5值
*/
class FileChunker {
/**
* 构造函数
* @param {File} file - 需要处理的文件对象
* @param {Object} options - 配置选项
* @param {number} options.chunkSize - 分片大小,默认为2MB
* @param {string} options.workerPath - Web Worker脚本路径
*/
constructor(file, options = {}) {
this.file = file; // 保存文件对象引用
this.chunkSize = options.chunkSize || 2 * 1024 * 1024; // 默认分片大小为2MB
this.workerPath = options.workerPath || 'md5-worker.js'; // Worker脚本路径
this.chunks = []; // 用于存储文件分片的数组
this.worker = null; // Web Worker实例
this.onProgress = null; // 进度回调函数
this.onComplete = null; // 完成回调函数
this.onError = null; // 错误回调函数
}
/**
* 创建文件分片
* 将文件按照设定的大小分割成多个小块
* @returns {Array} 分片数组
*/
createChunks() {
const chunks = []; // 分片数组
const totalChunks = Math.ceil(this.file.size / this.chunkSize); // 计算总分片数
// 根据分片大小切分文件
// 这个循环是遍历每个分片的索引,从0开始直到总分片数减1
for (let i = 0; i < totalChunks; i++) {
// 计算每个分片在原始文件中的字节范围
const start = i * this.chunkSize; // 当前分片的起始字节位置
// 计算当前分片的结束位置:
// 1. start + this.chunkSize 是理论上的结束位置(当前分片起始位置加上分片大小)
// 2. this.file.size 是文件的总大小
// 3. Math.min() 函数取两者中较小的值,确保结束位置不会超出文件实际大小
// 4. 对于最后一个分片,end可能小于start+chunkSize,因为文件末尾可能不足一个完整分片
const end = Math.min(start + this.chunkSize, this.file.size); // 当前分片的结束字节位置
// File对象继承自Blob,slice方法可以从文件中提取指定范围的数据
// 这里实际上是在原始文件上执行"切片"操作,每次只取文件的一小部分
// 返回的chunk是一个新的Blob对象,包含了原始文件指定范围的数据
const chunk = this.file.slice(start, end);
chunks.push(chunk); // 将切好的分片添加到数组中
}
// 文件被分成了多个小块,每个小块大小为chunkSize(最后一块可能较小)
// 这种分片方式使得我们可以逐块处理大文件,避免一次性加载整个文件到内存
this.chunks = chunks; // 保存分片数组到实例属性中
return chunks; // 返回分片数组供外部使用
}
/**
* 计算文件的MD5值
* 使用Web Worker在后台线程进行MD5计算
* @returns {Promise} 返回一个Promise,解析为文件的MD5值
*/
calculateMD5() {
// 使用Promise包装MD5计算过程,因为:
// 1. MD5计算是异步操作,在Web Worker中执行
// 2. 需要等待计算完成后才能获取结果
// 3. Promise可以优雅地处理异步操作的成功(resolve)和失败(reject)
return new Promise((resolve, reject) => {
// 如果尚未创建分片,先创建分片
if (this.chunks.length === 0) {
this.createChunks();
}
// 创建Web Worker实例
this.worker = new Worker(this.workerPath);
// 监听Worker发送的消息
this.worker.onmessage = (event) => {
const { type, percentLoaded, md5, message } = event.data;
if (type === 'progress') {
// 进度更新事件
// 注意:这里不会与processChunk方法冲突,因为:
// 1. 这里的计算发生在Web Worker中,是完全独立的线程
// 2. SparkMD5在Worker中使用,与主线程中的实例互不干扰
// 3. 这个进度是MD5计算的进度,而非上传进度
if (typeof this.onProgress === 'function') {
this.onProgress(percentLoaded);
}
} else if (type === 'complete') {
// MD5计算完成事件
// 这里获取的md5值将用于后续的上传流程
// 与上传过程中的processChunk完全是两个独立的阶段
if (typeof this.onComplete === 'function') {
this.onComplete(md5);
}
this.worker.terminate(); // 终止Worker线程,terminate()是Web Worker API的方法,用于立即停止Worker的执行并释放资源
resolve(md5); // 解析Promise,返回MD5值
} else if (type === 'error') {
// 错误事件
// 这里的回调函数作用是将计算过程中的错误信息传递给外部
// 便于外部进行错误处理,如显示错误提示或记录日志
if (typeof this.onError === 'function') {
this.onError(message);
}
this.worker.terminate(); // 终止Worker,释放资源
reject(new Error(message)); // 拒绝Promise,返回错误信息
}
};
// Worker错误处理
this.worker.onerror = (error) => {
const message = 'Worker错误: ' + (error.message || '未知错误');
if (typeof this.onError === 'function') {
this.onError(message);
}
reject(new Error(message)); // 拒绝Promise,返回错误信息
};
// 向Worker发送文件分片和大小信息
this.worker.postMessage({
chunks: this.chunks,
fileSize: this.file.size
});
// 注意:实际的MD5计算逻辑在Web Worker中进行
// 这里只是将任务委托给Worker,具体计算过程在md5-worker.js中实现
// Worker会使用SparkMD5库对所有分片进行增量计算,避免一次性加载整个文件
// 计算完成后,Worker会通过postMessage返回结果
});
}
/**
* 获取文件分片的相关信息
* @returns {Object} 包含分片信息的对象
*/
getChunksInfo() {
// 如果尚未创建分片,先创建分片
if (this.chunks.length === 0) {
this.createChunks();
}
// 返回包含分片信息的对象
return {
chunks: this.chunks, // 分片数组
totalChunks: this.chunks.length, // 总分片数
chunkSize: this.chunkSize, // 分片大小
fileSize: this.file.size, // 文件总大小
fileName: this.file.name // 文件名
};
}
/**
* 销毁实例,释放资源
* 防止内存泄漏
*/
destroy() {
// 如果Worker存在,终止它
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
this.chunks = []; // 清空分片数组
this.file = null; // 移除文件引用
}
}
每个切片(chunk)通常包含以下信息:
chunk
:分片的二进制数据(Blob对象),用于实际上传。chunkIndex
:分片索引(从0开始),用于标识分片顺序。uploadId
:本次分片上传任务的唯一ID,服务端用于关联所有分片。objectKey
:OSS对象存储中的目标文件路径(或key)。size
:分片大小(字节数)。start
:分片在原始文件中的起始字节位置。end
:分片在原始文件中的结束字节位置。fileMd5
:整个文件的MD5值(用于校验和秒传)。- 其他可选信息:如分片的唯一标识、重试次数、状态等。
在实际实现中,最核心的字段是chunk
(数据)、chunkIndex
(顺序)、uploadId
和objectKey
(服务端识别),其余信息可根据业务需求扩展。
2MB的分片大小是如何确定的?
- 分片大小是权衡结果,太小会导致请求次数多,网络开销大;太大会导致单次上传时间长
- 2MB是经验值,在大多数网络环境下表现良好
- 实际应用中可根据网络情况动态调整分片大小
File.slice()方法的优势
- 不会复制文件数据,而是创建原始数据的引用,内存消耗小
- 返回Blob对象,可直接用于网络传输
- 切片操作在主线程,但速度快,对UI影响小
calculateMD5()方法的Promise设计
- 使用Promise封装异步计算过程,支持链式调用和async/await
- 区分成功(resolve)和失败(reject)两种情况
- 通过回调函数支持进度通知
在OSS直传分片上传过程中,服务端需要通过一组关键信息来识别“某个分片属于哪个文件,以及是该文件的哪一部分”。具体实现方式如下:
uploadId:
- 每次发起分片上传时,客户端会先向服务端(或OSS)请求初始化分片上传任务,服务端会返回一个唯一的
uploadId
。 - 这个
uploadId
相当于本次大文件上传的唯一标识,所有属于同一个文件的分片上传请求都要携带同一个uploadId
。
- 每次发起分片上传时,客户端会先向服务端(或OSS)请求初始化分片上传任务,服务端会返回一个唯一的
分片序号(PartNumber 或 chunkIndex):
- 每个分片在文件中的顺序编号,通常从1或0开始递增。
- 这个编号用于服务端在最终合并分片时,能够按照正确顺序拼接文件。
objectKey(或key、fileKey):
- OSS对象存储中的目标文件路径(唯一标识该文件在OSS中的位置)。
- 一般由客户端生成并在所有分片上传时携带,服务端据此区分不同文件。
文件MD5(可选):
- 某些业务场景下,客户端会将整个文件的MD5值传给服务端,用于秒传校验或唯一性判断。
分片上传时,客户端会在每个分片请求中携带上述信息,服务端(或OSS)通过uploadId + objectKey + chunkIndex
三元组即可唯一确定“某个分片属于哪个文件的哪一部分”。
3. 文件分片的处理策略
在实现过程中,我对文件分片大小的选择进行了深入思考:
graph LR A[分片大小选择] --> B[过小] A --> C[过大] B --> D[请求次数多] B --> E[网络开销大] B --> F[并发控制复杂] C --> G[单次上传耗时长] C --> H[失败影响范围大] C --> I[内存占用高]
分片大小的选择是一个权衡问题:
- 分片过小:会导致请求次数过多,增加网络开销和服务器压力
- 分片过大:会导致单次上传时间过长,失败重试代价高
经过测试,我选择了默认2MB的分片大小,这在大多数网络环境下能够取得较好的平衡。同时,系统支持根据实际情况动态调整分片大小,以适应不同的网络条件。
4. OSS分片上传实现
OSS分片上传主要包括三个步骤:初始化上传任务、上传分片、完成上传。以下是相关实现:
/**
* OSS分片上传管理器
* 负责处理与OSS服务交互的所有操作,包括初始化上传、上传分片、完成上传
*/
class OSSUploader {
/**
* 构造函数
* @param {Object} options - 配置选项
* @param {string} options.ossHost - OSS服务器地址
* @param {number} options.maxConcurrency - 最大并发上传数,默认为3
* @param {number} options.maxRetries - 最大重试次数,默认为3
* @param {number} options.retryDelay - 重试间隔(毫秒),默认为1000
*/
constructor(options = {}) {
// 初始化配置参数
this.ossHost = options.ossHost || ''; // OSS服务器地址
this.maxConcurrency = options.maxConcurrency || 3; // 最大并发上传数,控制并发请求数量
this.maxRetries = options.maxRetries || 3; // 最大重试次数,提高上传可靠性
this.retryDelay = options.retryDelay || 1000; // 重试间隔(毫秒),避免立即重试导致服务器压力
// 内部状态管理
this.currentUploads = 0; // 当前活跃上传数,用于并发控制
this.uploadQueue = []; // 上传队列,存储待上传的分片
this.uploadingChunks = new Map(); // 当前正在上传的分片,key为chunkIndex,用于跟踪上传状态
this.uploadedChunks = new Set(); // 已上传的分片索引集合,用于断点续传和进度计算
// 回调函数
this.onProgress = null; // 进度回调
this.onChunkSuccess = null; // 分片上传成功回调
this.onChunkError = null; // 分片上传失败回调
this.onComplete = null; // 上传完成回调函数,初始为null,在实例化OSSUploader时可通过options传入
// 当所有分片上传完成后,会调用this.onComplete(result)传递上传结果
// 使用方式:new OSSUploader({onComplete: (result) => console.log('上传完成', result)})
this.onError = null; // 上传错误回调
}
/**
* 初始化分片上传任务
* 向服务器发送请求,创建一个OSS分片上传任务
* @param {Object} params - 初始化参数
* @param {string} params.fileName - 文件名
* @param {string} params.fileMd5 - 文件MD5值,用于唯一标识文件
* @param {number} params.fileSize - 文件大小
* @returns {Promise} 返回包含uploadId的Promise
*/
async initMultipartUpload(params) {
try {
// 向服务器请求初始化分片上传
const response = await fetch(`${this.ossHost}/api/init-multipart-upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: params.fileName,
fileMd5: params.fileMd5,
fileSize: params.fileSize
})
});
// 检查响应状态
if (!response.ok) {
throw new Error(`初始化上传失败: ${response.status} ${response.statusText}`);
}
// 解析响应结果
const result = await response.json();
// 返回上传ID和其他必要信息,供后续上传分片使用
return {
uploadId: result.uploadId, // OSS分片上传的唯一标识
bucket: result.bucket, // OSS存储空间名称
objectKey: result.objectKey, // OSS对象键(文件路径)
securityToken: result.securityToken // 安全令牌,用于授权
};
} catch (error) {
// 错误处理
if (typeof this.onError === 'function') {
this.onError(error);
}
throw error; // 重新抛出错误,让上层处理
}
}
/**
* 检查文件是否已存在(用于秒传功能)
* 通过文件MD5查询服务器是否已有相同文件
* @param {string} fileMd5 - 文件MD5值
* @returns {Promise<boolean>} 文件是否已存在
*/
async checkFileExists(fileMd5) {
try {
// 向服务器查询文件是否存在
const response = await fetch(`${this.ossHost}/api/check-file-exists?fileMd5=${fileMd5}`, {
method: 'GET'
});
// 检查响应状态
if (!response.ok) {
throw new Error(`检查文件是否存在失败: ${response.status} ${response.statusText}`);
}
// 解析响应结果
const result = await response.json();
// 返回文件是否存在的布尔值,!!确保返回true/false
// !! 是双重否定运算符,用于将任何值强制转换为布尔类型
// 例如:undefined、null、0、""、NaN 等值会被转换为 false
// 而其他值如对象、数组、非空字符串、数字等会被转换为 true
// 这里确保无论服务器返回什么类型的值,最终都会返回标准的布尔值 true/false
return !!result.exists;
} catch (error) {
// 错误处理
if (typeof this.onError === 'function') {
this.onError(error);
}
throw error; // 重新抛出错误,让上层处理
}
}
/**
* 上传单个分片
* 包含获取签名、上传到OSS、报告成功三个步骤
* @param {Object} params - 上传参数
* @param {Blob} params.chunk - 分片数据
* @param {number} params.chunkIndex - 分片索引
* @param {string} params.uploadId - 上传ID
* @param {string} params.objectKey - OSS对象键
* @param {number} params.retryCount - 当前重试次数
* @returns {Promise} 上传结果Promise
*/
async uploadChunk(params) {
const { chunk, chunkIndex, uploadId, objectKey } = params;
let retryCount = params.retryCount || 0; // 当前重试次数,默认为0
try {
// 步骤1: 获取上传签名
// 向服务器获取该分片的上传URL和token
// 步骤1: 获取OSS上传签名
// 向服务器请求特定分片的OSS上传URL和授权信息
const signatureResponse = await fetch(`${this.ossHost}/api/get-upload-signature`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
uploadId: uploadId, // OSS的multipart upload ID
objectKey: objectKey, // OSS的对象存储路径标识符
partNumber: chunkIndex + 1 // OSS分片编号从1开始,而我们的索引从0开始
})
});
if (!signatureResponse.ok) {
throw new Error(`获取OSS上传签名失败: ${signatureResponse.status}`);
}
// 解析服务器返回的OSS签名信息
const signatureResult = await signatureResponse.json();
const { uploadUrl, headers } = signatureResult; // 获取OSS直传URL和授权头信息
// 步骤2: 使用签名URL上传分片到OSS
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
headers: headers, // 包含授权信息的请求头
body: chunk // 直接上传分片数据
});
if (!uploadResponse.ok) {
throw new Error(`分片上传失败: ${uploadResponse.status}`);
}
// 获取ETag,OSS返回的分片唯一标识,后续完成上传时需要
const etag = uploadResponse.headers.get('ETag');
// 步骤3: 向服务器报告分片上传成功
const reportResponse = await fetch(`${this.ossHost}/api/report-part-success`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
uploadId: uploadId,
objectKey: objectKey,
partNumber: chunkIndex + 1,
etag: etag
})
});
if (!reportResponse.ok) {
throw new Error(`报告分片上传成功失败: ${reportResponse.status}`);
}
// 标记该分片为已上传
this.uploadedChunks.add(chunkIndex);
// 从上传中分片集合移除
this.uploadingChunks.delete(chunkIndex);
// 触发分片上传成功回调
if (typeof this.onChunkSuccess === 'function') {
this.onChunkSuccess({
chunkIndex: chunkIndex,
etag: etag
});
}
// 返回成功结果
return { chunkIndex, etag };
} catch (error) {
// 分片上传失败处理
if (retryCount < this.maxRetries) {
// 未超过最大重试次数,进行重试
if (typeof this.onChunkError === 'function') {
this.onChunkError({
chunkIndex: chunkIndex,
error: error,
willRetry: true,
retryCount: retryCount + 1
});
}
// 延迟一段时间后重试,避免立即重试给服务器造成压力
// 这是一个重要的优化策略,实现了指数退避算法,防止在网络不稳定时对服务器造成"雪崩"效应
// 每次重试会增加等待时间,给网络和服务器恢复的机会
await new Promise(resolve => setTimeout(resolve, this.retryDelay * Math.pow(2, retryCount)));
// 递归调用自身进行重试,增加重试计数
return this.uploadChunk({
...params,
retryCount: retryCount + 1
});
} else {
// 已超过最大重试次数,上传失败
if (typeof this.onChunkError === 'function') {
this.onChunkError({
chunkIndex: chunkIndex,
error: error,
willRetry: false,
retryCount: retryCount
});
}
// 从上传中分片集合移除
this.uploadingChunks.delete(chunkIndex);
throw error; // 重新抛出错误,让上层处理
}
} finally {
// 减少当前活跃上传数(即当前正在并发上传的分片数量),不管成功失败都执行
// 活跃上传数用于控制并发上传的分片数量,确保不超过maxConcurrency设定的并发限制
this.currentUploads--;
// 处理队列中等待的分片
this.processQueue();
}
}
/**
* 处理上传队列
* 核心并发控制逻辑,确保同时只有限定数量的分片在上传
*/
processQueue() {
// 当前上传数小于最大并发数且队列不为空时,继续处理队列
while (this.currentUploads < this.maxConcurrency && this.uploadQueue.length > 0) {
// 取出队列中第一个分片信息对象
const chunkInfo = this.uploadQueue.shift();
// 将分片标记为正在上传状态,使用Map结构存储
// key为分片索引,value为分片信息对象
// 这样可以快速查询某个分片是否正在上传中
this.uploadingChunks.set(chunkInfo.chunkIndex, chunkInfo);
// 增加当前活跃上传数,用于控制并发数量
// 每开始一个新的分片上传,计数器加1
this.currentUploads++;
// 开始上传分片,使用非阻塞方式(不使用await)
// 这里不使用await是因为await会暂停当前函数执行,等待Promise解决
// 如果使用await,则必须等待当前分片上传完成后才能处理下一个分片
// 而我们的目标是并发上传多个分片,同时处理多个上传任务
this.uploadChunk(chunkInfo)
.then(result => {
// 上传成功后,分片会在uploadChunk方法中被添加到uploadedChunks集合
// 并且会从uploadingChunks中移除
console.log(`分片 ${chunkInfo.chunkIndex} 上传成功`);
})
.catch(error => {
// 记录错误信息,但不中断整体上传流程
// 失败的分片处理逻辑已在uploadChunk方法中实现(包括重试机制)
console.error(`分片 ${chunkInfo.chunkIndex} 上传失败:`, error);
});
// 如果使用await,代码会变成:
// await this.uploadChunk(chunkInfo);
// 这会导致while循环在每个分片上传完成前无法继续,从而无法实现并发上传
}
// 每次处理队列后更新总体进度
// 这样用户界面可以实时显示当前上传进度
this.updateProgress();
// 注意:当所有分片上传完成后的逻辑在uploadFile方法中处理
// 通过定时检查uploadedChunks.size是否等于totalChunks来判断
}
/**
* 更新上传进度
* 计算并触发进度回调
*/
updateProgress() {
if (typeof this.onProgress === 'function' && this.totalChunks > 0) {
// 计算当前进度百分比
const progress = (this.uploadedChunks.size / this.totalChunks) * 100;
// 向上层报告进度,取整数值
this.onProgress(Math.floor(progress));
}
}
/**
* 开始上传所有分片
* 整个上传过程的主控制流程
* @param {Object} params - 上传参数
* @param {Array} params.chunks - 分片数组
* @param {string} params.fileName - 文件名
* @param {string} params.fileMd5 - 文件MD5值
* @param {number} params.fileSize - 文件大小
* @returns {Promise} 上传结果Promise
*/
async uploadFile(params) {
const { chunks, fileName, fileMd5, fileSize } = params;
// 记录总分片数,用于进度计算
this.totalChunks = chunks.length;
try {
// 步骤1: 检查文件是否已存在(秒传功能)
const exists = await this.checkFileExists(fileMd5);
if (exists) {
// 文件已存在,触发完成回调
if (typeof this.onComplete === 'function') {
this.onComplete({
fileName: fileName,
fileSize: fileSize,
fileMd5: fileMd5,
isQuickUpload: true // 标记为秒传
});
}
return { success: true, isQuickUpload: true };
}
// 步骤2: 初始化分片上传
const { uploadId, objectKey } = await this.initMultipartUpload({
fileName: fileName,
fileMd5: fileMd5,
fileSize: fileSize
});
// 步骤3: 准备上传分片
// 清空之前的状态
this.uploadQueue = [];
this.uploadingChunks.clear();
this.uploadedChunks.clear();
this.currentUploads = 0;
// 创建上传队列
// 使用队列数组的原因:
// 1. 控制并发:通过队列可以实现有序的并发控制,避免同时发起过多请求导致网络拥塞
// 2. 任务管理:便于跟踪每个分片的上传状态,支持暂停/恢复功能
// 3. 优先级处理:可以根据需要调整队列中任务的优先级(如失败重试优先)
// 4. 资源控制:防止一次性创建大量Promise导致内存占用过高
// 5. 断点续传:结合已上传分片记录,可以只处理未完成的分片
for (let i = 0; i < chunks.length; i++) {
this.uploadQueue.push({
chunk: chunks[i],
chunkIndex: i,
uploadId: uploadId,
objectKey: objectKey
});
}
// 步骤4: 开始处理上传队列,启动并发上传
this.processQueue();
// 步骤5: 等待所有分片上传完成
// 这里使用自定义Promise而不是Promise.all的原因:
// 1. 动态性:分片上传是动态进行的,不是一次性创建所有Promise
// 2. 队列控制:我们使用队列控制并发数量,而不是同时启动所有分片上传
// 3. 状态监控:需要实时监控上传、队列和失败状态,Promise.all无法满足这种复杂状态管理
// 4. 错误处理:使用自定义Promise可以更精确地控制何时判定为失败(部分分片失败时)
// 5. 资源管理:避免同时创建大量Promise对象,减少内存占用
const allChunksPromise = new Promise((resolve, reject) => {
// 定时检查上传状态
const checkInterval = setInterval(() => {
if (this.uploadedChunks.size === this.totalChunks) {
// 所有分片上传成功
clearInterval(checkInterval);
resolve();
} else if (this.uploadQueue.length === 0 && this.uploadingChunks.size === 0 &&
this.uploadedChunks.size < this.totalChunks) {
// 所有分片都已处理,但并非全部成功,上传失败
clearInterval(checkInterval);
reject(new Error('部分分片上传失败,无法完成上传'));
}
// 其他情况继续等待
}, 100);
});
// 等待所有分片上传完成
await allChunksPromise;
// 步骤6: 完成上传,合并所有分片
// 这里只是发送请求到服务端,实际的分片合并操作是由OSS服务端完成的
// 当我们调用complete-multipart-upload接口时,服务端会根据uploadId找到所有已上传的分片
// 然后按照分片索引顺序将它们合并成完整的文件
const completeResponse = await fetch(`${this.ossHost}/api/complete-multipart-upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
uploadId: uploadId, // 上传ID,用于标识整个分片上传任务
objectKey: objectKey, // 对象键,即OSS中的文件路径
fileName: fileName, // 文件名
fileMd5: fileMd5 // 文件MD5,用于校验和标识
})
});
// 注意:分片的合并是由OSS服务在后端完成的,客户端只需要发送合并请求
// 服务端会调用OSS的CompleteMultipartUpload API来执行实际的合并操作
if (!completeResponse.ok) {
throw new Error(`完成上传失败: ${completeResponse.status}`);
}
// 解析合并结果
const completeResult = await completeResponse.json();
// 触发上传完成回调
if (typeof this.onComplete === 'function') {
this.onComplete({
fileName: fileName,
fileSize: fileSize,
fileMd5: fileMd5,
url: completeResult.url, // 文件访问URL
isQuickUpload: false // 非秒传
});
}
// 返回成功结果
return {
success: true,
url: completeResult.url,
isQuickUpload: false
};
} catch (error) {
// 错误处理
if (typeof this.onError === 'function') {
this.onError(error);
}
throw error; // 重新抛出错误,让上层处理
}
}
/**
* 取消上传
* 中止当前所有上传任务
*/
cancelUpload() {
// 清空上传队列,停止所有待上传的分片
this.uploadQueue = [];
// 清除状态
this.currentUploads = 0;
this.uploadingChunks.clear();
this.uploadedChunks.clear();
// 注意:这个方法不会中止已经发出的网络请求
// 对于需要取消正在进行的请求,可以使用AbortController
}
}
面试要点:OSS分片上传实现
- OSS分片上传的完整流程
- 客户端计算文件MD5,向应用服务器查询是否可以秒传
- 应用服务器为客户端提供临时授权(STS令牌或签名URL)
- 客户端使用授权信息初始化OSS上传任务
- 客户端并发上传分片到OSS(文件内容不经过应用服务器)
- 客户端通知应用服务器所有分片上传完成
- 应用服务器调用OSS API完成分片合并
并发控制的实现原理
- 使用计数器(currentUploads)、队列(uploadQueue)和映射(uploadingChunks)跟踪上传状态
- 通过processQueue方法控制并发数,确保同时只有特定数量的请求
- 使用非阻塞方式启动上传任务,提高并发效率
错误处理与重试机制
- 分级重试策略:区分临时错误和永久错误
- 延时重试:使用setTimeout在重试前等待一段时间
- 递归重试:通过递归调用自身实现多次重试
- 记录重试次数,达到限制后放弃
秒传功能实现原理
- 基于文件MD5值查询服务器是否已有相同文件
- 文件存在时跳过实际上传过程,直接返回成功
- 节省带宽和时间,提升用户体验
5. 并发控制与重试策略
在上传过程中,合理的并发控制与重试策略至关重要:
graph TD A[并发控制] --> B[防止浏览器资源耗尽] A --> C[避免请求阻塞] A --> D[适应网络环境] E[重试策略] --> F[指数退避算法] E --> G[重试次数限制] E --> H[错误类型区分]
我在实现中采用的策略包括:
并发控制:
- 默认最大并发数为3,可根据网络条件和设备性能动态调整
- 使用队列管理待上传分片,确保平滑上传
- 实时监控上传状态,动态调整并发数
重试策略:
- 区分临时性错误和永久性错误,只对临时性错误进行重试
- 使用指数退避算法,每次重试的等待时间逐渐增加
- 设置最大重试次数,避免无限重试造成资源浪费
- 针对特定HTTP状态码采用不同重试策略
6. 断点续传实现
断点续传是大文件上传中极为重要的功能,它允许用户在上传中断后,从已上传的部分继续上传,而不是重新开始。
sequenceDiagram participant Browser participant LocalStorage participant Server Browser->>LocalStorage: 保存上传状态和已上传分片信息 Browser->>Browser: 上传中断 Browser->>LocalStorage: 恢复上传状态 Browser->>Server: 查询已上传分片 Server-->>Browser: 返回已上传分片列表 Browser->>Browser: 跳过已上传分片,继续上传剩余分片
以下是断点续传的核心实现:
/**
* 断点续传管理器
* 负责保存和恢复上传状态,支持中断后继续上传
*/
class ResumeUploadManager {
/**
* 构造函数
* @param {string} storageKeyPrefix - 本地存储键前缀,用于区分不同上传任务
*/
constructor(storageKeyPrefix = 'oss_upload_') {
this.storageKeyPrefix = storageKeyPrefix; // 本地存储键前缀
}
/**
* 保存上传状态到本地存储
* @param {Object} state - 上传状态信息
* @param {string} state.fileMd5 - 文件MD5,作为状态的唯一标识
* @param {string} state.fileName - 文件名
* @param {number} state.fileSize - 文件大小
* @param {string} state.uploadId - 上传ID,OSS分片上传的唯一标识
* @param {string} state.objectKey - OSS对象键(文件路径)
* @param {Array} state.uploadedChunks - 已上传分片索引数组
*/
saveUploadState(state) {
try {
// 使用fileMd5作为存储键的一部分,确保唯一性
const key = `${this.storageKeyPrefix}${state.fileMd5}`;
// 将状态对象序列化为JSON字符串存储
localStorage.setItem(key, JSON.stringify(state));
} catch (error) {
// 处理存储异常,如存储空间已满等
console.error('保存上传状态失败:', error);
}
}
/**
* 从本地存储获取上传状态
* @param {string} fileMd5 - 文件MD5
* @returns {Object|null} 上传状态对象或null(如果不存在)
*/
getUploadState(fileMd5) {
try {
// 构建存储键
const key = `${this.storageKeyPrefix}${fileMd5}`;
// 从localStorage获取状态字符串
const stateStr = localStorage.getItem(key);
// 将JSON字符串解析为对象,如果不存在则返回null
return stateStr ? JSON.parse(stateStr) : null;
} catch (error) {
// 处理解析异常,可能是JSON格式错误
console.error('获取上传状态失败:', error);
return null;
}
}
/**
* 更新已上传的分片信息
* 当一个分片上传成功时调用此方法更新状态
* @param {string} fileMd5 - 文件MD5
* @param {number} chunkIndex - 分片索引
*/
updateUploadedChunk(fileMd5, chunkIndex) {
try {
// 获取当前上传状态
const state = this.getUploadState(fileMd5);
if (state) {
// 确保uploadedChunks数组存在
if (!state.uploadedChunks) {
state.uploadedChunks = [];
}
// 如果分片索引不在已上传列表中,添加它
if (!state.uploadedChunks.includes(chunkIndex)) {
state.uploadedChunks.push(chunkIndex);
// 保存更新后的状态
this.saveUploadState(state);
}
}
} catch (error) {
console.error('更新已上传分片信息失败:', error);
}
}
/**
* 清除上传状态
* 上传完成或取消时调用
* @param {string} fileMd5 - 文件MD5
*/
clearUploadState(fileMd5) {
try {
// 构建存储键
const key = `${this.storageKeyPrefix}${fileMd5}`;
// 从localStorage中移除该键
localStorage.removeItem(key);
} catch (error) {
console.error('清除上传状态失败:', error);
}
}
/**
* 从服务器获取最新的已上传分片列表
* 确保本地状态与服务器状态同步
* @param {Object} params - 请求参数
* @param {string} params.ossHost - OSS服务器地址
* @param {string} params.fileMd5 - 文件MD5
* @param {string} params.uploadId - 上传ID
* @param {string} params.objectKey - OSS对象键
* @returns {Promise<Array>} 已上传分片索引数组
*/
async fetchUploadedChunks(params) {
const { ossHost, fileMd5, uploadId, objectKey } = params;
try {
// 向服务器请求已上传分片列表
const response = await fetch(`${ossHost}/api/list-uploaded-parts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileMd5: fileMd5,
uploadId: uploadId,
objectKey: objectKey
})
});
if (!response.ok) {
throw new Error(`获取已上传分片列表失败: ${response.status}`);
}
const result = await response.json();
// 更新本地存储的上传状态
const state = this.getUploadState(fileMd5);
if (state) {
// 将服务器返回的分片编号(从1开始)转换为索引(从0开始)
state.uploadedChunks = result.parts.map(part => part.partNumber - 1);
this.saveUploadState(state);
}
// 返回已上传分片索引数组
return result.parts.map(part => part.partNumber - 1);
} catch (error) {
console.error('获取已上传分片列表失败:', error);
// 如果服务器请求失败,返回本地存储的信息作为备选
const state = this.getUploadState(fileMd5);
return state?.uploadedChunks || [];
}
}
}
以下是如何将断点续传整合到OSS上传器中:
/**
* 扩展OSSUploader类,添加断点续传支持
* 继承基本的OSS上传器,增加断点续传功能
*/
class ResumeableOSSUploader extends OSSUploader {
/**
* 构造函数
* @param {Object} options - 配置选项
*/
constructor(options = {}) {
// 调用父类构造函数
super(options);
// 创建断点续传管理器实例
this.resumeManager = new ResumeUploadManager(options.storageKeyPrefix);
}
/**
* 重写上传方法,支持断点续传
* 在基本上传流程基础上增加状态保存和恢复
* @param {Object} params - 上传参数
* @returns {Promise} 上传结果Promise
*/
async uploadFile(params) {
const { chunks, fileName, fileMd5, fileSize } = params;
// 记录总分片数
this.totalChunks = chunks.length;
try {
// 步骤1: 检查文件是否已存在(秒传功能)
const exists = await this.checkFileExists(fileMd5);
if (exists) {
// 文件已存在,触发完成回调
if (typeof this.onComplete === 'function') {
this.onComplete({
fileName: fileName,
fileSize: fileSize,
fileMd5: fileMd5,
isQuickUpload: true // 标记为秒传
});
}
// 清除可能存在的上传状态
this.resumeManager.clearUploadState(fileMd5);
return { success: true, isQuickUpload: true };
}
// 步骤2: 尝试恢复上传状态
let uploadId, objectKey;
let uploadedChunks = [];
// 从本地存储获取上传状态
const savedState = this.resumeManager.getUploadState(fileMd5);
// 如果有保存的状态,尝试恢复
if (savedState && savedState.uploadId && savedState.objectKey) {
// 验证服务端上传任务是否仍有效
try {
// 从服务器获取已上传分片信息
uploadedChunks = await this.resumeManager.fetchUploadedChunks({
ossHost: this.ossHost,
fileMd5: fileMd5,
uploadId: savedState.uploadId,
objectKey: savedState.objectKey
});
// 如果服务端有有效的上传任务,使用保存的上传ID和对象键
uploadId = savedState.uploadId;
objectKey = savedState.objectKey;
console.log(`恢复上传任务: ${uploadId}, 已上传分片: ${uploadedChunks.length}`);
// 注意:这里只是创建上传队列,实际的上传操作在processQueue()方法中进行
// 断点续传时,我们会跳过已上传的分片,只将未上传的分片加入队列
// processQueue()方法会在后续步骤中被调用,它负责启动并发上传
// 每个分片的实际上传是由uploadChunk()方法执行的
} catch (error) {
console.warn('验证上传任务失败,创建新任务', error);
// 验证失败,将创建新的上传任务
}
}
// 步骤3: 如果没有有效的上传任务,初始化新的上传任务
if (!uploadId || !objectKey) {
const initResult = await this.initMultipartUpload({
fileName: fileName,
fileMd5: fileMd5,
fileSize: fileSize
});
uploadId = initResult.uploadId;
objectKey = initResult.objectKey;
// 保存新建的上传状态
this.resumeManager.saveUploadState({
fileMd5: fileMd5,
fileName: fileName,
fileSize: fileSize,
uploadId: uploadId,
objectKey: objectKey,
uploadedChunks: []
});
}
// 步骤4: 准备上传,清空之前的状态
this.uploadQueue = [];
this.uploadingChunks.clear();
this.uploadedChunks = new Set(uploadedChunks); // 设置已上传分片
this.currentUploads = 0;
// 步骤5: 创建上传队列,跳过已上传的分片
// 注意:这里只是准备上传队列,实际的上传操作在步骤7中通过processQueue()方法启动
for (let i = 0; i < chunks.length; i++) {
// 只将未上传的分片加入队列
if (!uploadedChunks.includes(i)) {
this.uploadQueue.push({
chunk: chunks[i],
chunkIndex: i,
uploadId: uploadId,
objectKey: objectKey,
fileMd5: fileMd5 // 添加fileMd5用于更新状态
});
}
}
// 步骤6: 如果所有分片都已上传,直接完成上传
if (this.uploadQueue.length === 0 && this.uploadedChunks.size === this.totalChunks) {
return await this.completeMultipartUpload({
uploadId: uploadId,
objectKey: objectKey,
fileName: fileName,
fileMd5: fileMd5
});
}
// 步骤7: 开始处理上传队列
this.processQueue();
// 步骤8: 等待所有分片上传完成
const allChunksPromise = new Promise((resolve, reject) => {
const checkInterval = setInterval(() => {
if (this.uploadedChunks.size === this.totalChunks) {
// 所有分片上传成功
clearInterval(checkInterval);
resolve();
} else if (this.uploadQueue.length === 0 && this.uploadingChunks.size === 0 &&
this.uploadedChunks.size < this.totalChunks) {
// 所有分片都已处理,但并非全部成功
clearInterval(checkInterval);
reject(new Error('部分分片上传失败,无法完成上传'));
}
}, 100);
});
// 等待上传完成
await allChunksPromise;
// 步骤9: 完成上传,合并分片
return await this.completeMultipartUpload({
uploadId: uploadId,
objectKey: objectKey,
fileName: fileName,
fileMd5: fileMd5
});
} catch (error) {
if (typeof this.onError === 'function') {
this.onError(error);
}
throw error;
}
}
/**
* 完成分片上传
* 通知服务器合并所有分片
* @param {Object} params - 完成上传参数
* @returns {Promise} 完成上传结果Promise
*/
async completeMultipartUpload(params) {
const { uploadId, objectKey, fileName, fileMd5 } = params;
// 调用完成上传API
const completeResponse = await fetch(`${this.ossHost}/api/complete-multipart-upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
uploadId: uploadId,
objectKey: objectKey,
fileName: fileName,
fileMd5: fileMd5
})
});
if (!completeResponse.ok) {
throw new Error(`完成上传失败: ${completeResponse.status}`);
}
const completeResult = await completeResponse.json();
// 上传完成后清除上传状态
this.resumeManager.clearUploadState(fileMd5);
// 触发上传完成回调
if (typeof this.onComplete === 'function') {
this.onComplete({
fileName: fileName,
fileSize: this.file?.size,
fileMd5: fileMd5,
url: completeResult.url,
isQuickUpload: false
});
}
return {
success: true,
url: completeResult.url,
isQuickUpload: false
};
}
/**
* 重写分片上传成功处理
* 增加了上传状态的更新
* @param {Object} params - 参数
*/
async onChunkUploadSuccess(params) {
const { chunkIndex, fileMd5 } = params;
// 更新本地存储的上传状态
this.resumeManager.updateUploadedChunk(fileMd5, chunkIndex);
// 调用父类方法
if (typeof super.onChunkSuccess === 'function') {
super.onChunkSuccess(params);
}
}
}
面试要点:断点续传实现
断点续传的核心原理
- 使用本地存储(localStorage)保存上传状态和已上传分片信息
- 上传前检查是否有未完成的上传任务
- 从服务器获取已上传分片列表,确保状态同步
- 只上传未完成的分片,节省时间和带宽
上传状态持久化方案
- 使用文件MD5作为唯一标识符,关联上传状态
- 存储结构包含上传ID、文件信息和已上传分片列表
- 定期更新状态,确保数据最新
- 上传完成后主动清除状态,避免存储空间浪费
断点续传的容错机制
- 服务器状态校验:恢复前先验证服务端上传任务是否有效
- 双重验证:同时通过本地存储和服务器查询确认已上传分片
- 状态不一致处理:以服务器数据为准,更新本地存储
- 任务恢复失败时自动创建新上传任务
多设备同步的考虑
- localStorage限制在当前浏览器,无法跨设备同步
- 使用服务器作为分片状态的权威来源
- 可扩展支持IndexedDB存储更大数据量
- 潜在的改进:使用云同步存储服务支持跨设备续传
性能与可靠性考量
在实现大文件上传功能时,我特别关注了以下性能和可靠性方面的考量:
1. 内存使用优化
graph TD A[内存使用优化] --> B[分片处理文件] A --> C[及时释放资源] A --> D[使用Web Worker隔离计算] B --> E[避免一次性加载整个文件] C --> F[上传完成后清除对象引用] D --> G[避免主线程阻塞]
- 分片处理:通过File.slice()方法创建文件分片,避免一次性将整个文件加载到内存
- 及时释放资源:上传完成后主动调用terminate()结束Worker,并清除不再需要的对象引用
- 使用Blob/ArrayBuffer:直接操作二进制数据,避免不必要的字符串转换
2. 错误处理与恢复
- 细粒度错误分类:区分网络错误、服务器错误和客户端错误,采用不同的处理策略
- 指数退避重试:重试间隔随重试次数增加,避免频繁重试导致服务器压力过大
- 部分错误容忍:只有关键步骤失败才终止整个上传过程,提高容错性
3. 网络适应性
- 动态调整并发数:根据网络情况动态调整并发上传的分片数
- 优先上传小分片:在弱网环境下,可优先上传较小的分片,提高成功率
- 自适应重试策略:根据错误类型和网络状况调整重试策略
总结与展望
本文详细介绍了如何使用Web Worker和分片上传技术,结合OSS的直传能力,实现一个高效、可靠的大文件上传解决方案。
主要成果
- 流畅的用户体验:通过Web Worker将MD5计算放在后台线程,保持页面响应性
- 高效的资源利用:客户端分片处理和直传OSS,减轻服务器负担
- 可靠的上传机制:断点续传、秒传和自动重试机制提高上传成功率
- 灵活的扩展性:模块化设计使系统易于扩展和维护
未来优化方向
- 自适应分片大小:根据网络环境和文件特性动态调整分片大小
- 预上传检测:上传前检测网络状况,提供更准确的上传时间估计
- 多文件并行上传:支持多文件队列,并智能调度上传顺序
- 更多存储服务支持:扩展支持更多云存储服务,如腾讯云COS、七牛云等
- 服务端集成优化:提供更完善的服务端集成方案,简化后端实现
最终思考
大文件上传是前端开发中的一个挑战性问题,它涉及网络、存储、性能、用户体验等多个方面。通过合理利用现代Web技术,如Web Worker、Blob API、Fetch API等,结合云存储服务的能力,我们可以构建出既高效又可靠的大文件上传解决方案。
在实际应用中,我们还应当根据具体业务场景进行调整和优化,例如针对特定文件类型的处理策略、特定用户群体的网络环境适配等。只有将技术与业务紧密结合,才能真正解决用户的实际问题。
面试要点:完整实现集成
架构设计与类的职责划分
- 采用组合设计模式,每个类负责单一功能:FileChunker负责文件分片,OSSUploader负责上传
- BigFileUploader作为外观类,提供统一简洁的接口,隐藏内部复杂性
- 良好的关注点分离,便于维护和扩展
进度计算和用户反馈
- 分别跟踪MD5计算和文件上传两个阶段的进度
- 使用加权平均计算总进度,反映实际耗时比例
- 丰富的回调机制支持进度展示、成功提示、错误处理等
错误处理策略
- 全流程try-catch捕获异常,避免未处理的错误
- 关键操作错误会重试,非关键错误优雅降级
- 通过onError回调提供详细错误信息,便于调试和用户反馈
资源管理和内存释放
- 使用reset方法在上传完成后清理资源
- 及时释放不再需要的对象引用,避免内存泄漏
- 文件处理完毕后主动终止Web Worker
面试重点与常见问题
作为一个复杂的前端技术解决方案,大文件上传在面试中常被提及。以下是一些面试官可能关注的要点和相应的回答思路:
1. 核心技术选型与原理
为什么需要对文件进行分片?
- 大文件一次性上传容易因网络波动导致失败,分片可降低单次上传失败的影响
- 支持断点续传,中断后只需上传未完成的分片
- 分片并发上传可以提高整体上传速度
- 浏览器对单次请求大小有限制,分片可突破这个限制
2MB的分片大小是如何确定的?
- 分片大小是权衡结果,太小会导致请求次数多,网络开销大;太大会导致单次上传时间长
- 2MB是经验值,在大多数网络环境下表现良好
- 实际应用中可根据网络情况动态调整分片大小
File.slice()方法的优势
- 不会复制文件数据,而是创建原始数据的引用,内存消耗小
- 返回Blob对象,可直接用于网络传输
- 切片操作在主线程,但速度快,对UI影响小
calculateMD5()方法的Promise设计
- 使用Promise封装异步计算过程,支持链式调用和async/await
- 区分成功(resolve)和失败(reject)两种情况
- 通过回调函数支持进度通知
问题:大文件上传的核心技术方案是什么?
答:我们的大文件上传方案主要基于以下核心技术:
分片上传技术:将大文件切分为多个小块(通常为1MB-5MB),分别上传
- 使用Blob API和File API进行客户端文件切片
- 利用Blob.slice()方法高效切分文件,不占用额外内存
OSS直传方案:
- 采用基于STS临时授权的客户端直传模式
- 客户端获取临时凭证后直接与OSS服务器通信,不经过应用服务器
- 使用OSS的分片上传API:InitiateMultipartUpload、UploadPart、CompleteMultipartUpload
并行上传控制:
- 实现可配置的并发控制机制,默认3-5个并发请求
- 使用Promise.all和自定义队列管理并发任务
文件指纹计算:
- 使用Web Worker在后台线程计算文件MD5
- 采用SparkMD5库进行增量计算,避免内存溢出
断点续传实现:
- 基于localStorage存储上传状态和已上传分片信息
- 结合文件MD5作为唯一标识,实现跨会话的断点续传
2. 实际操作流程
问题:在实际项目中,你是如何操作大文件上传的?
答:在实际项目中,我的操作流程如下:
前期准备工作:
- 与后端确定上传流程和接口规范
- 配置OSS相关参数(Bucket、Region等)
- 设计上传组件的UI和交互逻辑
核心代码实现:
- 封装FileChunker类处理文件分片和MD5计算
- 实现OSSUploader类负责与OSS服务交互
- 开发BigFileUploader外观类提供统一API
上传流程实现:
- 用户选择文件后,先计算文件MD5作为唯一标识
- 向后端请求上传参数和STS临时授权
- 初始化OSS分片上传任务,获取uploadId
- 并行上传分片,实时更新进度
- 所有分片上传完成后,调用合并接口完成上传
异常处理与优化:
- 实现网络异常自动重试机制
- 添加上传暂停/恢复功能
- 优化上传速度和内存占用
3. 前后端联调过程
问题:在这个大文件上传方面,你与后端是怎么进行联调的?
答:与后端的联调过程主要包括以下几个方面:
接口协议设计:
- 共同制定接口规范,包括请求参数、响应格式和错误码
- 明确分工界面:前端负责文件分片和上传,后端负责授权和文件管理
授权机制联调:
- 测试STS临时授权的获取和刷新机制
- 验证授权策略的正确性,确保安全性和最小权限原则
分片上传联调:
- 验证分片大小的合理性,通常在测试环境尝试不同大小(1MB、5MB等)
- 测试并发上传的稳定性,调整并发数量
断点续传测试:
- 模拟网络中断场景,验证断点续传功能
- 测试跨会话续传(关闭浏览器后重新打开)
边界情况处理:
- 测试超大文件(如10GB以上)的上传性能
- 验证特殊文件类型(如视频、压缩包)的兼容性
- 模拟各种错误情况,如授权失败、分片上传失败等
性能优化:
- 使用Chrome DevTools分析上传过程的网络和内存使用情况
- 与后端一起优化上传参数,如分片大小、超时时间等
4. MD5相关知识
问题:md5了解过吗?md5加密的长度是不是固定的?
MD5基本概念:
- MD5(Message-Digest Algorithm 5)是一种广泛使用的哈希函数,可以生成数据的"指纹"
- 它不是加密算法,而是一种单向散列函数,无法从结果逆向推导出原始数据
- 主要用于数据完整性校验、文件唯一性标识等场景
MD5输出长度:
- MD5的输出长度是固定的,始终为128位(16字节)
- 通常表示为32位的十六进制字符串(每个字节用两个十六进制字符表示)
- 例如:
e10adc3949ba59abbe56e057f20f883e
(这是"123456"的MD5值)
在文件上传中的应用:
- 我们使用MD5计算文件的唯一标识,用于实现秒传和断点续传
- 对于大文件,采用分块计算然后合并的方式,避免内存溢出
- 使用Web Worker在后台线程计算,避免阻塞UI线程
MD5的局限性:
- MD5已被证明存在碰撞风险(不同输入可能产生相同输出)
- 在安全敏感场景,现在更推荐使用SHA-256等更安全的哈希算法
- 但在文件指纹场景下,MD5仍然是计算效率和安全性的良好平衡点
问题:为什么选择客户端分片而不是服务端分片?
答:客户端分片的优势主要有三点:
1. 减轻服务器负担:文件直接从浏览器上传到OSS,不占用应用服务器带宽和存储
2. 更好的用户体验:即使上传中断,也只需重新上传未完成的分片,真正实现断点续传
3. 更高的并发性能:客户端可以并行上传多个分片,提高整体上传速度
服务端分片则需要先将完整文件上传至服务器,过程中仍可能遇到超时问题,且文件需要二次传输(客户端→服务器→OSS),效率低下。
问题:Web Worker在整个方案中的作用是什么?
答:Web Worker主要用于MD5计算,发挥了三个关键作用:
1. 避免主线程阻塞:MD5计算是CPU密集型操作,大文件计算可能长达数秒甚至数十秒,放在Worker线程避免页面卡顿
2. 提高计算性能:利用多核CPU的并行计算能力,提升计算速度
3. 优化用户体验:计算过程中UI保持响应,同时可以显示计算进度
技术上,我们使用SparkMD5库的增量计算能力,结合ArrayBuffer处理二进制数据,通过postMessage与主线程通信。
2. 关键实现难点
问题:如何处理大文件分片上传的并发控制?
答:并发控制是上传性能与浏览器资源消耗间的平衡,我采用的策略包括:
1. 队列管理:使用三个数据结构(uploadQueue、uploadingChunks、uploadedChunks)跟踪分片状态
2. 计数器限流:通过currentUploads变量控制同时上传的分片数量
3. 动态调整:根据上传速度和错误率,可动态调整并发数
4. 非阻塞设计:使用Promise处理异步上传,避免互相阻塞
并发数默认设为3,这是经验值,在大多数网络环境下表现良好。过高会导致浏览器资源耗尽,过低则上传速度不理想。
问题:断点续传是如何实现的?
答:断点续传的核心是状态持久化和分片验证:
1. 状态持久化:使用localStorage存储上传状态(uploadId、objectKey)和已上传分片索引
2. 状态恢复:上传前检查是否有未完成的任务,有则向服务器验证任务是否仍有效
3. 服务器协同:通过API获取服务器上已存在的分片列表,确保本地状态与服务器同步
4. 选择性上传:恢复上传时,只上传未完成的分片,节省时间和带宽
关键是使用文件MD5作为唯一标识符,将上传状态与特定文件关联,并处理任务失效情况。
问题:上传错误处理和重试策略有哪些考量?
答:错误处理遵循"失败优雅,成功必达"的原则:
1. 错误分类:区分临时错误(网络波动)和永久错误(权限问题),只对临时错误重试
2. 重试策略:采用延时递增策略,每次重试间隔逐渐增加,避免短时间内大量重试
3. 重试限制:设置最大重试次数(默认3次),防止无限重试消耗资源
4. 局部失败处理:单个分片失败不会导致整体失败,除非重试次数耗尽
实现上使用try-catch结合Promise,捕获各种异常并提供详细的错误信息,便于调试和用户反馈。
3. 性能与安全
问题:如何优化大文件上传的性能?
答:性能优化贯穿整个方案:
1. 计算优化:使用Web Worker和增量MD5计算,避免一次性加载整个文件
2. 网络优化:分片并发上传,动态调整分片大小和并发数
3. 传输优化:秒传功能避免重复上传,断点续传减少重传数据量
4. 内存优化:使用File.slice()创建分片引用而非复制数据,降低内存占用
5. UI响应优化:所有耗时操作都是异步的,不阻塞主线程
此外,还实现了进度实时反馈,让用户感知上传进度,提升体验。
问题:上传过程中有哪些安全考量?
答:安全性是文件上传的重要方面:
1. 签名授权:每个分片上传前都需获取临时授权签名,确保上传权限
2. 文件校验:使用MD5验证文件完整性,防止上传过程中的数据损坏
3. 类型控制:可以在上传前验证文件类型和大小,防止恶意文件上传
4. 服务端验证:所有客户端行为都需要服务端最终确认,防止客户端伪造
实现中,我们通过API接口获取临时授权,而非直接在前端存储密钥,符合安全最佳实践。
4. 工程化与拓展性
问题:如何使这个上传方案适应不同的业务需求?
答:设计时考虑了高度的可配置性和扩展性:
1. 模块化设计:分离文件处理、MD5计算、上传管理等模块,可单独使用或替换
2. 配置灵活:关键参数如分片大小、并发数、重试策略等均可配置
3. 回调机制:提供丰富的事件回调(进度、成功、错误等),便于业务集成
4. 接口适配:OSS上传部分可扩展支持其他云存储服务(如腾讯云COS、七牛云等)
此外,支持不同文件类型,并可根据文件特性动态调整策略,例如对图片可采用较小分片,对视频采用较大分片。
问题:还有哪些可以改进的地方?
答:现有实现虽然健壮,但仍有几个方向可以提升:
1. 自适应分片:根据网络环境动态调整分片大小和并发数
2. 预上传检测:上传前对网络状况进行评估,提供更准确的时间预估
3. 多文件队列:支持多文件同时排队,智能调度上传顺序
4. 离线上传:结合Service Worker实现断网后继续队列上传
5. 上传限速:针对不同网络环境控制上传速度,避免占用全部带宽
从工程角度,还可以提供更完善的错误监控和数据统计,便于问题排查和性能分析。
问题:如果在上传途中断网中断了,怎么处理?
答:断网中断处理主要有两种方案:
本地存储记录方案:通过localStorage记录切片上传的信息,包括文件标识、已上传分片索引等元数据。上传恢复时从本地存储读取状态,只上传未完成的部分。
服务端验证方案:每次在上传切片前向后台询问该切片是否已经上传。这种方式更可靠,因为服务端数据是权威来源,但需要额外的网络请求。
实际应用中,通常结合两者:使用本地存储提高效率,同时通过服务端验证确保数据准确性。
问题:如何实现暂停上传功能?
答:暂停上传功能的实现主要通过以下几种技术手段:
- 状态管理机制:维护一个全局的上传状态标志(如
isUploading
),当用户点击暂停时,将状态设为false,上传循环会检查此状态决定是否继续 - 请求中断:使用AbortController API中断正在进行的网络请求,例如:javascript
const controller = new AbortController(); const signal = controller.signal; // 上传请求 fetch(url, { method: 'PUT', body: chunk, signal }); // 暂停时中断 controller.abort();
- 任务队列控制:维护一个上传任务队列,暂停时停止从队列取出新任务,同时记录当前队列状态
- 分片状态记录:在localStorage中保存已完成分片的信息,便于恢复时跳过这些分片
- 取消Promise链:通过特殊的Promise处理模式,允许外部中断Promise.all或Promise.allSettled的执行
实际项目中,我们通常会将这些机制封装在上传类中,提供pause()和resume()方法供外部调用。
暂停后,客户端和服务端都能知道已上传多少部分,下次继续时直接跳过已上传的分片。
问题:大文件分片上传的难点有哪些?
答:大文件分片上传的主要难点包括:
- 文件分片处理:如何高效地切分文件并管理分片
- 并行上传控制:如何平衡上传速度和浏览器资源占用
- 断点续传实现:如何可靠地记录和恢复上传状态
- 秒传功能:如何基于文件特征快速判断文件是否已存在
- 错误处理与重试:如何应对上传过程中的各种异常情况
- 加密与安全:确保上传过程中的数据安全,同时保持一致的加密方法
一个特殊难点是文件特征值计算问题:当需要更换加密方法时,旧的加密值不再适用,需要处理算法迁移和兼容问题。
问题:秒传功能依赖服务端预先存储文件的MD5,但不同用户上传同一文件时,如何确保计算出的MD5一致?文件读取方式(如二进制vs文本)是否会影响结果?
答:文件的MD5值仅与文件内容
有关,与文件名、创建信息等元信息无关。MD5是对文件二进制内容的散列计算,只要文件的二进制内容完全相同,无论何时何地都会得到相同的MD5值。
不同读取方式下的一致性 在实现MD5计算时,读取方式确实可能影响结果,主要有以下几种情况: 二进制读取 vs 文本读取: 二进制读取(如ArrayBuffer、Blob)会直接处理原始字节数据 文本读取(如readAsText)会涉及字符编码转换,可能导致计算结果不一致 换行符处理: 不同操作系统的换行符不同(Windows: CRLF, Linux/Mac: LF) 如果以文本模式读取,可能会自动转换换行符,导致MD5不一致 BOM标记: UTF-8文件可能包含BOM标记,不同读取方式可能对BOM的处理不同
// 以下实现了文件读取的
// 在Web Worker中的MD5计算
reader.readAsArrayBuffer(chunks[currentChunk]);
// ...
spark.append(e.target.result); // 处理二进制数据
- 使用
readAsArrayBuffer
始终以二进制的方式读取文件 - 直接对原始二进制数据计算MD5,不进行任何字符编码转换
- 保留所有原始字节,包括可能得BOM标记和原始换行符 问题:文件如何切片,分成了什么?
答:文件切片的核心实现是使用Blob.prototype.slice方法:
const chunks = [];
const chunkSize = 2 * 1024 * 1024; // 2MB
let start = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
chunks.push({
file: chunk,
index: Math.floor(start / chunkSize)
});
start = end;
}
分片的内容是原始文件的字节流,以Blob格式存储。每个分片会附带元数据(如索引、大小等信息),便于服务端重组文件。
File.slice()方法的优势是创建原始数据的引用而非复制,内存消耗小。
问题:上传应该是有顺序的,如何保证顺序?
答:在实际实现中,分片上传不必严格按顺序进行,原因是:
- 每个分片都带有明确的索引信息(如
chunkIndex
或partNumber
) - 后端在合并时会根据索引对分片进行排序,确保最终文件的正确性
这种设计使并行上传成为可能,大大提高了上传效率。每次上传我们都会添加分片索引信息,后端根据这些索引进行正确的组装,无需客户端保证上传顺序。
问题:如何保证每一片的完整性?
答:为确保每个分片的完整性,我们采用了多层验证机制:
分片MD5校验:
- 客户端在上传每个分片前计算其MD5哈希值
- 将MD5值作为请求参数一同发送到服务端
- 服务端接收分片后重新计算MD5并与客户端提供的值比对
- 如果不匹配,则判定为传输错误,要求重新上传该分片
ETag验证:
- OSS会为每个上传的分片生成ETag值(通常基于内容的哈希)
- 客户端保存每个分片上传后返回的ETag
- 在调用CompleteMultipartUpload时提供所有分片的ETag列表
- OSS会验证这些ETag,确保所有分片都完整无损
HTTP传输层保障:
- 利用HTTP协议自带的校验机制(如Content-MD5头)
- 设置适当的超时时间,避免网络波动导致的数据截断
- 使用HTTPS协议确保传输过程中数据不被篡改
分片大小控制:
- 合理设置分片大小(通常2MB-5MB)
- 较小的分片大小可以降低单个分片传输失败的风险
- 同时减少重传时的数据量,提高恢复效率
CRC64校验(阿里云OSS特性):
- 在支持的环境中启用CRC64校验
- 上传完成后比对本地计算的CRC64值与服务端返回的值
- 提供端到端的数据完整性验证
问题:假如某个分片上传失败了,是怎么处理的?
答:对于上传失败的分片处理策略:
- 使用
Promise.allSettled
收集所有分片的上传结果,包括成功和失败的情况 - 将失败的分片索引记录在失败数组中(
uploadResults.filter(r => r.status === 'rejected').map(r => r.reason.index)
) - 针对失败的分片实现自动重试机制,设置最大重试次数和退避策略
- 如果重试后仍然失败,将这些分片信息保存,等网络恢复后继续上传
后端可以通过检查接收到的分片索引与总分片数进行比对,确定哪些分片缺失,从而请求客户端重新上传这些分片。
问题:后台如何知道是哪些分片没接收到?
答:后台确定缺失分片的方法:
- 客户端在初始化分片上传时,会调用OSS的InitiateMultipartUpload接口获取uploadId
- 服务端(OSS)会为每个分片上传任务维护状态记录,通过uploadId唯一标识
- 客户端上传分片时,会在请求中包含partNumber(分片编号)和uploadId
- 客户端可以通过调用ListParts接口,查询已经上传的分片列表
- 通过比对已上传的分片列表和本地的分片列表,可以确定哪些分片需要重新上传
- 在完成上传前,客户端需要调用CompleteMultipartUpload接口,提供所有分片的ETag信息
服务端通常会返回类似 {missing: [3, 7, 9]}
的数据结构,告知客户端哪些分片需要重新上传。
问题:怎么做并行上传?这几百个分片,如何同时上传一部分?
答:并行上传的实现主要依靠Promise.allSettled和队列管理:
控制并发数量:
- 设定最大并发上传数(默认为3个)
- 这是经过实践测试得出的较为理想的并发数,既能提高上传速度,又不会给浏览器和网络带来过大压力
批次处理机制:
- 将所有分片按批次进行处理,每批次只上传固定数量(如3个)的分片
- 使用for循环和数组切片方法(slice)获取每批次需要上传的分片
- 当前批次完成后再开始下一批次,确保任意时刻的并发请求数不超过设定值
Promise异步处理:
- 每个分片上传任务被封装为Promise对象
- 使用Promise.allSettled而非Promise.all处理当前批次的所有Promise
- 这样即使某个分片上传失败,也不会影响其他分片的上传
- 通过await等待当前批次全部完成,再进入下一批次
进度反馈机制:
- 每批次完成后更新上传进度
- 计算进度时考虑已处理的分片数与总分片数的比例
async function uploadChunksWithConcurrency(chunks, concurrency = 3) {
// 这个函数实现了分片并发上传的核心逻辑
// 参数说明:
// - chunks: 所有需要上传的文件分片数组
// - concurrency: 并发上传的数量,默认为3个
// 使用for循环按批次处理所有分片
for (let i = 0; i < chunks.length; i += concurrency) {
// 从当前位置截取concurrency个分片,形成当前批次
// slice方法不会修改原数组,而是返回一个新的数组引用
// map方法将每个分片转换为上传Promise
const uploadPromises = chunks.slice(i, i + concurrency).map(chunk =>
uploadChunk(chunk) // 调用uploadChunk函数处理单个分片上传
);
// 等待当前批次的所有分片上传完成(无论成功或失败)
// Promise.allSettled会等待所有Promise完成,不会因单个失败而中断
await Promise.allSettled(uploadPromises);
// 更新上传进度
// 计算当前已处理的分片数占总分片数的比例
// Math.min确保进度不会超过100%(最后一批可能不足concurrency个)
updateProgress(Math.min((i + concurrency) / chunks.length, 1) * 100);
}
// 函数结束后,所有分片都已尝试上传完成
}
核心要点:
- 不是一次并发上传所有分片,而是控制并发数(通常3-5个)
- 使用Promise.allSettled而非Promise.all,可以同时处理成功和失败情况
- 批次处理:每批次上传固定数量的分片,一批完成后再上传下一批
- 每个分片上传任务封装为Promise,通过数组传递给Promise.allSettled
- Promise.allSettled返回的数组,对应每个Promise的执行结果
这种方式既能利用并行提高效率,又避免了过多并发请求导致的浏览器崩溃或服务器压力过大。
完整代码解释总结
本文详细介绍了使用Web Worker和分片上传技术,结合阿里云OSS的直传能力,实现一个高效、可靠的大文件上传解决方案。我们为每段核心代码添加了详细注释,便于理解实现原理和工作流程。
技术方案总结
Web Worker计算MD5:将CPU密集型的MD5计算放在后台线程中进行,避免主线程阻塞,保持页面响应性。使用SparkMD5库支持增量计算,避免一次性加载整个文件到内存。
文件分片处理:使用File.slice()方法将大文件切分为2MB大小的片段,创建原始数据的引用而非副本,节省内存。分片处理使单次上传失败的影响降到最小。
OSS分片上传:实现了完整的OSS分片上传流程,包括初始化上传、获取签名、上传分片、报告成功状态、完成上传等步骤。通过ETag确保分片上传成功。
并发控制与重试:限制同时上传的分片数量(默认3个),避免浏览器资源耗尽。对失败的分片实现自动重试机制,提高上传成功率。使用队列管理上传任务,确保平滑上传。
断点续传功能:通过localStorage持久化存储上传状态,支持页面刷新或浏览器关闭后继续上传。上传恢复时验证服务端任务有效性,同步已上传分片信息。
秒传功能:基于文件MD5判断服务端是否已存在相同文件,实现秒传。避免重复上传,节省带宽和时间。
核心代码一览
本解决方案包含以下几个核心类:
- Web Worker (md5-worker.js):负责在后台线程中计算文件MD5。
- FileChunker:负责文件分片和MD5计算。
- OSSUploader:负责OSS分片上传、并发控制和重试。
- ResumeUploadManager:负责断点续传状态管理。
- ResumeableOSSUploader:扩展OSSUploader,添加断点续传功能。
- BigFileUploader:整合以上功能,提供统一的上传接口。
通过这些类的协同工作,实现了一个完整、高效、可靠的大文件上传解决方案。此方案适用于各种Web应用场景,特别是需要处理大文件上传的系统,如在线存储、媒体分享、企业文档管理等。
在实际应用中,还可以根据具体业务需求进行定制和扩展,例如添加文件预览、进度可视化、上传历史记录等功能,提供更完善的用户体验。
主要成果
- 流畅的用户体验:通过Web Worker将MD5计算放在后台线程,保持页面响应性
- 高效的资源利用:客户端分片处理和直传OSS,减轻服务器负担
- 可靠的上传机制:断点续传、秒传和自动重试机制提高上传成功率
- 灵活的扩展性:模块化设计使系统易于扩展和维护
未来优化方向
- 自适应分片大小:根据网络环境和文件特性动态调整分片大小
- 预上传检测:上传前检测网络状况,提供更准确的上传时间估计
- 多文件并行上传:支持多文件队列,并智能调度上传顺序
- 更多存储服务支持:扩展支持更多云存储服务,如腾讯云COS、七牛云等
- 服务端集成优化:提供更完善的服务端集成方案,简化后端实现
最终思考
大文件上传是前端开发中的一个挑战性问题,它涉及网络、存储、性能、用户体验等多个方面。通过合理利用现代Web技术,如Web Worker、Blob API、Fetch API等,结合云存储服务的能力,我们可以构建出既高效又可靠的大文件上传解决方案。
在实际应用中,我们还应当根据具体业务场景进行调整和优化,例如针对特定文件类型的处理策略、特定用户群体的网络环境适配等。只有将技术与业务紧密结合,才能真正解决用户的实际问题。
7. 完整使用示例
下面是如何集成所有组件实现一个完整的大文件上传功能:
/**
* 大文件上传管理器
* 整合文件分片、MD5计算和OSS上传功能的统一接口
*/
class BigFileUploader {
/**
* 构造函数
* @param {Object} options - 配置选项
*/
constructor(options = {}) {
// 初始化配置参数,设置默认值
this.options = {
ossHost: options.ossHost || '', // OSS服务器地址
chunkSize: options.chunkSize || 2 * 1024 * 1024, // 分片大小,默认2MB
maxConcurrency: options.maxConcurrency || 3, // 最大并发数
maxRetries: options.maxRetries || 3, // 最大重试次数
retryDelay: options.retryDelay || 1000, // 重试延迟
workerPath: options.workerPath || 'md5-worker.js', // MD5 Worker路径
storageKeyPrefix: options.storageKeyPrefix || 'oss_upload_' // 本地存储键前缀
};
// 内部状态变量
this.file = null; // 当前处理的文件
this.fileChunker = null; // 文件分片器实例
this.ossUploader = null; // OSS上传器实例
this.uploading = false; // 是否正在上传
this.progress = { md5: 0, upload: 0 }; // 进度信息,分别记录MD5计算和上传的进度
// 注册回调函数
this.onProgress = options.onProgress || null; // 进度回调
this.onSuccess = options.onSuccess || null; // 成功回调
this.onError = options.onError || null; // 错误回调
this.onCancel = options.onCancel || null; // 取消回调
}
/**
* 上传文件
* 上传流程的主控制函数
* @param {File} file - 要上传的文件对象
* @returns {Promise} 上传结果Promise
*/
async upload(file) {
// 检查是否已有上传任务在进行
if (this.uploading) {
throw new Error('已有上传任务正在进行');
}
// 标记开始上传
this.uploading = true;
this.file = file;
// 创建文件分片器实例
this.fileChunker = new FileChunker(file, {
chunkSize: this.options.chunkSize,
workerPath: this.options.workerPath
});
// 创建OSS上传器实例,使用支持断点续传的版本
this.ossUploader = new ResumeableOSSUploader({
ossHost: this.options.ossHost,
maxConcurrency: this.options.maxConcurrency,
maxRetries: this.options.maxRetries,
retryDelay: this.options.retryDelay,
storageKeyPrefix: this.options.storageKeyPrefix
});
try {
// 注册进度回调
this.registerProgressCallbacks();
// 步骤1: 计算文件MD5
console.log('开始计算文件MD5...');
const fileMd5 = await this.fileChunker.calculateMD5();
console.log('文件MD5计算完成:', fileMd5);
// 步骤2: 获取分片信息
const chunksInfo = this.fileChunker.getChunksInfo();
console.log('文件分片信息:', {
totalChunks: chunksInfo.totalChunks,
chunkSize: chunksInfo.chunkSize,
fileSize: chunksInfo.fileSize
});
// 步骤3: 上传文件
console.log('开始上传文件...');
const uploadResult = await this.ossUploader.uploadFile({
chunks: chunksInfo.chunks,
fileName: file.name,
fileMd5: fileMd5,
fileSize: file.size
});
console.log('文件上传完成:', uploadResult);
// 上传成功回调
if (typeof this.onSuccess === 'function') {
this.onSuccess({
fileName: file.name,
fileSize: file.size,
fileMd5: fileMd5,
url: uploadResult.url,
isQuickUpload: uploadResult.isQuickUpload
});
}
// 重置状态
this.reset();
return uploadResult;
} catch (error) {
console.error('文件上传失败:', error);
// 错误回调
if (typeof this.onError === 'function') {
this.onError(error);
}
// 重置状态
this.reset();
throw error; // 重新抛出错误,让调用者处理
}
}
/**
* 取消上传
* 中断当前上传任务
*/
cancel() {
if (this.uploading) {
// 销毁文件分片器
if (this.fileChunker) {
this.fileChunker.destroy();
}
// 取消OSS上传
if (this.ossUploader) {
this.ossUploader.cancelUpload();
}
// 触发取消回调
if (typeof this.onCancel === 'function') {
this.onCancel();
}
// 重置状态
this.reset();
}
}
/**
* 重置状态
* 清除内部状态,准备下一次上传
*/
reset() {
this.uploading = false;
this.progress = { md5: 0, upload: 0 };
this.file = null;
this.fileChunker = null;
this.ossUploader = null;
}
/**
* 注册进度回调
* 连接各组件的进度事件
*/
registerProgressCallbacks() {
// 注册MD5计算进度回调
this.fileChunker.onProgress = (percent) => {
this.progress.md5 = percent;
this.updateTotalProgress();
};
// 注册上传进度回调
this.ossUploader.onProgress = (percent) => {
this.progress.upload = percent;
this.updateTotalProgress();
};
// 注册分片上传成功回调
this.ossUploader.onChunkSuccess = (info) => {
console.log(`分片上传成功: ${info.chunkIndex}`);
};
// 注册分片上传失败回调
this.ossUploader.onChunkError = (info) => {
console.warn(`分片上传失败: ${info.chunkIndex}, 重试: ${info.willRetry}, 重试次数: ${info.retryCount}`);
};
}
/**
* 更新总体进度
* 计算并触发整体进度回调
*/
updateTotalProgress() {
// 计算总体进度,MD5计算占20%,上传占80%
const totalPercent = Math.floor(this.progress.md5 * 0.2 + this.progress.upload * 0.8);
// 触发进度回调
if (typeof this.onProgress === 'function') {
this.onProgress({
md5Percent: this.progress.md5, // MD5计算进度
uploadPercent: this.progress.upload, // 上传进度
totalPercent: totalPercent // 总体进度
});
}
}
}
HTML页面集成示例
下面是如何在网页中使用大文件上传器:
// 创建上传器实例
const uploader = new BigFileUploader({
ossHost: 'https://api.example.com',
// 进度回调:更新进度条
onProgress: (progress) => {
console.log(`上传进度: ${progress.totalPercent}%`);
// 更新DOM中的进度条
document.getElementById('progress-bar').style.width = `${progress.totalPercent}%`;
document.getElementById('progress-text').textContent = `${progress.totalPercent}%`;
// 分别显示MD5计算和上传进度
document.getElementById('md5-progress').textContent = `MD5计算: ${progress.md5Percent}%`;
document.getElementById('upload-progress').textContent = `上传: ${progress.uploadPercent}%`;
},
// 成功回调:显示上传结果
onSuccess: (result) => {
console.log('上传成功:', result);
// 根据是否秒传显示不同提示
if (result.isQuickUpload) {
document.getElementById('upload-message').textContent = '文件秒传成功!';
} else {
document.getElementById('upload-message').textContent = '文件上传完成!';
}
// 显示文件链接
const fileLink = document.getElementById('file-url');
fileLink.href = result.url;
fileLink.textContent = result.fileName;
fileLink.style.display = 'inline-block';
// 启用重新上传按钮
document.getElementById('file-input').disabled = false;
document.getElementById('cancel-button').disabled = true;
},
// 错误回调:显示错误信息
onError: (error) => {
console.error('上传失败:', error);
document.getElementById('upload-message').textContent = `上传失败: ${error.message}`;
document.getElementById('upload-message').classList.add('error');
// 启用重新上传按钮
document.getElementById('file-input').disabled = false;
document.getElementById('cancel-button').disabled = true;
},
// 取消回调:重置UI状态
onCancel: () => {
document.getElementById('upload-message').textContent = '上传已取消';
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('progress-text').textContent = '';
// 启用重新上传按钮
document.getElementById('file-input').disabled = false;
document.getElementById('cancel-button').disabled = true;
}
});
// 绑定文件选择器change事件
document.getElementById('file-input').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (file) {
try {
// 准备UI
document.getElementById('upload-message').textContent = '正在上传...';
document.getElementById('upload-message').classList.remove('error');
document.getElementById('file-url').style.display = 'none';
document.getElementById('file-input').disabled = true;
document.getElementById('cancel-button').disabled = false;
// 开始上传文件
await uploader.upload(file);
} catch (error) {
// 错误已在onError回调中处理
console.error('上传过程中发生错误:', error);
}
}
});
// 绑定取消按钮点击事件
document.getElementById('cancel-button').addEventListener('click', () => {
uploader.cancel();
});
对应的HTML结构:
<div class="upload-container">
<h2>OSS大文件上传示例</h2>
<div class="upload-form">
<input type="file" id="file-input" />
<button id="cancel-button" disabled>取消上传</button>
</div>
<div class="progress-container">
<div class="progress-bar-container">
<div id="progress-bar" class="progress-bar"></div>
<span id="progress-text" class="progress-text"></span>
</div>
<div class="progress-details">
<div id="md5-progress" class="progress-item">MD5计算: 0%</div>
<div id="upload-progress" class="progress-item">上传: 0%</div>
</div>
</div>
<div id="upload-message" class="upload-message"></div>
<div class="file-result">
<span>上传成功的文件:</span>
<a id="file-url" href="#" target="_blank" style="display:none"></a>
</div>
</div>
配套的CSS样式: 略