大文件上传:断点续传与秒传

在前端开发中,如果是上传几张普通的图片或者十几 MB 的短视频,直接使用原生的 FormData 配合 AJAX 或者 Fetch API 把文件发给后端就可以了。

但是,如果用户要上传一个 2GB 或者 10GB 的高清蓝光电影,或者是巨型的日志压缩包,直接整个文件硬传,你面临的将是地狱级的体验:

  1. 浏览器崩溃风险:一次性把几个 GB 的文件读进内存,低端电脑直接卡死,浏览器原地白屏。
  2. 极速白给的网络:上传了 99.9% 时,用户不小心断网了或者关闭了页面,前面的 1 个多小时全部白费,只能从 0% 重新传。
  3. 网关并发限制:通常 Nginx 等网关会限制单次请求的 Body 大小,大文件直接被拦截。

为了应对这些致命痛点,企业级大厂的网盘产品(如百度网盘、阿里云盘)都采用了一套极其成熟的解决方案:切片上传 + 断点续传 + 秒传

1. 切片上传(分块上传)

核心思想:大而化小,分摊风险。

不要一次性把 2GB 的文件塞给 HTTP 请求,而是利用 HTML5 File 对象的 slice() 方法(File 对象继承自 Blob),把 2GB 的大文件切成无数个比如大小为 5MB 的小切块(Chunk)。 前端并发地(或者分批次地)把这一个个 5MB 的小块上传给后端,后端接收完所有切片后,再由前端发起一个“合并请求”,后端把切片拼接回完整的 2GB 文件。

核心实现逻辑

// 定义切片大小,例如 5MB
const CHUNK_SIZE = 5 * 1024 * 1024; 

function createChunks(file) {
  const chunks = [];
  let current = 0;
  
  // 利用 Blob.prototype.slice 进行物理切分(极快,不耗内存)
  while (current < file.size) {
    chunks.push(file.slice(current, current + CHUNK_SIZE));
    current += CHUNK_SIZE;
  }
  return chunks;
}

// 模拟上传核心流程
async function uploadFile(file) {
  // 1. 切片
  const chunks = createChunks(file);
  
  // 2. 包装并上传切片(附带索引、文件名等信息让后端知道怎么拼)
  const requests = chunks.map((chunk, index) => {
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('hash', `${file.name}-${index}`); // 切片的唯一标识
    formData.append('filename', file.name);
    
    // 返回 axios 等请求的 promise
    return request({ url: '/upload-chunk', data: formData });
  });
  
  // 3. 并发上传所有切片
  // 优化点:使用 Promise.all 并发上传,但极多切片时通常要做并发控制(如 p-limit 控制在 3-5 个并发)
  await Promise.all(requests);
  
  // 4. 通知后端合并切片
  await request({ url: '/merge', data: { filename: file.name, size: CHUNK_SIZE } });
}

2. 断点续传

核心痛点:如果上传到一半断网了,刚才传的 1GB 全白传了吗?

既然我们已经切片了,断点续传的逻辑就非常简单了: 只需跳过那些已经成功上传的切片,只传还没传(或者传失败了)的切片即可。

实现步骤

  1. 前端在发起上传前,先利用完整文件的内容计算出一个 文件唯一识别码 (Hash值)
  2. 发送请求问后端:“这个 Hash 为 xyz123 的文件,你那里有已经接收完毕的切片索引列表吗?”
  3. 后端返回已存在的切片索引数组(例如 [0, 1, 2, 5, 8])。
  4. 前端在并发上传切片数组前,把这些已经传过的索引过滤掉,剩下的再 Promise.all 发起请求。这就是断点续传

计算文件 Hash(SparkMD5)

如果用文件名作为 Hash 是不靠谱的(两个不同的电影可能都叫 video.mp4)。通常我们会读取文件的二进制内容利用 spark-md5 这个库来计算。 性能坑:读取 2GB 文件算 MD5 会导致 JS 线程长时间阻塞卡死页面。解决方案通常是用 Web Worker 开辟后台线程去算,或者用抽样算 Hash的魔法(只取文件头 2M + 尾部 2M + 中间抽样组合来算 MD5,牺牲极小概率的碰撞来换取瞬间算出的极速体验)。

3. 秒传

你是不是经常在百度贴吧拿了一个极其珍稀资源的磁力链,扔到百度云盘离线下载,结果几十个 GB 的蓝光原盘,刚按确认键 1 秒钟就提示“下载/上传完成”?

这就是秒传的魔法。

核心逻辑

  1. 你的前端用抽样算法极速算出了这个电影的 MD5 Hash 值发送给后端去“查重”。
  2. 后端的数据库里一查:哦豁,这个 Hash 表明这电影我服务器的硬盘里早就有哪怕一份别人传过的完整文件了。
  3. 后端直接返回给前端:“好了,上传成功。”
  4. 系统只是在你的用户网盘数据表里插入了一条指向这个真实文件的记录(软链接),你瞬间拥有了它。

高频面试题剖析

Q:如果有用户需要上传一个 2GB 大小的视频文件,你的前端团队会怎么设计这套上传架构?详细说说切片、断点续传以及极速秒传的实现原理。

回答思路:

  1. 起手式:抛出痛点分析:直接硬传会导致浏览器内存爆炸被杀死、超时容易导致全盘白费没得重试、触发网关 Size 限制。所以大文件必上“切片上传”。
  2. 拆解切片上传(基础分):利用 File 原型链上的 Blob.slice 方法将文件逻辑分割成固定大小(如 5M)的切片数组。然后利用 FormData 裹挟切片、切片索引和文件名,并发(通常使用 Promise 的并发控制维持在 4-6 个请求)发给服务端。全都 Resolved 后发出 /merge 合并指令。
  3. 引申断点续传(进阶分):提到核心在于利用 MD5 给文件赋予身份。在切片上传前,先携带文件 hash 询问服务端对应的切片接收清单,前端在本地切片数组中 .filter() 过滤掉已经完成上传的块,仅将剩下的发出去。这就在宏观上实现了不仅能续传,还可以“继续上传传断了的半截子”。
  4. 展露架构巧思秒传与性能(加满分)
    • 如果算 MD5 太慢卡死主渲染线程,可以说使用 Web Worker 去后台沉默认。
    • 更狠的优化是提及“抽样 MD5”:大文件不必全量读,只摘取首尾和中间的几个片段合并做 MD5 摘要,能将几秒的卡顿压缩至几毫秒。
    • 最后解释秒传的真谛:“秒传不是传得快,而是网盘服务端利用这个 Hash 查到他库里早就有实体,直接在表里给你挂个软链接指向它,于是假装告诉你传完了。”