前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版

大文件上传实战,Node.js 分片上传断点续传与秒传完整落地

首页2024-10-05 14:40:12Front-End
大文件上传分片上传断点续传秒传Node.jsWeb Worker

用户传一个 2GB 的培训视频,点了上传,进度条走到 80% 网络抖了一下,整个请求超时,回来得从 0 重传。运维那边还会顺带告诉你 Nginx 的 client_max_body_size 被打爆了,网关日志里全是 413。这类问题单靠调大超时时间和请求体上限是解决不了的,得换一套上传姿势。

这篇把分片上传、断点续传、秒传这三件事从原理到代码完整走一遍,前端切片和 Hash 计算、Node 端接收与合并、Web Worker 和抽样 Hash 优化、并发控制,最后附一份上线前的 checklist 和面试速答版,照着做能直接落到项目里。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 分片上传、断点续传、秒传三者的关系和各自的代价
  • 一张图看清浏览器和 Node 服务端之间的四次交互
  • File / Blob.slice / FileReader / FormData 这几个 API 在链路里各干什么
  • 前端切片与 spark-md5 计算文件指纹的完整实现,以及内存上的坑
  • Node 端接收分片、校验状态、流式合并的实现,含几个容易写错的地方
  • Web Worker、抽样 Hash、并发控制、失败重试四种优化手段
  • 上线前的安全与稳定性 checklist
  • 面试里怎么用两分钟把这套方案讲清楚

# 一、三个概念和它们之间的关系

先把名词捋清楚,这三件事是层层递进的,不是三个平行选项。

分片上传是地基。把一个大文件按固定大小切成若干个 Chunk,每个 Chunk 单独发一个 HTTP 请求,服务端收齐之后再拼回完整文件。

大文件分片上传示意,一个大文件被切成多个 chunk 分别发送到服务端后合并

切开之后立刻带来三个好处。单个请求体从 2GB 变成 1MB,超时和 413 的问题直接消失。多个分片可以并行发,带宽利用率上去了。服务端不用把整个文件读进内存,内存曲线平了。

断点续传是建在分片之上的。既然文件已经是一片一片传的,服务端就能记住「哪几片收到了」。客户端每次上传前先问一句「这个文件传到哪了」,服务端返回还缺哪几片,客户端只补那几片就行。

秒传是断点续传的极端情况。如果服务端发现这个文件已经完整存在了,那一片都不用传,直接返回成功。实现秒传的前提是能给文件算出一个稳定的唯一标识,通常用 MD5 之类的 Hash 算法生成文件指纹。

所以这三件事共用同一套基础设施,那就是「切片 + 文件指纹」。指纹算出来了,秒传和断点续传就都是顺手的事。

# 二、整体链路,浏览器和服务端聊四次

在写代码之前,先把整条链路的交互顺序画出来,后面每一节都是在填这张图里的某一格:

┌──────────────┐                                     ┌──────────────┐
│   浏览器      │                                     │  Node 服务端  │
└──────┬───────┘                                     └──────┬───────┘
       │ ① File.slice 切片 + spark-md5 算 fileHash          │
       │    (大文件放 Web Worker,别卡主线程)              │
       │                                                     │
       │ ② POST /verify  { fileHash, totalCount, extname }   │
       ├────────────────────────────────────────────────────►│
       │                                                     │ 查
       │ ◄────────────────────────────────────────────────── │ chunkFile/<hash>/
       │    neededFileList = []        → 秒传,直接结束       │
       │    neededFileList = [3,7,9]   → 断点续传,只补 3 片  │
       │    neededFileList = [1..N]    → 全量上传             │
       │                                                     │
       │ ③ POST /upload  FormData(chunk,index,hash)  × N     │
       ├════════════════════════════════════════════════════►│ 落盘
       │      并发受控 · 单片失败单片重试 · 更新进度条         │ chunk-<index>
       │                                                     │
       │ ④ POST /merge   { fileHash, extname }               │
       ├────────────────────────────────────────────────────►│ 按索引排序
       │ ◄────────────────────────────────────────────────── │ 流式拼接
       │                  { ok, url }                        │ 删分片目录
@前端进阶之旅: 代码已经复制到剪贴板

四次交互里,第二次是整套方案的大脑。秒传、断点续传、全量上传三条分支全靠它的返回值决定,前端只需要照着 neededFileList 干活。

# 三、动手前要先认识四个浏览器 API

# 3.1 File 对象

File 是一种特殊的 Blob,从 <input type="file"> 或者拖拽事件里拿到,带着这几个只读属性:

属性 描述
File.name 文件名
File.size 文件大小(字节)
File.type 文件 MIME 类型
File.lastModified 最后修改时间

这里有个坑要注意,File.type 是浏览器根据扩展名猜的,不可信。真要做类型校验得读文件头的 magic number,光看 type 很容易被改后缀绕过。

# 3.2 Blob.slice 做切片

切片靠的就是 Blob.slice(start, end),它返回一个新的 Blob,指向原文件的一段区间。

const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
const chunk = blobSlice.call(file, start, end); // 返回指定范围的 Blob
@前端进阶之旅: 代码已经复制到剪贴板

mozSlice 和 webkitSlice 是很老的前缀写法,现在的浏览器都支持标准的 File.prototype.slice,这段兼容代码留着没坏处,但新项目直接写 file.slice(start, end) 就够了。

slice 有一个特别关键的性质,它是惰性的,不会真的把那段数据读进内存,只是记了一个偏移量。后面讲内存优化的时候会用到这一点。

# 3.3 FileReader 读取内容

要算 Hash 就得拿到二进制内容,这一步靠 FileReader:

const fileReader = new FileReader();
fileReader.readAsArrayBuffer(blob); // 读取为 ArrayBuffer
fileReader.onload = (e) => {
  console.log(e.target.result); // 读取结果
};
@前端进阶之旅: 代码已经复制到剪贴板

FileReader 是回调式的老 API。现在 Blob 上直接有 arrayBuffer() 方法返回 Promise,写起来清爽得多,配合 for await 循环读分片会比回调链好维护。老代码里的 FileReader 不用急着改,但新写的建议直接用 await blob.arrayBuffer()。

# 3.4 FormData 组装请求

分片上传走 multipart/form-data,前端用 FormData 组装:

const formData = new FormData();
formData.append('chunk', new Blob([chunkData])); // 文件分片
formData.append('index', 1); // 分片索引
formData.append('fileHash', 'xxx'); // 文件哈希
@前端进阶之旅: 代码已经复制到剪贴板

注意别自己手动设 Content-Type。传 FormData 时浏览器会自动带上带 boundary 的 Content-Type,你手写一个反而会把 boundary 弄丢,服务端解析直接失败。这个我踩过,排查方向很容易跑偏到后端去。

# 四、Step 1 前端切片并计算文件指纹

第一步要同时产出两样东西,分片列表和文件 Hash。因为算 Hash 本来就要把文件整个读一遍,顺手就把分片攒出来了。

import SparkMD5 from 'spark-md5';

/**
 * 将目标文件分片并计算文件 Hash
 * @param {File} targetFile 目标上传文件
 * @param {number} baseChunkSize 分片大小(单位 MB)
 * @returns {chunkList: ArrayBuffer[], fileHash: string}
 */
async function sliceFile(targetFile, baseChunkSize = 1) {
  return new Promise((resolve, reject) => {
    // 兼容不同浏览器的分片方法
    const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;

    const chunkSize = baseChunkSize * 1024 * 1024;
    const targetChunkCount = Math.ceil(targetFile.size / chunkSize);
    let currentChunkCount = 0;
    const chunkList = [];

    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();

    fileReader.onload = (e) => {
      const curChunk = e.target.result;
      spark.append(curChunk); // 追加到 Hash 计算
      currentChunkCount++;
      chunkList.push(curChunk);

      if (currentChunkCount >= targetChunkCount) {
        // 全部读取完成,获取文件 Hash
        const fileHash = spark.end();
        resolve({ chunkList, fileHash });
      } else {
        loadNext();
      }
    };

    fileReader.onerror = () => reject(null);

    const loadNext = () => {
      const start = chunkSize * currentChunkCount;
      const end = Math.min(start + chunkSize, targetFile.size);
      fileReader.readAsArrayBuffer(blobSlice.call(targetFile, start, end));
    };

    loadNext();
  });
}
@前端进阶之旅: 代码已经复制到剪贴板

这段代码是串行读的,一片读完在 onload 里触发下一片。串行是必须的,因为 spark.append() 的调用顺序决定了最终 Hash 值,乱序算出来的指纹每次都不一样,秒传立刻就废了。

SparkMD5.ArrayBuffer 这个类的价值就在这里,它支持增量计算。你可以喂它一片一片的数据,最后调 end() 拿结果,全程不需要把整个文件驻留在内存里。浏览器原生的 crypto.subtle.digest() 只接受一次性的完整数据,做不到流式,这也是大文件场景下大家还在用 spark-md5 的原因。

不过这版实现有个明显的内存问题。

fe
  • 一、三个概念和它们之间的关系
  • 二、整体链路,浏览器和服务端聊四次
  • 三、动手前要先认识四个浏览器 API
    • 3.1 File 对象
    • 3.2 Blob.slice 做切片
    • 3.3 FileReader 读取内容
    • 3.4 FormData 组装请求
  • 四、Step 1 前端切片并计算文件指纹
  • 五、Step 2 上传前先问一句服务端
  • 六、Step 3 发分片并更新进度
  • 七、Step 4 服务端接收、校验与合并
    • 7.1 接收分片
    • 7.2 校验上传状态
    • 7.3 流式合并分片
  • 八、性能优化的四个方向
    • 8.1 Web Worker,把 Hash 计算挪出主线程
    • 8.2 抽样 Hash,用一点碰撞概率换速度
    • 8.3 并发控制,别一次把请求全发出去
    • 8.4 失败重试
  • 九、方案对比与选择建议
  • 十、上线前 checklist
  • 十一、面试怎么答
  • 总结
  • 参考

← MobX 核心概念解析与 React 协同最佳实践,以及 2026 年的替代选择ESLint 9新特性、重大变化与从8升级的完整攻略 →