Skip to content

OSS大文件直传:基于Web Worker的分片上传解决方案

简介

在Web应用中,文件上传是一个常见但充满挑战的功能,特别是当需要处理大文件时,传统的表单提交方式往往面临超时、内存占用过高、用户体验差等问题。本文将详细介绍如何利用现代Web技术,特别是Web Worker和分片上传,结合阿里云OSS(对象存储服务)的直传能力,实现一个高效、可靠且用户友好的大文件上传解决方案。

该方案主要解决以下问题:

  1. 大文件上传过程中浏览器内存占用过高的问题
  2. 上传中断后需要重新上传的问题
  3. 重复文件上传的资源浪费问题
  4. 上传过程中的并发控制与错误重试问题

技术架构

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

核心技术点

  1. Web Worker:利用浏览器的多线程能力,将耗时的MD5计算过程放在后台线程中执行,避免阻塞主线程,保持页面的响应性。

  2. 文件分片:将大文件切分为多个小块,分别上传,有效降低单次上传失败的影响范围。

  3. OSS直传:减轻服务器带宽和存储负担,文件内容直接从浏览器传输到OSS,服务器仅处理控制流程。

  4. 并发控制:限制同时上传的分片数量,避免过多的并发请求导致浏览器或网络压力过大。

  5. 断点续传:记录已上传的分片信息,支持从中断处继续上传。

  6. 秒传功能:基于文件MD5判断服务端是否已存在相同文件,实现秒传。

  7. 自动重试:对上传失败的分片实现自动重试机制,提高上传成功率。

技术选型思考

在实现大文件上传功能时,我经历了以下思考过程:

为什么选择客户端分片而非服务端分片?

服务端分片需要先将完整文件上传至服务器,然后由服务器进行分片处理和OSS上传,这会导致:

  • 服务器带宽和存储资源消耗大
  • 文件需要两次传输(客户端→服务器→OSS)
  • 大文件上传至服务器过程中仍可能遇到超时问题

而客户端分片则可以:

  • 减轻服务器负担,文件直接从浏览器传输到OSS
  • 实现真正的断点续传,提升用户体验
  • 充分利用客户端计算资源

为什么需要计算文件MD5?

文件MD5值作为文件的唯一标识符,具有以下作用:

  • 实现秒传功能:相同MD5的文件可直接标记为上传成功
  • 作为断点续传的依据:结合MD5和分片索引记录上传进度
  • 校验文件完整性:确保上传的文件没有被篡改

为什么使用Web Worker计算MD5?

MD5计算是CPU密集型操作,对大文件进行MD5计算会占用主线程大量时间,导致:

  • 页面卡顿,交互响应慢
  • 可能触发浏览器"脚本运行时间过长"的警告

通过Web Worker将计算过程转移到后台线程:

  • 保持页面流畅响应
  • 充分利用多核CPU的并行计算能力
  • 提高整体计算效率

实现细节

1. Web Worker实现MD5计算

Worker文件:首先,我们需要创建一个专门用于计算MD5的Web

javascript
// 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计算

  1. 为什么使用Web Worker计算MD5?

    • MD5计算是CPU密集型操作,在主线程执行会阻塞UI渲染,导致页面卡顿
    • 通过Web Worker将计算放在后台线程,保持页面响应性
    • 利用多核CPU并行计算能力,提高整体性能
  2. SparkMD5库的优势

    • 支持增量计算文件MD5,避免一次性加载整个文件到内存
    • 提供对ArrayBuffer的直接支持,处理二进制数据效率高
    • 针对大文件处理进行了优化
  3. 关键实现细节

    • 使用FileReader.readAsArrayBuffer()读取二进制数据,而非Base64等格式
    • 采用递归方式处理分片,避免同时加载多个分片
    • 通过postMessage进行线程间通信,定期报告进度
    • 计算完成后主动释放资源,防止内存泄漏
  4. 异常处理策略

    • 捕获读取错误并向主线程报告
    • 区分进度、完成和错误三种消息类型
    • Worker完成任务后自行终止,避免资源浪费

2. 文件分片处理实现

以下是主线程中实现文件分片和使用Web Worker计算MD5的代码:

javascript
/**
 * 文件分片和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(顺序)、uploadIdobjectKey(服务端识别),其余信息可根据业务需求扩展。

  1. 2MB的分片大小是如何确定的?

    • 分片大小是权衡结果,太小会导致请求次数多,网络开销大;太大会导致单次上传时间长
    • 2MB是经验值,在大多数网络环境下表现良好
    • 实际应用中可根据网络情况动态调整分片大小
  2. File.slice()方法的优势

    • 不会复制文件数据,而是创建原始数据的引用,内存消耗小
    • 返回Blob对象,可直接用于网络传输
    • 切片操作在主线程,但速度快,对UI影响小
  3. calculateMD5()方法的Promise设计

    • 使用Promise封装异步计算过程,支持链式调用和async/await
    • 区分成功(resolve)和失败(reject)两种情况
    • 通过回调函数支持进度通知

在OSS直传分片上传过程中,服务端需要通过一组关键信息来识别“某个分片属于哪个文件,以及是该文件的哪一部分”。具体实现方式如下:

  1. uploadId

    • 每次发起分片上传时,客户端会先向服务端(或OSS)请求初始化分片上传任务,服务端会返回一个唯一的uploadId
    • 这个uploadId相当于本次大文件上传的唯一标识,所有属于同一个文件的分片上传请求都要携带同一个uploadId
  2. 分片序号(PartNumber 或 chunkIndex)

    • 每个分片在文件中的顺序编号,通常从1或0开始递增。
    • 这个编号用于服务端在最终合并分片时,能够按照正确顺序拼接文件。
  3. objectKey(或key、fileKey)

    • OSS对象存储中的目标文件路径(唯一标识该文件在OSS中的位置)。
    • 一般由客户端生成并在所有分片上传时携带,服务端据此区分不同文件。
  4. 文件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分片上传主要包括三个步骤:初始化上传任务、上传分片、完成上传。以下是相关实现:

javascript
/**
 * 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分片上传实现

  1. OSS分片上传的完整流程
  • 客户端计算文件MD5,向应用服务器查询是否可以秒传
  • 应用服务器为客户端提供临时授权(STS令牌或签名URL)
  • 客户端使用授权信息初始化OSS上传任务
  • 客户端并发上传分片到OSS(文件内容不经过应用服务器)
  • 客户端通知应用服务器所有分片上传完成
  • 应用服务器调用OSS API完成分片合并
  1. 并发控制的实现原理

    • 使用计数器(currentUploads)、队列(uploadQueue)和映射(uploadingChunks)跟踪上传状态
    • 通过processQueue方法控制并发数,确保同时只有特定数量的请求
    • 使用非阻塞方式启动上传任务,提高并发效率
  2. 错误处理与重试机制

    • 分级重试策略:区分临时错误和永久错误
    • 延时重试:使用setTimeout在重试前等待一段时间
    • 递归重试:通过递归调用自身实现多次重试
    • 记录重试次数,达到限制后放弃
  3. 秒传功能实现原理

    • 基于文件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: 跳过已上传分片,继续上传剩余分片

以下是断点续传的核心实现:

javascript
/**
 * 断点续传管理器
 * 负责保存和恢复上传状态,支持中断后继续上传
 */
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上传器中:

javascript
/**
 * 扩展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);
    }
  }
}

面试要点:断点续传实现

  1. 断点续传的核心原理

    • 使用本地存储(localStorage)保存上传状态和已上传分片信息
    • 上传前检查是否有未完成的上传任务
    • 从服务器获取已上传分片列表,确保状态同步
    • 只上传未完成的分片,节省时间和带宽
  2. 上传状态持久化方案

    • 使用文件MD5作为唯一标识符,关联上传状态
    • 存储结构包含上传ID、文件信息和已上传分片列表
    • 定期更新状态,确保数据最新
    • 上传完成后主动清除状态,避免存储空间浪费
  3. 断点续传的容错机制

    • 服务器状态校验:恢复前先验证服务端上传任务是否有效
    • 双重验证:同时通过本地存储和服务器查询确认已上传分片
    • 状态不一致处理:以服务器数据为准,更新本地存储
    • 任务恢复失败时自动创建新上传任务
  4. 多设备同步的考虑

    • 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的直传能力,实现一个高效、可靠的大文件上传解决方案。

主要成果

  1. 流畅的用户体验:通过Web Worker将MD5计算放在后台线程,保持页面响应性
  2. 高效的资源利用:客户端分片处理和直传OSS,减轻服务器负担
  3. 可靠的上传机制:断点续传、秒传和自动重试机制提高上传成功率
  4. 灵活的扩展性:模块化设计使系统易于扩展和维护

未来优化方向

  1. 自适应分片大小:根据网络环境和文件特性动态调整分片大小
  2. 预上传检测:上传前检测网络状况,提供更准确的上传时间估计
  3. 多文件并行上传:支持多文件队列,并智能调度上传顺序
  4. 更多存储服务支持:扩展支持更多云存储服务,如腾讯云COS、七牛云等
  5. 服务端集成优化:提供更完善的服务端集成方案,简化后端实现

最终思考

大文件上传是前端开发中的一个挑战性问题,它涉及网络、存储、性能、用户体验等多个方面。通过合理利用现代Web技术,如Web Worker、Blob API、Fetch API等,结合云存储服务的能力,我们可以构建出既高效又可靠的大文件上传解决方案。

在实际应用中,我们还应当根据具体业务场景进行调整和优化,例如针对特定文件类型的处理策略、特定用户群体的网络环境适配等。只有将技术与业务紧密结合,才能真正解决用户的实际问题。

面试要点:完整实现集成

  1. 架构设计与类的职责划分

    • 采用组合设计模式,每个类负责单一功能:FileChunker负责文件分片,OSSUploader负责上传
    • BigFileUploader作为外观类,提供统一简洁的接口,隐藏内部复杂性
    • 良好的关注点分离,便于维护和扩展
  2. 进度计算和用户反馈

    • 分别跟踪MD5计算和文件上传两个阶段的进度
    • 使用加权平均计算总进度,反映实际耗时比例
    • 丰富的回调机制支持进度展示、成功提示、错误处理等
  3. 错误处理策略

    • 全流程try-catch捕获异常,避免未处理的错误
    • 关键操作错误会重试,非关键错误优雅降级
    • 通过onError回调提供详细错误信息,便于调试和用户反馈
  4. 资源管理和内存释放

    • 使用reset方法在上传完成后清理资源
    • 及时释放不再需要的对象引用,避免内存泄漏
    • 文件处理完毕后主动终止Web Worker

面试重点与常见问题

作为一个复杂的前端技术解决方案,大文件上传在面试中常被提及。以下是一些面试官可能关注的要点和相应的回答思路:

1. 核心技术选型与原理

  1. 为什么需要对文件进行分片?

    • 大文件一次性上传容易因网络波动导致失败,分片可降低单次上传失败的影响
    • 支持断点续传,中断后只需上传未完成的分片
    • 分片并发上传可以提高整体上传速度
    • 浏览器对单次请求大小有限制,分片可突破这个限制
  2. 2MB的分片大小是如何确定的?

    • 分片大小是权衡结果,太小会导致请求次数多,网络开销大;太大会导致单次上传时间长
    • 2MB是经验值,在大多数网络环境下表现良好
    • 实际应用中可根据网络情况动态调整分片大小
  3. File.slice()方法的优势

    • 不会复制文件数据,而是创建原始数据的引用,内存消耗小
    • 返回Blob对象,可直接用于网络传输
    • 切片操作在主线程,但速度快,对UI影响小
  4. calculateMD5()方法的Promise设计

    • 使用Promise封装异步计算过程,支持链式调用和async/await
    • 区分成功(resolve)和失败(reject)两种情况
    • 通过回调函数支持进度通知

问题:大文件上传的核心技术方案是什么?

答:我们的大文件上传方案主要基于以下核心技术:

  1. 分片上传技术:将大文件切分为多个小块(通常为1MB-5MB),分别上传

    • 使用Blob API和File API进行客户端文件切片
    • 利用Blob.slice()方法高效切分文件,不占用额外内存
  2. OSS直传方案

    • 采用基于STS临时授权的客户端直传模式
    • 客户端获取临时凭证后直接与OSS服务器通信,不经过应用服务器
    • 使用OSS的分片上传API:InitiateMultipartUpload、UploadPart、CompleteMultipartUpload
  3. 并行上传控制

    • 实现可配置的并发控制机制,默认3-5个并发请求
    • 使用Promise.all和自定义队列管理并发任务
  4. 文件指纹计算

    • 使用Web Worker在后台线程计算文件MD5
    • 采用SparkMD5库进行增量计算,避免内存溢出
  5. 断点续传实现

    • 基于localStorage存储上传状态和已上传分片信息
    • 结合文件MD5作为唯一标识,实现跨会话的断点续传

2. 实际操作流程

问题:在实际项目中,你是如何操作大文件上传的?

答:在实际项目中,我的操作流程如下:

  1. 前期准备工作

    • 与后端确定上传流程和接口规范
    • 配置OSS相关参数(Bucket、Region等)
    • 设计上传组件的UI和交互逻辑
  2. 核心代码实现

    • 封装FileChunker类处理文件分片和MD5计算
    • 实现OSSUploader类负责与OSS服务交互
    • 开发BigFileUploader外观类提供统一API
  3. 上传流程实现

    • 用户选择文件后,先计算文件MD5作为唯一标识
    • 向后端请求上传参数和STS临时授权
    • 初始化OSS分片上传任务,获取uploadId
    • 并行上传分片,实时更新进度
    • 所有分片上传完成后,调用合并接口完成上传
  4. 异常处理与优化

    • 实现网络异常自动重试机制
    • 添加上传暂停/恢复功能
    • 优化上传速度和内存占用

3. 前后端联调过程

问题:在这个大文件上传方面,你与后端是怎么进行联调的?

答:与后端的联调过程主要包括以下几个方面:

  1. 接口协议设计

    • 共同制定接口规范,包括请求参数、响应格式和错误码
    • 明确分工界面:前端负责文件分片和上传,后端负责授权和文件管理
  2. 授权机制联调

    • 测试STS临时授权的获取和刷新机制
    • 验证授权策略的正确性,确保安全性和最小权限原则
  3. 分片上传联调

    • 验证分片大小的合理性,通常在测试环境尝试不同大小(1MB、5MB等)
    • 测试并发上传的稳定性,调整并发数量
  4. 断点续传测试

    • 模拟网络中断场景,验证断点续传功能
    • 测试跨会话续传(关闭浏览器后重新打开)
  5. 边界情况处理

    • 测试超大文件(如10GB以上)的上传性能
    • 验证特殊文件类型(如视频、压缩包)的兼容性
    • 模拟各种错误情况,如授权失败、分片上传失败等
  6. 性能优化

    • 使用Chrome DevTools分析上传过程的网络和内存使用情况
    • 与后端一起优化上传参数,如分片大小、超时时间等

4. MD5相关知识

问题:md5了解过吗?md5加密的长度是不是固定的?

  1. MD5基本概念

    • MD5(Message-Digest Algorithm 5)是一种广泛使用的哈希函数,可以生成数据的"指纹"
    • 它不是加密算法,而是一种单向散列函数,无法从结果逆向推导出原始数据
    • 主要用于数据完整性校验、文件唯一性标识等场景
  2. MD5输出长度

    • MD5的输出长度是固定的,始终为128位(16字节)
    • 通常表示为32位的十六进制字符串(每个字节用两个十六进制字符表示)
    • 例如:e10adc3949ba59abbe56e057f20f883e(这是"123456"的MD5值)
  3. 在文件上传中的应用

    • 我们使用MD5计算文件的唯一标识,用于实现秒传和断点续传
    • 对于大文件,采用分块计算然后合并的方式,避免内存溢出
    • 使用Web Worker在后台线程计算,避免阻塞UI线程
  4. 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. 上传限速:针对不同网络环境控制上传速度,避免占用全部带宽

从工程角度,还可以提供更完善的错误监控和数据统计,便于问题排查和性能分析。

问题:如果在上传途中断网中断了,怎么处理?

答:断网中断处理主要有两种方案:

  1. 本地存储记录方案:通过localStorage记录切片上传的信息,包括文件标识、已上传分片索引等元数据。上传恢复时从本地存储读取状态,只上传未完成的部分。

  2. 服务端验证方案:每次在上传切片前向后台询问该切片是否已经上传。这种方式更可靠,因为服务端数据是权威来源,但需要额外的网络请求。

实际应用中,通常结合两者:使用本地存储提高效率,同时通过服务端验证确保数据准确性。

问题:如何实现暂停上传功能?

答:暂停上传功能的实现主要通过以下几种技术手段:

  1. 状态管理机制:维护一个全局的上传状态标志(如isUploading),当用户点击暂停时,将状态设为false,上传循环会检查此状态决定是否继续
  2. 请求中断:使用AbortController API中断正在进行的网络请求,例如:
    javascript
    const controller = new AbortController();
    const signal = controller.signal;
    // 上传请求
    fetch(url, { method: 'PUT', body: chunk, signal });
    // 暂停时中断
    controller.abort();
  3. 任务队列控制:维护一个上传任务队列,暂停时停止从队列取出新任务,同时记录当前队列状态
  4. 分片状态记录:在localStorage中保存已完成分片的信息,便于恢复时跳过这些分片
  5. 取消Promise链:通过特殊的Promise处理模式,允许外部中断Promise.all或Promise.allSettled的执行

实际项目中,我们通常会将这些机制封装在上传类中,提供pause()和resume()方法供外部调用。

暂停后,客户端和服务端都能知道已上传多少部分,下次继续时直接跳过已上传的分片。

问题:大文件分片上传的难点有哪些?

答:大文件分片上传的主要难点包括:

  1. 文件分片处理:如何高效地切分文件并管理分片
  2. 并行上传控制:如何平衡上传速度和浏览器资源占用
  3. 断点续传实现:如何可靠地记录和恢复上传状态
  4. 秒传功能:如何基于文件特征快速判断文件是否已存在
  5. 错误处理与重试:如何应对上传过程中的各种异常情况
  6. 加密与安全:确保上传过程中的数据安全,同时保持一致的加密方法

一个特殊难点是文件特征值计算问题:当需要更换加密方法时,旧的加密值不再适用,需要处理算法迁移和兼容问题。

问题:秒传功能依赖服务端预先存储文件的MD5,但不同用户上传同一文件时,如何确保计算出的MD5一致?文件读取方式(如二进制vs文本)是否会影响结果?

答:文件的MD5值仅与文件内容有关,与文件名、创建信息等元信息无关。MD5是对文件二进制内容的散列计算,只要文件的二进制内容完全相同,无论何时何地都会得到相同的MD5值。

不同读取方式下的一致性 在实现MD5计算时,读取方式确实可能影响结果,主要有以下几种情况: 二进制读取 vs 文本读取: 二进制读取(如ArrayBuffer、Blob)会直接处理原始字节数据 文本读取(如readAsText)会涉及字符编码转换,可能导致计算结果不一致 换行符处理: 不同操作系统的换行符不同(Windows: CRLF, Linux/Mac: LF) 如果以文本模式读取,可能会自动转换换行符,导致MD5不一致 BOM标记: UTF-8文件可能包含BOM标记,不同读取方式可能对BOM的处理不同

js
// 以下实现了文件读取的
// 在Web Worker中的MD5计算
reader.readAsArrayBuffer(chunks[currentChunk]);
// ...
spark.append(e.target.result); // 处理二进制数据
  • 使用readAsArrayBuffer始终以二进制的方式读取文件
  • 直接对原始二进制数据计算MD5,不进行任何字符编码转换
  • 保留所有原始字节,包括可能得BOM标记和原始换行符 问题:文件如何切片,分成了什么?

答:文件切片的核心实现是使用Blob.prototype.slice方法:

javascript
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()方法的优势是创建原始数据的引用而非复制,内存消耗小。

问题:上传应该是有顺序的,如何保证顺序?

答:在实际实现中,分片上传不必严格按顺序进行,原因是:

  1. 每个分片都带有明确的索引信息(如chunkIndexpartNumber
  2. 后端在合并时会根据索引对分片进行排序,确保最终文件的正确性

这种设计使并行上传成为可能,大大提高了上传效率。每次上传我们都会添加分片索引信息,后端根据这些索引进行正确的组装,无需客户端保证上传顺序。

问题:如何保证每一片的完整性?

答:为确保每个分片的完整性,我们采用了多层验证机制:

  1. 分片MD5校验

    • 客户端在上传每个分片前计算其MD5哈希值
    • 将MD5值作为请求参数一同发送到服务端
    • 服务端接收分片后重新计算MD5并与客户端提供的值比对
    • 如果不匹配,则判定为传输错误,要求重新上传该分片
  2. ETag验证

    • OSS会为每个上传的分片生成ETag值(通常基于内容的哈希)
    • 客户端保存每个分片上传后返回的ETag
    • 在调用CompleteMultipartUpload时提供所有分片的ETag列表
    • OSS会验证这些ETag,确保所有分片都完整无损
  3. HTTP传输层保障

    • 利用HTTP协议自带的校验机制(如Content-MD5头)
    • 设置适当的超时时间,避免网络波动导致的数据截断
    • 使用HTTPS协议确保传输过程中数据不被篡改
  4. 分片大小控制

    • 合理设置分片大小(通常2MB-5MB)
    • 较小的分片大小可以降低单个分片传输失败的风险
    • 同时减少重传时的数据量,提高恢复效率
  5. CRC64校验(阿里云OSS特性)

    • 在支持的环境中启用CRC64校验
    • 上传完成后比对本地计算的CRC64值与服务端返回的值
    • 提供端到端的数据完整性验证

问题:假如某个分片上传失败了,是怎么处理的?

答:对于上传失败的分片处理策略:

  1. 使用Promise.allSettled收集所有分片的上传结果,包括成功和失败的情况
  2. 将失败的分片索引记录在失败数组中(uploadResults.filter(r => r.status === 'rejected').map(r => r.reason.index)
  3. 针对失败的分片实现自动重试机制,设置最大重试次数和退避策略
  4. 如果重试后仍然失败,将这些分片信息保存,等网络恢复后继续上传

后端可以通过检查接收到的分片索引与总分片数进行比对,确定哪些分片缺失,从而请求客户端重新上传这些分片。

问题:后台如何知道是哪些分片没接收到?

答:后台确定缺失分片的方法:

  1. 客户端在初始化分片上传时,会调用OSS的InitiateMultipartUpload接口获取uploadId
  2. 服务端(OSS)会为每个分片上传任务维护状态记录,通过uploadId唯一标识
  3. 客户端上传分片时,会在请求中包含partNumber(分片编号)和uploadId
  4. 客户端可以通过调用ListParts接口,查询已经上传的分片列表
  5. 通过比对已上传的分片列表和本地的分片列表,可以确定哪些分片需要重新上传
  6. 在完成上传前,客户端需要调用CompleteMultipartUpload接口,提供所有分片的ETag信息

服务端通常会返回类似 {missing: [3, 7, 9]} 的数据结构,告知客户端哪些分片需要重新上传。

问题:怎么做并行上传?这几百个分片,如何同时上传一部分?

答:并行上传的实现主要依靠Promise.allSettled和队列管理:

  • 控制并发数量

    • 设定最大并发上传数(默认为3个)
    • 这是经过实践测试得出的较为理想的并发数,既能提高上传速度,又不会给浏览器和网络带来过大压力
  • 批次处理机制

    • 将所有分片按批次进行处理,每批次只上传固定数量(如3个)的分片
    • 使用for循环和数组切片方法(slice)获取每批次需要上传的分片
    • 当前批次完成后再开始下一批次,确保任意时刻的并发请求数不超过设定值
  • Promise异步处理

    • 每个分片上传任务被封装为Promise对象
    • 使用Promise.allSettled而非Promise.all处理当前批次的所有Promise
    • 这样即使某个分片上传失败,也不会影响其他分片的上传
    • 通过await等待当前批次全部完成,再进入下一批次
  • 进度反馈机制

    • 每批次完成后更新上传进度
    • 计算进度时考虑已处理的分片数与总分片数的比例
javascript
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);
  }
  // 函数结束后,所有分片都已尝试上传完成
}

核心要点:

  1. 不是一次并发上传所有分片,而是控制并发数(通常3-5个)
  2. 使用Promise.allSettled而非Promise.all,可以同时处理成功和失败情况
  3. 批次处理:每批次上传固定数量的分片,一批完成后再上传下一批
  4. 每个分片上传任务封装为Promise,通过数组传递给Promise.allSettled
  5. Promise.allSettled返回的数组,对应每个Promise的执行结果

这种方式既能利用并行提高效率,又避免了过多并发请求导致的浏览器崩溃或服务器压力过大。

完整代码解释总结

本文详细介绍了使用Web Worker和分片上传技术,结合阿里云OSS的直传能力,实现一个高效、可靠的大文件上传解决方案。我们为每段核心代码添加了详细注释,便于理解实现原理和工作流程。

技术方案总结

  1. Web Worker计算MD5:将CPU密集型的MD5计算放在后台线程中进行,避免主线程阻塞,保持页面响应性。使用SparkMD5库支持增量计算,避免一次性加载整个文件到内存。

  2. 文件分片处理:使用File.slice()方法将大文件切分为2MB大小的片段,创建原始数据的引用而非副本,节省内存。分片处理使单次上传失败的影响降到最小。

  3. OSS分片上传:实现了完整的OSS分片上传流程,包括初始化上传、获取签名、上传分片、报告成功状态、完成上传等步骤。通过ETag确保分片上传成功。

  4. 并发控制与重试:限制同时上传的分片数量(默认3个),避免浏览器资源耗尽。对失败的分片实现自动重试机制,提高上传成功率。使用队列管理上传任务,确保平滑上传。

  5. 断点续传功能:通过localStorage持久化存储上传状态,支持页面刷新或浏览器关闭后继续上传。上传恢复时验证服务端任务有效性,同步已上传分片信息。

  6. 秒传功能:基于文件MD5判断服务端是否已存在相同文件,实现秒传。避免重复上传,节省带宽和时间。

核心代码一览

本解决方案包含以下几个核心类:

  1. Web Worker (md5-worker.js):负责在后台线程中计算文件MD5。
  2. FileChunker:负责文件分片和MD5计算。
  3. OSSUploader:负责OSS分片上传、并发控制和重试。
  4. ResumeUploadManager:负责断点续传状态管理。
  5. ResumeableOSSUploader:扩展OSSUploader,添加断点续传功能。
  6. BigFileUploader:整合以上功能,提供统一的上传接口。

通过这些类的协同工作,实现了一个完整、高效、可靠的大文件上传解决方案。此方案适用于各种Web应用场景,特别是需要处理大文件上传的系统,如在线存储、媒体分享、企业文档管理等。

在实际应用中,还可以根据具体业务需求进行定制和扩展,例如添加文件预览、进度可视化、上传历史记录等功能,提供更完善的用户体验。

主要成果

  1. 流畅的用户体验:通过Web Worker将MD5计算放在后台线程,保持页面响应性
  2. 高效的资源利用:客户端分片处理和直传OSS,减轻服务器负担
  3. 可靠的上传机制:断点续传、秒传和自动重试机制提高上传成功率
  4. 灵活的扩展性:模块化设计使系统易于扩展和维护

未来优化方向

  1. 自适应分片大小:根据网络环境和文件特性动态调整分片大小
  2. 预上传检测:上传前检测网络状况,提供更准确的上传时间估计
  3. 多文件并行上传:支持多文件队列,并智能调度上传顺序
  4. 更多存储服务支持:扩展支持更多云存储服务,如腾讯云COS、七牛云等
  5. 服务端集成优化:提供更完善的服务端集成方案,简化后端实现

最终思考

大文件上传是前端开发中的一个挑战性问题,它涉及网络、存储、性能、用户体验等多个方面。通过合理利用现代Web技术,如Web Worker、Blob API、Fetch API等,结合云存储服务的能力,我们可以构建出既高效又可靠的大文件上传解决方案。

在实际应用中,我们还应当根据具体业务场景进行调整和优化,例如针对特定文件类型的处理策略、特定用户群体的网络环境适配等。只有将技术与业务紧密结合,才能真正解决用户的实际问题。

7. 完整使用示例

下面是如何集成所有组件实现一个完整的大文件上传功能:

javascript
/**
 * 大文件上传管理器
 * 整合文件分片、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页面集成示例

下面是如何在网页中使用大文件上传器:

javascript
// 创建上传器实例
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结构:

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样式: 略