一:整体流程
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) {
// 全部读取完成,计算文件Hash
fileHash = 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;
}
}