一:整体流程
1.1. 核心流程图解
1.2. 关键实现步骤
- 文件分片与哈希计算
- 将大文件切割为多个固定大小的块(如 1MB/片)
- 使用 SparkMD5 计算文件唯一指纹
- 断点续传实现
- 通过文件哈希查询服务器已上传分片
- 仅上传缺失的分片
- 并发控制策略
- 使用 Promise 实现请求队列
- 限制最大并发数(默认 3 个并行请求)
- 进度同步机制
- 基于已上传分片数计算整体进度
- 通过回调函数实时更新进度
二:核心代码实现解析
2.1. 文件分片与哈希计算
/*** 将目标文件分片 并 计算文件Hash* @param {File} targetFile 目标上传文件* @param {number} baseChunkSize 上传分块大小,单位Mb* @returns {chunkList:ArrayBuffer,fileHash:string}*/
async function sliceFile(targetFile, baseChunkSize = 1) {return new Promise((resolve, reject) => {// 初始化分片方法let blobSlice =File.prototype.slice ||File.prototype.mozSlice ||File.prototype.webkitSlice;let chunkSize = baseChunkSize * 1024 * 1024;// 分片数let targetChunkCount = targetFile && Math.ceil(targetFile.size / chunkSize);// 当前已执行分片数let currentChunkCount = 0;// 当前已收集分片数let chunkList = [];let spark = new SparkMD5.ArrayBuffer();let fileReader = new FileReader();let fileHash = null;// 检查文件是否存在if (!targetFile) {return reject(new Error('文件不存在'));}fileReader.onload = e => {const curChunk = e.target.result;// 将当前分快追加到 spark 对象中spark.append(curChunk);currentChunkCount++;chunkList.push(curChunk);// 判断分快是否全部读取成功if (currentChunkCount >= targetChunkCount) {// 全部读取完成,计算文件HashfileHash = spark.end();resolve({chunkList,fileHash});} else {loadNext();}};fileReader.onerror = () => reject(new Error('文件读取失败'));// 未全部读取完成,读取下一个分快const loadNext = () => {// 计算分快的起始位置和终止位置const start = chunkSize * currentChunkCount;const end = start + chunkSize;if (end > targetFile.size) end = targetFile.size;// 读取文件,触发 fileReader.onload 事件fileReader.readAsArrayBuffer(blobSlice.call(targetFile, start, end));};loadNext();});
}
关键点说明:
- 使用 File.slice() 实现文件切割
- 分片大小建议根据实际场景调整(视频文件可适当增大)
- MD5 计算全程增量更新,避免内存溢出
2.2. 分片上传队列控制
/*** 将文件分片数据发送到服务器* @param {Array<Object>} postFormData 包含 FormData 的对象数组,由 uploadChunk 函数生成* @param {number} limit 并发上传的最大数量,默认为 3* @param {string} uploadUrl 上传文件的服务器地址* @returns {Promise<boolean>} 上传成功返回 true* @throws {Error} 上传失败抛出错误* * 实现了以下功能:* 1. 控制并发上传数量* 2. 失败自动重试,最多重试 3 次* 3. 跟踪上传进度* 4. 所有分片上传完成后返回结果*/
function postToServer(postFormData, limit = 3, uploadUrl) {return new Promise((resolve, reject) => {if (!postFormData || !postFormData.length) {resolve(true);return;}if (!uploadUrl) {reject(new Error('上传URL不能为空'));return;}let len = postFormData.length;let counter = 0;let isStop = false;const startPost = async () => {// 检查是否还有数据需要上传if (postFormData.length === 0 || isStop) {return;}const formDatas = postFormData.shift();if (!formDatas) return;try {await requestInstance.post(uploadUrl, formDatas.formData);counter++;formDatas.progress = Math.ceil(counter / len * 100);// 所有请求都已结束,返回结果if (counter === len) {resolve(true);return;}// 请求还未结束,继续启动任务startPost();} catch (error) {if (formDatas.error >= 3) {isStop = true;reject(new Error('上传失败,已重试3次'));return;}formDatas.error++;// 将错误的内容放到数据列表中,然后立马重试postFormData.unshift(formDatas);startPost();}};// 限制并发数,启动上传任务const actualLimit = Math.min(limit, postFormData.length);for (let index = 0; index < actualLimit; index++) {startPost();}});
}
并发控制要点:
- 使用计数器控制最大并行数
- 任务队列动态管理
- 错误自动重试机制(默认最多重试3次)
2.3. 断点续传实现逻辑
/*** @param {File} file 目标上传文件* @param {number} baseChunkSize 上传分块大小,单位Mb* @param {string} uploadUrl 上传文件的后端接口地址* @param {string} vertifyUrl 验证文件上传的接口地址* @param {string} mergeUrl 请求进行文件合并的接口地址* @param {Function} progress_cb 更新上传进度的回调函数* @returns {Promise}*/
async function uploadFile(file,baseChunkSize,uploadUrl,vertifyUrl,mergeUrl,progress_cb
) {try {if (!file) {throw new Error('文件不能为空');}if (!progress_cb || typeof progress_cb !== 'function') {progress_cb = progress => {console.log(`上传进度: ${progress}%`);};}const { chunkList, fileHash } = await sliceFile(file, baseChunkSize);let allChunkList = chunkList;// 需要上传的分片let neededFileList = [...allChunkList]; // 默认所有分片都需要上传let progress = 0;if (vertifyUrl) {try {const { data } = await requestInstance.post(vertifyUrl, {fileHash,totalCount: allChunkList.length,extname: '.' + file.name.split('.').pop()});const { needFileList, message } = data;if (message) console.info(message);// 无待上传文件,秒传if (needFileList && needFileList.length === 0) {progress_cb(100);return { success: true, message: '文件秒传成功' };}// 部分上传成功,更新需要上传的分片if (needFileList) {neededFileList = needFileList;}} catch (error) {console.error('验证文件上传状态失败:', error);// 验证失败时继续上传所有分片}}// 断点续传// 同步上传进度progress =(allChunkList.length - neededFileList.length) / allChunkList.length * 100;progress_cb(progress);// 上传if (neededFileList.length) {const postFormData = uploadChunk(neededFileList, fileHash);await postToServer(postFormData, 3, uploadUrl);// 发送请求,通知后端合并const extname = '.' + file.name.split('.').pop();try {await requestInstance.post(mergeUrl, { fileHash, extname });} catch (error) {console.error('文件合并请求失败:', error);throw new Error('文件合并失败');}// 上传完成后更新进度为100%progress_cb(100);return { success: true, message: '文件上传成功' };}return { success: true, message: '文件上传完成' };} catch (error) {console.error('文件上传过程中发生错误:', error);progress_cb(0); // 重置进度throw error;}
}