一、单文件直传方案的局限性
传统的单文件直传方案,是指前端获取到文件对象后,直接通过 FormData 将整个文件用一个上传请求发送给服务器。但这种方案只适用于小文件上传,在面对GB级别的大文件时,就存在诸多的局限性,比如:
上传时间长: 单次请求的数据量过大,请求需要较长时间才能完成,可能会触发浏览器或服务器的超时机制。
服务端压力大: 若服务端未采取流式处理,将文件加载至内存中,则可能造成内存占用过高,容易卡顿或崩溃。
用户体验差: 需要长时间等待请求完成,且一旦遭遇网络波动或其他问题导致请求失败,需要重传整个大文件。
二、分片上传方案
1、简介
分片上传是专门针对大文件上传场景优化的一种上传方案,是指前端获取到文件对象之后,通过前端将大文件进行分割成多个文件片段,然后单个分片独立上传,所有分片分批次并发上传至后端;后端接收分片的上传请求并临时存储分片,待所有分片上传完成后,再按序进行合并,得到完整的大文件。分片上传极大地提升了大文件上传的稳定性与效率。
核心思路:
分片: 前端将大文件按固定大小分割为文件片段,并设置分片编号以及其他相关信息。
标识: 为文件生成唯一标识,通常是基于文件内容计算哈希值,服务端根据该标识区分不同文件。
上传: 文件片段分批次进行并发上传,后端一一接收并临时存储文件片段。
合并: 所有文件片段上传完成后,前端通知后端,后端根据分片编号按序合并所有文件片段,得到完整大文件。
核心功能:
文件分片: 将大文件按固定大小分割为多个小文件片段。
文件秒传: 如果服务器已存在当前文件或存在文件的所有分片,则直接上传成功,无需重复上传。
断点续传: 如果服务器只存在当前文件的部分分片,则只需上传服务器不存在的分片,无需从头开始上传。
并发上传: 分批次并发进行多个上传请求,缩短整体上传时间。
进度展示: 实时计算并展示文件上传进度,提升用户体验。
失败重试: 如果有部分分片上传失败,则针对失败的分片进行局部重传。
取消上传: 上传过程中,可随时取消上传,终止上传流程。
2、优缺点
优点:
总上传时间短: 所有分片分批次并发上传,相对一次直传,可显著缩短总上传时间。
服务器压力小: 将大文件分为多个分片上传请求发送,单次请求处理数据量小,服务器压力小。
上传稳定性更好: 单个分片上传失败不会影响其他分片,且有针对上传失败分片的局部重传机制。
缺点:
逻辑复杂度高: 前后端都需要较多的逻辑处理,前端涉及文件切割、哈希计算、并发控制等逻辑;后端涉及分片存储、分片合并等逻辑。
性能消耗: 需要对文件内容进行哈希计算,需要消耗一定的CPU资源。
3、相关知识
① File.slice()
File 对象是一种特定类型的 Blob,继承了 Blob 的属性和方法,其中就包括 slice() 方法。File对象调用该方法,可以根据参数创建并返回一个新的 File 对象,其中包含调用对象中指定数据的拷贝,且不会影响调用对象。
具体语法:
File.slice([start[, end[, contentType]]]),其中:
start(可选参数):表示从 File 对象的哪个字节(索引)开始拷贝,参数值为数值类型,默认值为0。如果值为正数,则是从数据的开头从前往后计算位置;如果值为负数,则是从数据的末尾从后往前计算位置;如果值大于 File 对象的 size,则会直接返回一个长度为0,不包含任何数据的空 File对象。
end(可选参数):表示到 File 对象的哪个字节(索引)之前结束拷贝,参数值为数值类型,默认值为 File.size(索引是从0开始,到File.size-1结束)。如果值为正数,则是从数据的开头从前往后计算位置;如果值为负数,则是从数据的末尾从后往前计算位置。
contentType(可选参数):表示创建的新 File 对象的内容类型,即 File.type 字段,默认值为空。
② spark-md5.js
Spark MD5 是一个轻量级的 JavaScript 库,可以高效的计算文件的 MD5 哈希值,其支持分块读取文件,然后逐块计算文件的 MD5 哈希值,占用内存相对较少,尤其适用于大文件的分片上传场景。
该库可通过NPM或CDN进行引用。
相关语法:
// 创建SparkMD5对象const spark = new SparkMD5.ArrayBuffer();
// 逐块传入文件内容 进行计算spark.append('******');spark.append('******');
// 文件读取完成 返回文件所有内容的 MD5哈希值spark.end()更多内容可查看:https://www.npmjs.com/package/spark-md5 。
③ FileReader.readAsArrayBuffer()
FileReader.readAsArrayBuffer() 方法用于读取指定 Blob/File 对象的内容,读取成功后会触发onload事件,事件对象中的 e.target.result 表示读取的文件内容,结果为一个表示文件数据的 ArrayBuffer 数据。
相关语法:
// 创建FileReader对象const fileReader = new FileReader();
// 文件内容读取成功fileReader.onload = (e) => {
console.log('文件读取成功,读取到的文件内容:', e.target.result);};
// 文件读取错误处理fileReader.onerror = (e) => {
console.error('文件读取失败:', e);};
// 读取文件内容fileReader.readAsArrayBuffer(file);④ Promise.allSettled()
Promise.allSettled() 方法是ES11引入的 Promise 并发处理方法,用于并行执行多个Promise并完整捕获所有的执行结果(无论成功或失败),参数接收一个由Promise对象组成的可迭代对象(主要为Array、Set 等),并返回一个Promise。
Promise.allSettled(iterable) ->Promise<Object[]>参数中单个Promise的成功或失败都不会影响其他Promise的执行,只有当所有Promise对象全部都执行结束进入 "已完成" (fulfilled 或 rejected)状态时,Promise.allSettled() 返回值的Promise才会进入 "已完成" (恒定为"fulfilled")状态,并以结果对象数组的形式按序返回所有Promise的执行结果,结果对象的属性如下:
status:状态字符串,表示对应Promise的执行装填是成功还是失败,值可能为 fulfilled 或 rejected。
value:成功结果,表示对应Promise的执行成功结果,只有当
status="fulfilled"时才存在。reason:失败原因,表示对应Promise的执行失败原因,只有当
status="rejected"时才存在。
三、前端核心代码实现
1、工具库引用
前端主要借助的工具库是:spark-md5 和 axios,分别用于计算文件的md5值和发送上传请求。由于示例代码是在HTML中实现的,所以此处使用的是cdn引入。
<!-- spark-md5 用于计算文件的md5值 -->
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js">
</script><!-- axios 用于发送请求 -->
<script src="https://cdn.jsdelivr.net/npm/axios@1.4.0/dist/axios.min.js"></script>2、基础 DOM 和 JS
基础DOM结构:
<input type="file" id="fileInput" accept="*" /> <button id="uploadBtn">开始上传</button> <button id="cancelBtn">取消上传</button> <div id="progress"></div>基础JS逻辑:
获取DOM元素,绑定文件选择、文件上传等相关事件处理函数。
声明全局变量和常量,存储选择的文件、上传标记、上传进度等相关信息,以及相关的重置状态方法。
通过 <input type="file"> 来调起文件选择框,选择要上传文件,并对文件进行校验。校验通过后,点击上传文件按钮,根据文件大小决定使用单文件直传方案还是分片上传方案。
// 获取DOM元素
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const cancelBtn = document.getElementById('cancelBtn');
const progressDom = document.getElementById('progress');
// 存储选中的文件
let selectedFile = null;
// 是否有正在上传的文件
let isUploading = false;
// 存储文件各个分片的上传进度
const chunkProgress = new Map();
// 是否取消上传
let isCancelUpload = false;
// 上传请求的controller数组(用于取消上传请求)
const controllerArr = [];
// 分片上传的最大并发请求数量
const MAX_CONCURRENCY = 5;
// 分片上传的错误重试机制-最大重试次数
const MAX_RETRY_COUNT = 3;
// 分片上传的错误重试机制-重试间隔(毫秒)
const RETRY_DELAY = 1000;
// 重置相关状态
function resetUploadState() {
// 重置文件选择
fileInput.value = '';
// 重置选中文件
selectedFile = null;
// 重置正在上传的文件标志
isUploading = false;
// 重置上传进度
chunkProgress.clear();
// 重置取消上传标志
isCancelUpload = false;
// 重置controller集合
controllerSet.clear();
}
// 监听文件选择
fileInput.addEventListener('change', (e) => {
// 校验是否正在上传文件
if (isUploading) {
alert('请先等待当前文件上传结束');
// 重置文件选择
fileInput.value = '';
return;
}
// 获取选中的文件
const file = e.target.files[0];
// 校验文件是否符合要求(文件大小、文件类型等)
if (checkFile(file)) {
// 符合要求 则记录选中文件
selectedFile = file;
} else {
// 不符合要求 则提示
alert('文件不符合要求,请重新选择');
// 重置文件选择
fileInput.value = '';
}
});
// 校验文件是否符合要求(文件大小、文件类型等)
function checkFile(file) {
// 文件大小校验
if (file.size > 1024 * 1024 * 1024 * 5) {
alert('文件大小不能超过5GB');
return false;
}
// 文件类型限制等其他限制(自定义)
// 如果符合要求 则返回true
return true;
}
// 监听上传按钮点击
uploadBtn.addEventListener('click', () => {
// 校验是否选择了文件
if (!selectedFile) {
alert('请先选择文件');
return;
}
// 校验是否正在上传文件
if (isUploading) {
alert('当前存在正在上传的文件,请等待...');
return;
}
if (confirm(`确定要上传【${selectedFile.name}】文件吗?`)) {
// 根据文件大小 判断是采用分片上传还是普通上传
// 500MB以上使用分片上传
if (selectedFile.size > 1024 * 1024 * 500) {
// 分片上传
uploadFileByChunk();
} else {
// 普通上传
uploadFile();
}
}
});3、单文件直传
如果上传文件不属于大文件(size <= 500MB),则无需分片上传,采用单文件一次直传即可。
注意点:
要支持取消上传请求功能,通过 controller 实现。
要支持实时展示上传进度,通过 onUploadProgress 实现。
/**
* 普通文件上传(小文件)
*/
async function uploadFile() {
try {
// 设置正在上传的文件标志
isUploading = true;
// 更新上传进度显示
progressDom.textContent = '正在上传...';
// 构建FormData
const formData = new FormData();
formData.append('file', selectedFile);
// 创建当前请求的controller
const controller = new AbortController();
// 将controller添加到controller集合中
controllerSet.add(controller);
// 发送文件上传请求
const response = await axios.post('/api/upload', formData, {
onUploadProgress: (progressEvent) => {
// 计算上传进度
const progress = Math.floor((progressEvent.loaded / progressEvent.total) * 100); progressDom.textContent = `上传进度:${progress}%`;
},
signal: controller.signal // 绑定控制器用于取消上传请求
});
// 请求完成后从控制器集合中移除当前控制器
controllerSet.delete(controller);
console.log('文件上传完成:', response.data);
progressDom.textContent = '上传完成!';
} catch (error) {
// 如果是取消操作引起的错误,不当作失败处理
if (error.name === 'AbortError') {
console.log('文件上传被取消');
} else {
console.error('文件上传失败:', error);
progressDom.textContent = '上传失败,请重试!';
}
} finally {
// 重置上传相关的状态
resetUploadState();
}
}4、分片上传
如果上传文件属于大文件(size > 500MB),则采用分片上传方案。在分片上传总函数中依次调用文件分片、计算哈希值、上传分片等函数,实现文件分片上传和错误处理逻辑。
/**
* 分片上传总函数
*/
async function uploadFileByChunk() {
try {
// 设置正在上传的文件标志
isUploading = true;
// 更新上传进度显示
progressDom.textContent = '正在进行上传前处理...';
// 1. 文件分片
const allChunks = splitFileIntoChunks(selectedFile);
console.log(`文件分割完成,共${allChunks.length}个分片`);
// 2. 计算文件的MD5哈希值
const fileHash = await calculateFileHash(selectedFile);
console.log('文件的MD5哈希值:', fileHash);
// 3. 上传分片
// 更新上传进度显示
progressDom.textContent = '开始上传...';
const result = await uploadChunks(fileHash, allChunks);
console.log('上传分片成功的结果:', result);
// 4. 检查上传结果 进行失败重传或合并分片
await checkAndMergeChunks(allChunks, fileHash);
} catch (error) {
// 如果是取消操作引起的错误,不当作失败处理
if (error.name === 'AbortError' || error.name === 'CancelError') {
console.log('分片上传被取消');
} else {
console.error('分片上传过程出错:', error);
progressDom.textContent = '上传失败,请重试!';
}
} finally {
// 重置上传相关的状态
resetUploadState();
}
}5、文件分片
根据实际业务场景,设置合适的分片大小。然后循环通过 File.slice() 方法对大文件进行切割,分割为一个个小文件块(分片),并将分片和相关信息存储到分片数组中,为后续上传做准备。
注意点:
要将分片数据、分片索引等相关信息一并存储,方便区分不同分片。
切割到最后一个分片时,其实际大小可能小于设置的分片大小,因此在切割时需要注意最后的边界处理。
/**
* 分割文件为分片
* @param {File} file - 待分割的文件
* @param {number} chunkSize - 分片大小(字节)
* @returns {Array<Object>} - 分片后的文件片段数组,包含索引信息
*/
function splitFileIntoChunks(file, chunkSize = 5 * 1024 * 1024) {
// 存储分割后的文件片段
const chunks = [];
// 文件切片起始位置
let start = 0;
// 分片索引
let index = 0;
// 循环进行切片
while (start < file.size) {
// 根据起始位置和分片大小 计算切片结束位置(最后一片可能小于chunkSize)
const end = Math.min(start + chunkSize, file.size);
// 利用 slice 进行切片
const chunk = file.slice(start, end);
// 将切片和相关信息一起存储到chunks数组中
chunks.push({
data: chunk, // 分片数据
index: index, // 分片索引
size: chunk.size, // 分片大小
start: start, // 起始位置
end: end // 结束位置
});
// 更新下个切片的起始位置和索引
start = end;
index++;
}
// 返回分割后的文件片段数组
return chunks;
}4、文件标识
需要根据文件内容计算文件标识,此处是通过 SparkMD5 计算文件内容的哈希值,并将其作为文件的唯一标识。服务端根据该标识区分文件和识别重复上传,并且在分片上传时,需要通过该标识找到对应的分片临时存储目录。
注意点:
采用 Promise 封装异步操作,便于与其他上传流程(分片切割、分片上传、合并等)进行串联。
如果直接计算大文件的哈希值,可能需要较长时间。为避免长时间占用主线程,逐块读取文件内容,逐块计算的方案。
当前分块读取完成后,利用 setTimeout() 和事件循环机制,将下个分块的读取函数变为宏任务,让出主线程,避免阻塞UI,同时让取消操作生效。
在文件读取成功和下个分块读取开始前,进行取消上传校验。
/**
* 计算文件的MD5哈希值
* @param {File} file - 待计算的文件
* @returns {Promise<string>} - 文件哈希值
*/
function calculateFileHash(file) {
// 返回一个Promise对象 设置为异步操作
return new Promise((resolve, reject) => {
// 分块计算哈希值
// 自定义每次读取分块的大小
const chunkSize = 5 * 1024 * 1024;
// 计算分块数量
const chunks = Math.ceil(file.size / chunkSize);
// 当前分块索引
let currentChunk = 0;
// 创建SparkMD5对象
const spark = new SparkMD5.ArrayBuffer();
// 创建FileReader对象 分块读取文件内容
const fileReader = new FileReader();
// 文件读取错误处理
fileReader.onerror = (e) => {
console.error('文件读取失败:', e);
reject(e);
};
// 当前分块内容读取成功
fileReader.onload = (e) => {
// 检查是否取消上传(传入reject函数用于抛出取消错误)
checkCancellation(reject);
console.log('读取到的文件内容:', e.target.result);
// 将读取到的文件内容添加到SparkMD5对象中 计算哈希值
spark.append(e.target.result);
// 更新当前分块索引
currentChunk++;
// 如果还有未读取的分块 则继续读取
if (currentChunk < chunks) {
// 使用 setTimeout 让出主线程,避免阻塞UI,同时让取消操作生效(事件循环机制)
setTimeout(() => loadNextChunk(), 0);
} else {
// 如果所有分块都已读取 则返回最终整个文件的哈希值
resolve(spark.end());
}
};
// 读取下个分块
function loadNextChunk() {
// 检查是否取消上传(传入reject函数用于抛出取消错误)
checkCancellation(reject);
// 根据当前分块索引和分块大小 计算下个分块的起始位置
const start = currentChunk * chunkSize;
// 计算下个分块的结束位置(最后一个分块可能小于chunkSize)
const end = Math.min(start + chunkSize, file.size);
// file.slice获取对应分块数据 fileReader.readAsArrayBuffer读取对应分块内容
fileReader.readAsArrayBuffer(file.slice(start, end));
}
// 初始调用 读取第一个分块
loadNextChunk();
});
}补充:
在计算文件哈希值阶段,可以通过一些手段缩短时间,比如:不读取整个文件内容计算哈希值,只抽样读取部分片段(例如:首尾分片全量 + 中间分片抽样)计算哈希值,从而大幅提升计算速度。但这种手段,存在风险,如果文件修改了未被抽样的部分,则最终计算的哈希值与修改前相同,从而导致数据错误、上传异常等问题。因此并不推荐使用。
而上面使用全量计算虽然计算时间长一些,但能保证数据的准确性和结果的可靠性。
5、并发上传
将文件分片分批次并发上传至服务器,支持秒传、断点续传等功能。
注意点:
获取服务器是否存在当前文件或当前文件的分片。如果服务器已存在当前文件或存在当前文件的所有分片,则无需重复上传,直接上传成功(秒传)。
如果服务器只存在当前文件的部分分片,则对当前分片数组进行过滤,只保留服务器缺失的文件分片,实现断点续传。
分批次并发上传分片,构建上传请求,并通过 Promise.allSettled() 等待当前批次的并发请求全部完成,获取上传结果。
限制并发上传的数量,避免同时间请求过多导致浏览器和服务器压力过大。
针对失败的上传请求,如果是取消请求错误,则抛出错误,中断上传流程;如果其他错误,则记录到失败结果中,但不中断流程。
在每批次的分片并发上传前,进行取消上传校验。
分片上传总函数:
/**
* 分片上传
* @param {string} fileHash - 文件哈希值
* @param {Array<Object>} chunks - 分片数组
* @returns {Array<any>} - 上传结果
*/
async function uploadChunks(fileHash, chunks) {
// 1. 检查服务器存在当前文件的哪些分片(秒传、断点续传核心)
const uploadedChunkIndexes = await getUploadedChunks(fileHash);
console.log('已上传分片索引列表:', uploadedChunkIndexes);
// 2. 过滤掉服务器已经存在的分片,只保留未上传的分片(断点续传)
const chunksToUpload = chunks.filter(chunk => !uploadedChunkIndexes.includes(chunk.index));
console.log(`过滤结束后,还需要上传 ${chunksToUpload.length} 个分片`);
// 3. 初始化上传进度 将服务器已存在的分片设置为100%
chunks.forEach(chunk => {
if (uploadedChunkIndexes.includes(chunk.index)) {
chunkProgress.set(chunk.index, 1); // 已上传的分片进度为100%
} else {
chunkProgress.set(chunk.index, 0); // 未上传的分片进度为0%
}
});
// 更新上传进度显示
updateTotalProgress();
// 4. 如果所有分片都已上传,则直接上传成功(秒传)
if (chunksToUpload.length === 0) {
console.log('所有分片都已上传,无需再次上传');
return [];
}
// 上传结果数组
const results = {
success: [], // 上传成功的分片结果
failed: [] // 上传失败的分片结果
};
// 5. 并发上传分片 并限制最大并发上传数量 避免同时过多请求造成服务器压力
for (let i = 0; i < chunksToUpload.length; i += MAX_CONCURRENCY) {
// 检查是否取消上传
checkCancellation();
// 获取当前批次要上传的分片
const batchChunks = chunksToUpload.slice(i, i + MAX_CONCURRENCY);
// 为当前批次的分片 发起并发上传请求 并返回Promise数组
const batchPromises = batchChunks.map(chunk => {
return uploadSingleChunk(fileHash, chunk, chunks.length);
});
// 利用allSettled()方法等待当前批次的并发请求全部完成(无论请求成功还是失败 都会返回对应结果)
const batchResults = await Promise.allSettled(batchPromises);
// 处理当前批次上传结果
batchResults.forEach((res, index) => {
// 如果结果是上传成功 则将结果放到结果数组中
if (res.status === 'fulfilled') {
results.success.push(res.value);
} else {
// 检查是否是取消操作的错误,如果是则立即抛出,中断整个上传流程
if (res.reason && res.reason.name === 'AbortError') {
throw res.reason;
}
// 如果是其他上传错误,记录到失败结果中,但不中断流程
// 这些失败的分片会在后续的重试机制中处理
results.failed.push(res.reason);
}
});
}
// 6. 所有分片上传结束后 返回所有上传结果(包括成功和失败)
return results;
}检查服务器上存在的分片:
/**
* 获取当前文件的哪些分片已经上传至服务端
* @param {string} fileHash - 文件哈希值
* @returns {Array<number>} - 已上传的分片索引数组
*/
async function getUploadedChunks(fileHash) {
// 创建当前请求的controller
const controller = new AbortController();
// 将controller添加到controller集合中
controllerSet.add(controller);
const res = await axios.get('/api/check-chunks', {
params: { fileHash },
signal: controller.signal // 绑定控制器用于取消上传请求
});
// 请求完成后从控制器集合中移除当前控制器
controllerSet.delete(controller);
// 返回已上传的分片索引数组
return res.data || [];
}构建单个分片上传请求:
/**
* 单个分片上传
* @param {string} fileHash - 文件哈希值
* @param {Object} chunk - 分片对象
* @param {number} totalChunks - 总分片数
* @param {number} retryCount - 当前重试次数
* @returns {Promise} - 上传Promise
*/
async function uploadSingleChunk(fileHash, chunk, totalChunks) {
try {
// 构建FormData
const formData = new FormData();
// 添加文件哈希值 用于判断分片属于哪个文件
formData.append('fileHash', fileHash);
// 添加当前分片索引 用于区分分片
formData.append('chunkIndex', chunk.index);
// 添加当前分片数据
formData.append('chunk', chunk.data);
// 添加总分片数
formData.append('totalChunks', totalChunks);
// 创建当前请求的controller
const controller = new AbortController();
// 将controller添加到controller集合中
controllerSet.add(controller);
// 单个分片的上传Promise
const res = await axios.post('/api/upload-chunk', formData, {
timeout: 60000, // 60秒超时(可自定义)
onUploadProgress: (progressEvent) => {
// 计算单个分片的上传进度
const progress = progressEvent.loaded / progressEvent.total;
// 更新单个分片的上传进度
chunkProgress.set(chunk.index, progress);
// 更新总进度显示
updateTotalProgress();
},
signal: controller.signal // 绑定控制器用于取消上传请求
});
// 请求完成后从控制器集合中移除当前控制器
controllerSet.delete(controller);
// 返回单个分片上传结果
return res.data;
} catch (error) {
if (error.name === 'AbortError') {
console.log(`第${chunk.index}个分片上传被取消(网络请求被中止)`);
} else {
console.error(`第${chunk.index}个分片上传失败:`, error);
}
// 抛出错误 在Promise.allSettled中识别错误类型
throw error;
}
}6、上传进度展示
在单个分片上传过程中,会更新该分片的上传进度,然后根据所有分片的上传进度,计算文件整体上传进度。
注意点:
所有分片上传成功后,还存在一个合并分片和失败重试的过程,需要一定时间,所以此时上传进度只需更新到99%,等最终合并完成后,再显示上传成功。
/**
* 更新总上传进度展示
*/
function updateTotalProgress() {
// 如果识别到取消上传标志 则取消更新进度
if (isCancelUpload) return;
// 如果分片数量为0 则返回
if (chunkProgress.size === 0) return;
// 计算所有分片的上传进度总和(每个分片的上传进度是0-1)
const totalProgress = Array.from(chunkProgress.values()).reduce((sum, val) => sum + val, 0);
// 根据上传进度总和和分片数量 计算平均上传进度(平均上传进度是0-1)
const averageProgress = totalProgress / chunkProgress.size;
// 转换为百分比(百分比是0-100)
const percentage = Math.floor(averageProgress * 100);
// 更新DOM展示(最多更新到99%,因为后面还有合并分片的过程,等合并完分片之后再更新到100%,或者直接上传成功)
progressDom.textContent = `上传总进度:${Math.min(percentage, 99)}%`;
}7、检查上传结果
所有分片上传结束后,检查上传结果,为了确保数据可靠性,以服务器中的文件分片为准,并实现失败重试机制。
注意点:
如果全部上传成功,则通知后端合并分片,合并完成后,上传成功。
如果存在上传失败的分片,则进行递归失败重试,最多重试三次。
在失败重试时,要等待一段时间后再重试 避免短时间多次重试。
每次失败重试之前,要进行取消上传校验。
/**
* 检查上传结果并合并分片
* @param {Array<Object>} chunks - 分片数组(包含索引信息)
* @param {string} fileHash - 文件哈希值
* @param {number} retryCount - 当前重试次数
*/
async function checkAndMergeChunks(chunks, fileHash, retryCount = 0) {
// 1. 获取服务器中上传成功的分片列表(上传结果以服务器为准)
const uploadedChunkIndexes = await getUploadedChunks(fileHash);
console.log('已上传分片索引数组:', uploadedChunkIndexes);
// 2. 检查当前文件的所有分片是否都上传成功
if (uploadedChunkIndexes.length === chunks.length) {
// 3. 如果都上传成功 则通知后端合并分片
// 创建当前请求的controller
const controller = new AbortController();
// 将controller添加到controller集合中
controllerSet.add(controller);
// 发送合并分片请求
const res = await axios.post('/api/merge-chunks', {
fileHash,
totalChunks: chunks.length
}, {
signal: controller.signal // 绑定控制器用于取消上传请求
});
// 请求完成后从控制器集合中移除当前控制器
controllerSet.delete(controller);
console.log('文件合并完成!', res.data);
progressDom.textContent = '文件上传成功!';
} else {
// 3. 如果有部分分片上传失败,则进行重试(最多重试3次)
console.log(`还有 ${chunks.length - uploadedChunkIndexes.length} 个分片未上传成功`);
// 如果重试次数小于最大重试次数 则进行重试
if (retryCount < MAX_RETRY_COUNT) {
console.log(`正在重试第 ${retryCount + 1} 次...`);
// 等待一段时间后再重试 避免短时间多次重试
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
// 检查是否取消上传
checkCancellation();
// 重新调用分片上传函数
// 但成功的分片会在上传前被过滤掉 只会上传之前失败的分片
const retryResult = await uploadChunks(fileHash, chunks);
// 递归检查(最多递归重试3次)
await checkAndMergeChunks(chunks, fileHash, retryCount + 1);
} else {
throw new Error('达到最大重试次数,上传失败,请重新上传');
}
}
}8、取消上传
上传过程中支持用户取消上传,可在上传的任何阶段取消。
注意点:
监听取消上传按钮,绑定对应事件处理函数。
全局有多个地方需要通过检查取消标志的状态,来结束流程,因此封装了一个取消检查函数。
上传过程中所有请求的 controller,都会在请求发起前加入到 controllerSet 中,在请求正常完成后,从 controllerSet 移除。
当取消上传时,要调用当前 controllerSet 中所有 controller 的 abort() 方法,取消正在进行的请求。
取消时可能处于某些特殊阶段中,需要等待一段时间后再提示取消成功和重置状态,避免过早重置取消上传标识状态。
如果取消上传执行异常,则触发兜底逻辑,直接刷新页面。
// 监听取消上传按钮点击
cancelBtn.addEventListener('click', async () => {
// 检查是否有正在进行的上传任务
if (!isUploading) {
alert('当前没有正在进行的上传任务');
return;
}
// 确认是否要取消上传
if (!confirm('确定要取消当前上传任务吗?')) {
return;
}
// 设置取消上传标志
isCancelUpload = true;
progressDom.textContent = '正在取消...';
try {
// 取消所有正在进行的请求
abortAllRequests();
// 可能是正好处于读取某个文件块的过程中 需等待onload事件触发
// 可能是处于失败重试的等待时间中 需等待时间结束
// 此处进行粗糙处理 定时器等待两秒 避免过早重置取消上传标识状态(可设置一个全局loading状态)
setTimeout(() => {
progressDom.textContent = '上传已取消';
resetUploadState();
}, 2000);
} catch (error) {
console.error('取消操作失败:', error);
// 自动刷新页面 避免页面状态异常
window.location.reload();
}
});
/**
* 取消检查函数
* @param {Function} rejectFn - 取消错误处理函数(用于抛出取消错误)
*/
function checkCancellation(rejectFn = null) {
// 如果此时取消了上传
if (isCancelUpload) {
// 创建一个取消错误
const cancelError = new Error('上传操作被取消');
// 设置取消错误名称(用于识别错误类型)
cancelError.name = 'CancelError';
// 如果传入了取消错误处理函数
if (rejectFn) {
// 则调用该函数 并将取消错误传递给该函数
rejectFn(cancelError);
} else {
// 如果没有传入取消错误处理函数,则直接抛出取消错误
throw cancelError;
}
}
}
/**
* 取消所有正在进行的请求
*/
function abortAllRequests() {
// 遍历controller集合 取消所有正在进行的请求
controllerSet.forEach(controller => {
// 某个请求的取消报错 不影响后续请求的取消
try {
controller.abort();
} catch (e) {
console.warn('Controller abort failed:', e);
}
});
// 清空controller集合
controllerSet.clear();
}四、后端接口文档
1、/api/check-chunks
根据当前文件哈希值查询该文件已上传至服务器的分片,返回已存在分片的索引列表。
请求方式: GET
请求参数:
响应数据:
数据类型: Array<number>
描述: 返回已上传分片的索引数组
示例:
[0, 1, 3, 5, 7]表示索引为0、1、3、5、7的分片已上传至服务器。
2、/api/upload-chunk
上传单个文件分片到服务器,返回上传结果。
请求方式: POST
请求参数:
响应数据:
数据类型: Object
描述: 单个分片上传结果信息
示例:
{ success: true, data: 'xxxxxxx' },表示当前分片上传成功。
3、/api/merge-chunks
当所有分片上传完成后,通知后端合并所有分片,获取完整文件。
请求方式: POST
请求参数:
响应数据:
数据类型: Object
描述: 文件合并结果信息
示例:
{ success: true, filePath: "https:xxxxx" },表示分片合并成功。
4、/api/upload
用于小文件(小于500MB)的直接上传,不进行分片处理。
请求方式: POST
请求参数:
响应数据:
数据类型: Object
描述: 文件上传结果信息
示例:
{ success: true, data: 'xxxxxxx' },表示当前文件上传成功。