大宇宇宇
发布于 2025-09-22 / 12 阅读
0
0

大文件分片上传:逻辑拆解+代码实战

#JS

一、单文件直传方案的局限性

传统的单文件直传方案,是指前端获取到文件对象后,直接通过 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
请求参数:

参数名

类型

是否必填

描述

fileHash

string

文件的MD5哈希值,用于唯一标识文件

响应数据:
  • 数据类型: Array<number>

  • 描述: 返回已上传分片的索引数组

  • 示例:[0, 1, 3, 5, 7] 表示索引为0、1、3、5、7的分片已上传至服务器。

2、/api/upload-chunk

上传单个文件分片到服务器,返回上传结果。

请求方式: POST
请求参数:

参数名

类型

是否必填

描述

fileHash

string

文件的MD5哈希值,用于标识分片属于哪个文件

chunkIndex

number

当前分片的索引

chunk

File

分片数据内容

totalChunks

number

文件总分片数量

响应数据:
  • 数据类型: Object

  • 描述: 单个分片上传结果信息

  • 示例:{ success: true, data: 'xxxxxxx' },表示当前分片上传成功。

3、/api/merge-chunks

当所有分片上传完成后,通知后端合并所有分片,获取完整文件。

请求方式: POST
请求参数:

参数名

类型

是否必填

描述

fileHash

string

文件的MD5哈希值,用于标识要合并的文件

totalChunks

number

文件总分片数量,用于验证完整性

响应数据:
  • 数据类型: Object

  • 描述: 文件合并结果信息

  • 示例:{ success: true, filePath: "https:xxxxx" },表示分片合并成功。

4、/api/upload

用于小文件(小于500MB)的直接上传,不进行分片处理。

请求方式: POST
请求参数:

参数名

类型

是否必填

描述

file

File

要上传的文件对象

响应数据:
  • 数据类型: Object

  • 描述: 文件上传结果信息

  • 示例:{ success: true, data: 'xxxxxxx' },表示当前文件上传成功。


评论