前端大文件分片上传 —— 基于 React 的工程化实现

你是否曾苦苦等待一个几G的大文件上传,却在进度条到99%时因为网络波动而崩溃重来?在现代Web应用中,传统的单次上传方式早已力不从心。本文介绍作者使用的一个 生产级 的解决方案,实现支持 分片、取消、重试 的大文件上传。

一、为什么要做分片上传?

1. 解决大文件上传的稳定性问题(核心意义)

这是分片上传最首要的目的。

  • 传统单次上传的弊端:如果一个2GB的文件一次性上传,网络波动导致在传输到90%时中断,整个上传就失败了,用户必须从头开始重传,体验极差。
  • 分片上传的优势 :如果将2GB文件切成100个20MB的分片。即使传输到第99个分片时中断,也只需要重新上传这失败的1个分片(第99片),而不是整个文件。这极大地增强了上传的容错能力,节省了时间和带宽

2. 提升上传速度和用户体验

  • 并发上传 :浏览器可以同时发起多个HTTP请求,分别上传不同的文件分片。这充分利用了用户的网络带宽,将串行上传变为并行上传,显著提高了大文件的上传速度
  • 进度条更精确:可以实时计算已成功上传的分片数量和总大小的比例,向用户展示一个非常精确和流畅的上传进度条,提升了用户体验。

3. 绕过各种大小限制

  • 客户端限制:某些浏览器、服务器或中间件(如Nginx)对单个请求的Body大小有默认限制。分片上传确保了每个请求的体积都很小,可以轻松绕过这些限制。
  • 服务端处理:一次性将超大文件加载到服务器内存中进行处理是不现实且危险的,容易导致内存溢出。分片上传后,服务器每次只处理一个小分片,最后再按需合并,对服务器更友好。

4. 便于做额外的校验和控制

  • MD5校验 :不仅可以对整个文件做校验,还可以对每一个分片单独做MD5校验。服务端收到分片后立即验证其MD5,如果校验失败,可以立即要求客户端重传该分片,保证了每个分片数据的准确性。
  • 暂停和重试:因为上传状态(每个分片是否成功)被记录在服务端,用户可以随时暂停上传,并在之后继续上传。

二、如何实现分片上传?

核心流程:化整为零,再化零为整

分片上传的核心思想是"分而治之",其流程如下图所示:

核心实现细节

以下基于 React + antd 实现,我们将聚焦几个关键的技术细节。整体设计思路可参考这篇优秀的文章:前端大文件上传深入研究和实现

🔍 MD5计算:全量 vs 抽样

计算整个文件的MD5以保证唯一性准确,但大文件全量计算非常耗时。我们采用了一种均衡策略

  • 小文件:直接全量计算。
  • 大文件 :采用抽样计算,在精度和速度间取得尽量的平衡。
  • 如何定义大小文件 :根据实际情况,给分片数设定一个阈值,低于该阈值采用全量计算,高于该阈值采用抽样计算

下面我们来介绍一下抽样计算方案:

  1. 切片 :按固定大小(如2MB)将文件切分成多个切片。

  2. 采样

    • 第一个最后一个切片的全部内容。
    • 对于中间的所有切片,每片取首、中、尾各x个字节
  3. 组合 :将这些采样的字节数据合并成一个新的 Blob 对象。

  4. 计算 :使用 spark-md5 库计算这个采样 Blob 的hash值,作为文件的唯一标识。

这种方法极大地减少了计算量,虽有小概率的hash碰撞,但在实际业务中完全可以接受

🧵 并发控制:线程池

为了避免浏览器同时发起过多HTTP请求,实现了一个简单的线程池 来控制并发数。它采用 队列 + 工作者 模型。

ts 复制代码
class ThreadPool {
    maxThread: number;        // 最大并发数
    queue: Array<Worker>;     // 当前执行队列
    workers: Array<Worker>;   // 待执行的工作者队列  
    errorList: Array<Error>;  // 错误收集列表
    
    constructor(maxThread: number, fns: Array<() => Promise<void>>) {
        this.maxThread = maxThread;
        this.queue = [];
        // 将所有任务函数包装成 worker
        this.workers = fns.map((item, ind) => {
            return this.createWorker({ fn: item, ind });
        });
        this.errorList = [];
    }
    
    // Worker 包装机制
    createWorker(worker: { fn: () => Promise<void>; ind: number }) {
        // 1. 补足队列 - 如果当前队列未满,从待执行队列中取任务
        this.fullQueue();
        // 2. 执行当前任务
        await worker.fn().catch(() => {
            // 错误收集
            ...
        })
        this.queue = this.queue.filter((item) => item.ind !== worker.ind);
        
        // 4. 递归执行下一个任务 - 这里有个关键的递归调用
        await this.workers?.[0]?.fn();
    }
    // 队列补充机制
    fullQueue(){
        // 1. 从待执行队列取出
        // 2. 加入执行队列
        while...
    }
    async start() {
        // 初始填充队列
        this.fullQueue();
        // 并发执行队列中的所有任务
        return await Promise.allSettled(this.queue.map(async (item) => await item.fn()));
    }
}

执行过程解析(假设 maxThread=3)

ini 复制代码
时间轴:  0    1    2    3    4    5    6
任务队列: [T1, T2, T3, T4, T5, T6, T7, T8] (假设 maxThread=3)

执行过程:
T0: queue=[T1,T2,T3], workers=[T4,T5,T6,T7,T8]
     ↓ 并发执行T1,T2,T3

T1: T1完成 → queue=[T2,T3], workers=[T4,T5,T6,T7,T8]
     → fullQueue() → queue=[T2,T3,T4], workers=[T5,T6,T7,T8]
     → 启动T4

T2: T2完成 → queue=[T3,T4], workers=[T5,T6,T7,T8]  
     → fullQueue() → queue=[T3,T4,T5], workers=[T6,T7,T8]
     → 启动T5

... 依此类推,直到所有任务完成

重试与暂停

分片上传阶段

利用线程池和错误收集实现:

  • 失败重试 :每个分片任务失败时,将错误信息(分片索引等)存入 errorList。所有分片首次尝试后,只需重试 errorList 中的任务即可。
  • 暂停 :给所有上传请求设置统一的cancelToken,使用cancelTokenSource.cancel终止所有上传的请求
  • 关于暂停机制的补充说明 :需要特别注意的是,在上传阶段执行暂停操作时,前端实际所做的是中止(cancel) 发出的 HTTP 请求------即前端不再等待和接收后端的响应。然而,此时请求可能已经到达服务器,后端可能仍在处理并持续写入对应的分片 直到完成。对于这些已生成的完整分片,通常有两种处理方式:在后续重试上传同一分片时直接覆盖,或者统一清理
ts 复制代码
    const fn = async(index:number)=>{
        try{
            // 上传逻辑
            ...
        }catch(error){
            // 收集上传失败信息
            status.onUploadErr.push({
                lifeCircle: 'chunkEnd',
                error,
            })
        }
    }
    
  -----------
  
    this.cancelTokenSource = axios.CancelToken.source();
    // 设置统一的cancelToken
    const res = await axios.post(url,data,{
        cancelToken:  this.cancelTokenSource.token
    })
    // 取消所有上传
    cancel() {
      this.cancelTokenSource.cancel('请求取消');
    }
    
分片合并阶段

合并请求是串行 的,上一个合并完成才能执行下一个,失败则终止。我们通过 for循环 + async/await 实现串行,并用一个标志位 isContinueMerge 控制暂停。

ts 复制代码
for (index = p.startIndex; index < this.mergeParams.total; index++) {
    try {
        await mergeFn(index);
    } catch (error) {
        // ...错误处理
        
        // 跳出循环,后续的 merge 不会执行
        break;
    }
}

async mergeFn(){
    if (!this.isContinueMerge) {
        // 当前批次的请求,应该暂停了
        throw new Error('合并暂停');
    }
}

注意:合并请求的暂停是"软暂停",即取消下一个合并请求,而非中止当前正在进行的请求。合并过程中不支持暂停,所有使用标志位控制

上传速度与进度计算

实时计算速度与进度,给用户良好的反馈。

ts 复制代码
const beforeUpload = Date.now()
await requestFn()
const afterUpload = Date.now();

 // 计算当前分片上传耗时
const duration = (afterUpload - beforeUpload) / 1000; // 秒

if (duration > 0 && chunkSize > 0) {
    speed = chunkSize / duration; // 分片上传速度 byte/s
    this.totalBytes += chunkSize; // 已上传的文件大小
}

用户体验与交互设计

  1. 模态框管理:上传过程通常放在模态框中:

    • 上传过程中,禁用遮罩层关闭,右上角关闭按钮点击会出现需取消的提示。
    • 提供清晰的取消、重试按钮
  2. 资源清理 :弹窗关闭后,调用服务端清理接口,删除暂存的无效分片,避免存储资源浪费。

展望与优化空间

  1. Web Worker 解放主线程 :计算MD5是CPU密集型任务,会阻塞UI渲染导致页面卡顿。将其放入 Web Worker 中运行,可解决部分性能问题,保证页面流畅。
  2. 更健壮的重试机制:为每个分片上传增加重试策略,避免网络抖动造成的瞬时失败。
  3. 上传速度平滑与预估:计算平均速度而非仅当前分片速度,并基于此预估剩余时间,体验更佳。
  4. 上传进度的实时感知:目前分片上传依靠前端存储的状态来感知上传状态,如果用户刷新浏览器重置前端状态,没有相应的接口机制去实时获取上传状态(如上传信息、上传阶段等等)从而造成上传状态丢失
相关推荐
南雨北斗3 小时前
JS的对象属性存储器
前端
Lotzinfly3 小时前
12个TypeScript奇淫技巧你需要掌握😏😏😏
前端·javascript·面试
一个大苹果3 小时前
setTimeout延迟超过2^31立即执行?揭秘JavaScript定时器的隐藏边界
javascript
开源之眼3 小时前
React中,useState和useReducer有什么区别
前端
普郎特3 小时前
"不再迷惑!用'血缘关系'彻底搞懂JavaScript原型链机制"
前端·javascript
可观测性用观测云3 小时前
前端错误可观测最佳实践
前端
恋猫de小郭3 小时前
Android 将强制应用使用主题图标,你怎么看?
android·前端·flutter
一枚前端小能手3 小时前
「周更第3期」实用JS库推荐:Lodash
前端·javascript
艾小码3 小时前
Vue组件到底怎么定义?全局注册和局部注册,我踩过的坑你别再踩了!
前端·javascript·vue.js