ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

大文件上传下载方案详解:XHR分片与断点续传实战

2026/10/5 2:58:33 拓冰建站 浏览量
大文件上传下载方案详解:XHR分片与断点续传实战 做网页端文件传输这些年被问得最多的就是超过 1GB 的文件为什么用 HTML5 页面传不是传到一半断掉就是把浏览器整个卡死答案其实不在带宽而在大多数实现都在用一次性把整个文件吞进内存再发送的方式硬刚大文件。HTML5 真正擅长的是把文件拆开、流式读写、并行调度组合起来才能优雅解决大文件上传下载。这篇文章我会把目前前端最实用的三种大文件上传下载方案讲透经典的 XHR 分片上传加 Range 断点下载、Streams API 配 File System Access API 的流式读写、Web Worker 并发分片加 IndexedDB 断点续传。每种方案都会给出能直接落到项目里的关键代码、服务端协作要点以及我在实际项目中踩过的坑。1. 动手写代码前先把大文件传输的底层逻辑打通很多前端同学一上来就搜分片上传 代码结果代码复制过去了文件一大照样挂。原因是你没理解 HTML5 大文件方案背后的三块基石Blob 的虚拟切片、断点续传与秒传的工作机制以及浏览器网络模型对并发的限制。这三件事搞不清楚后面再怎么调参都是碰运气。1.1 Blob.slice 到底做了什么File对象继承自Blob所以你可以直接调用file.slice(start, end)得到一个子块。很多人误以为执行 slice 的时候浏览器已经把这一段数据读进内存了于是担心切 1000 个分片会撑爆内存。实际并不是这样。Blob 的 slice 操作本质上只是在底层文件数据上划出了一段引用范围类似于你在 PDF 里用框选选中了一部分内容——这个动作本身不复制数据。真正把字节读出来发生在后面发送给服务器、或者调用 stream/arrayBuffer 读取的那一瞬间。所以切片这个动作非常廉价你可以放心大胆地对一个 10GB 文件进行切片而不是真的在内存里拷贝了 10GB 数据。1.2 断点续传和秒传是怎么回事断点续传的思路很简单把大文件切成 N 段客户端记录已经成功上传的分片编号网络中断或者用户关掉页面后下次重新上传时跳过已完成的编号即可。秒传则是在文件上传前先在本地算出文件的唯一标识一般是 MD5 这类哈希值发给服务端查询。如果服务端发现这个文件之前已经传过完整的数据比如另一个用户传过同一个文件或者同一个用户传过这个文件直接返回一个上传完成标记前端连分片都省了。需要注意的是秒传本质上不是上传技术而是判断文件是否已存在的技术。真正的大文件传输方案必须把分片上传、断点续传、秒传三者揉在一起才能应对真实网络环境。1.3 三种方案的适用范围速览先给一张总览表后面每一章再展开讲。这张表同样可以作为你选型时的第一道过滤网。方案核心技术上传能力下载能力兼容性实现成本方案一XHR Blob.slice Range分片上传、手写断点续传分片下载后内存拼接适合中小文件IE10 全兼容中等方案二Streams API File System Access APIfetch 流式请求体上传网络流直接落盘内存占用极低Chrome/Edge 支持好Firefox/Safari 较差低方案三Web Worker IndexedDB 并发控制多线程并发分片、强断点续传需要配合方案二或服务端流式返回现代浏览器均可高2. 方案一XHR Blob.slice 分片上传配 Range 请求断点下载这是最经典的一种方案后端语言无关浏览器兼容性也最好。核心思路是上传时把文件切成固定大小的分片逐个 POST 给服务端由服务端按分片顺序合并下载时客户端用 HTTP Range 请求分段拉取字节最后在浏览器内拼接成完整文件。2.1 上传端的关键代码与并发控制先看最基础的上传切片逻辑。假设用 5MB 一分片实现如下const CHUNK_SIZE 5 * 1024 * 1024; const file fileInput.files[0]; const totalChunks Math.ceil(file.size / CHUNK_SIZE); const fileMd5 await calcFileHash(file); // 见下方说明 async function uploadChunk(index) { const start index * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const blob file.slice(start, end); const fd new FormData(); fd.append(chunk, blob); fd.append(index, index); fd.append(fileMd5, fileMd5); fd.append(total, totalChunks); return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload/chunk); xhr.upload.onprogress (e) { if (e.lengthComputable) { const chunkProgress e.loaded / e.total; // 整体进度 已完成的完整分片数 当前分片内部进度 / 总分片数 const overall (index chunkProgress) / totalChunks; updateProgressBar(overall); } }; xhr.onload () (xhr.status 200 ? resolve() : reject(new Error(chunk ${index} failed))); xhr.onerror () reject(new Error(chunk ${index} network error)); xhr.send(fd); }); }这里我特意用 XHR 而不是 fetch 做上传就是因为 XHR 有xhr.upload.onprogress事件。fetch 目前没有上传进度的原生事件你只能自己用流去包装复杂度反而上去了。实际问题是对分片逐个串行上传如果一分片在弱网下耗时 30 秒一分片 100 个那理论耗时接近 50 分钟体验很差。所以必须做并发。下面这个poolLimit是一个通用的并发池控制同时最多 3 个分片请求async function poolLimit(tasks, limit) { const queue [...tasks]; const workers new Array(Math.min(limit, queue.length)).fill(0).map(async () { while (queue.length) { await queue.shift()(); } }); await Promise.all(workers); } // 使用 const tasks []; for (let i 0; i totalChunks; i) { tasks.push(() uploadChunk(i)); } await poolLimit(tasks, 3);为什么并发数设在 3 而不是直接拉满因为浏览器对同一域名的 TCP 连接数有限制HTTP/1.1 下一般是 6 个左右你并发 10 个分片时后面 4 个其实在排队表现成假并发反而因为 TCP 连接切换带来额外开销。并发 3 到 5 是比较稳的经验值。如果你的项目跑在 HTTP/2 上可以适当放宽到 6 到 10 个。2.2 服务端需要配合什么这套方案的前端只负责把分片传到固定接口能不能落地取决于服务端三个接口是否健壮第一个是分片上传接口POST /api/upload/chunk接收表单里的 chunk 二进制、index、fileMd5把分片数据落盘。命名规则建议是fileMd5_index.part方便后面排序合并。第二个是合并接口POST /api/upload/merge。前端把所有分片传完后调用服务端读取该 fileMd5 对应的所有.part文件按 index 排序后按二进制流依次写入最终文件。合并完成后删除临时分片文件。第三个是查询已上传分片接口GET /api/upload/status?fileMd5xxx。这是断点续传的关键。前端在开始上传前先请求这个接口拿到服务端已存在的分片 index 数组然后跳过这些分片。Node.js 里分片合并的核心代码大致是这样import { readdirSync, createReadStream, createWriteStream, unlinkSync } from fs; import { join } from path; const dir join(uploadDir, fileMd5); const parts readdirSync(dir) .filter((name) name.endsWith(.part)) .sort((a, b) { const ia parseInt(a.split(_)[1]); const ib parseInt(b.split(_)[1]); return ia - ib; }); const output createWriteStream(join(uploadDir, ${fileMd5}.bin)); for (const part of parts) { await new Promise((resolve, reject) { const rs createReadStream(join(dir, part)); rs.pipe(output, { end: false }); rs.on(end, resolve); rs.on(error, reject); }); unlinkSync(join(dir, part)); } output.end();注意这里的pipe(output, { end: false })意思是每写完一个分片不要自动关闭目标文件流所有分片都写完后再手动output.end()否则第一个分片写入会把文件流关掉。2.3 这个方案的真实边界别拿它下载 2GB 文件分片下载的思路同样是 Range 请求。客户端先知道文件总大小然后循环请求一段段字节最后拼接成一个 Blob 触发浏览器保存async function downloadInChunks(url, totalSize) { const CHUNK 10 * 1024 * 1024; const parts []; for (let start 0; start totalSize; start CHUNK) { const end Math.min(start CHUNK - 1, totalSize - 1); const res await fetch(url, { headers: { Range: bytes${start}-${end} }, }); if (res.status ! 206) { throw new Error(服务端不支持 Range 请求无法分片下载); } parts.push(await res.blob()); } const finalBlob new Blob(parts, { type: application/octet-stream }); const objectUrl URL.createObjectURL(finalBlob); const a document.createElement(a); a.href objectUrl; a.download file.zip; a.click(); URL.revokeObjectURL(objectUrl); }这段代码前 99% 都没问题但 Blob 最后会被浏览器放进内存。也就是说对于 500MB 以内的文件这套下载还能用一旦超过 1GB浏览器内存占用会直线上升移动端浏览器基本会直接白屏。所以方案一的下载能力上限大约在 500MB 左右再大的文件要优先考虑后面的方案二。另外一点容易踩坑如果服务端没有正确处理 Range 请求也不返回 206 状态码而是直接把整个 2GB 文件返回fetch 会把文件全部拿进内存这比分片下载失败更糟。所以客户端一定要判断res.status ! 206时给出明确提示不要闷头往下走。3. 方案二Streams API File System Access API把文件写到用户磁盘如果你问浏览器里下载 5GB 视频怎么不让内存爆掉方案二就是答案。它利用两个现代浏览器能力Streams API 允许你像管道一样处理数据File System Access API 允许网页在用户授权后直接操作本地文件系统。两者结合后浏览器拉到的网络流可以不经过整块内存直接落盘。3.1 大文件下载pipeTo 直接把流落到本地磁盘先看下载场景。目标文件是 2GB 的压缩包传统下载方式是浏览器自动下载不走网页逻辑但在网页内做流式下载并显示进度代码可以精简到核心三步async function streamDownload(url) { // 1. 让用户选择保存位置拿到本地文件句柄 const handle await window.showSaveFilePicker({ suggestedName: archive.zip, }); // 2. 创建可写流 const writable await handle.createWritable(); // 3. 发起网络请求拿到响应流直接管道到本地文件 const res await fetch(url); if (!res.ok || !res.body) throw new Error(download failed); await res.body.pipeTo(writable); }中间没有任何arrayBuffer()没有Blob数据是以流的形式一点一点写入磁盘的。你可以在 Chrome 任务管理器里观察下载 2GB 文件的内存占用始终平稳这是传统 Blob 拼接方案完全做不到的。如果在下载过程中需要实时进度可以在网络流和文件流之间插一个 TransformStream自己数经过的字节数let loaded 0; const progressStream new TransformStream({ transform(chunk, controller) { loaded chunk.byteLength; onProgress(loaded / totalSize); controller.enqueue(chunk); }, }); await res.body.pipeThrough(progressStream).pipeTo(writable);TransformStream在这里就像水管上的一个流量计数据经过它时不改变内容只负责计数并把数据继续向后传。3.2 大文件上传用 TransformStream 做进度体面运行上传方向同样可以用流。file.stream()会返回一个 ReadableStream读取文件内容时按块产出数据不会把整个文件装进内存。把它直接作为 fetch 请求体发送const file fileInput.files[0]; let loaded 0; const uploadStream file.stream().pipeThrough(new TransformStream({ transform(chunk, controller) { loaded chunk.byteLength; onProgress(loaded / file.size); controller.enqueue(chunk); }, })); const response await fetch(/api/upload/stream, { method: POST, body: uploadStream, duplex: half, // 关键fetch 发送流式请求体必须标注 duplex });这里的坑有两个。第一个是duplex: half这是 fetch 规范要求发送流式请求体时必须显式声明的参数漏掉会直接报错。第二个是服务端必须能读取流式请求体不能依赖 Content-Length。用 Express 加express.raw({ type: */* })可以接收原始流或者直接在 Node.js 里用import { createWriteStream } from fs; const req httpRequest; // 以 Node HTTP 模块为例 const writeStream createWriteStream(/tmp/upload.bin); req.pipe(writeStream); req.on(end, () writeStream.end());这种方案的好处是内存占用恒定代码量也小。坏处是断点续传需要你自己设计如果用户传了一半断网服务端文件已经写入了一部分但你不知道具体写到了哪个字节。解决的办法是服务端记录已接收字节数下次上传时告知客户端从哪个 offset 继续。但这比方案一按分片续传实现要麻烦所以它更适合不需要断点续传追求极低内存的场景比如浏览器端把临时大文件转发到对象存储。3.3 兼容性现实Chrome/Edge 真香但 Firefox 要注意这套方案最大的限制是兼容性。showSaveFilePicker目前只有 Chromium 系内核支持良好Firefox 和 Safari 都还不支持file.stream()和TransformStream的兼容范围稍广但组合使用时还是建议做降级判断。我的做法是写一个能力检测不支持的浏览器自动退回方案一的下载方式if (showSaveFilePicker in window) { await streamDownload(url); } else { await downloadInChunks(url, totalSize); }另外注意showSaveFilePicker只能在用户手势触发的异步流程里调用比如点击事件中。如果用户在脚本里直接调用浏览器会拒绝因为这是权限 API。页面还需要运行在 HTTPS 或 localhost 环境下否则 API 根本不存在。4. 方案三Web Worker 并发分片 IndexedDB 断点记录网盘级上传体验如果你要做的产品是网盘、视频平台、文件协作工具用户要频繁上传几十 GB 的文件方案一那种主线程手写并发 内存里存状态的做法远远不够。方案三是目前前端能把上传体验做到接近原生客户端的组合Web Worker 负责并发上传不阻塞界面IndexedDB 负责持久化上传状态真正做到关页面重开后还能接着传。4.1 多线程并发上传的架构与代码整个架构分三层主线程负责切片和调度Worker 负责真正的网络请求IndexedDB 负责状态持久化。主线程先切好分片列表然后按并发度创建几个 Worker给每个 Worker 发一条消息让它上传一个分片并返回结果。这里强调一点不要把整个文件对象 postMessage 给 WorkerBlob 虽然在结构化克隆中不会立刻把全部数据拷贝进内存但大对象的克隆开销仍然不小。正确做法是主线程一次只给 Worker 发一个具体分片。Worker 内部代码如下// upload-worker.js self.onmessage async (event) { const { index, blob, fileMd5 } event.data; const fd new FormData(); fd.append(chunk, blob); fd.append(index, index); fd.append(fileMd5, fileMd5); try { const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload/chunk); xhr.upload.onprogress (e) { if (e.lengthComputable) { self.postMessage({ type: progress, index, loaded: e.loaded, total: e.total }); } }; xhr.onload () { self.postMessage({ type: done, index, ok: xhr.status 200 }); }; xhr.onerror () { self.postMessage({ type: error, index, message: network error }); }; xhr.send(fd); } catch (err) { self.postMessage({ type: error, index, message: err.message }); } };主线程的调度逻辑可以用任务队列 固定 Worker 数实现。这里给一个实战可用的简洁版本const WORKER_COUNT 4; const workers []; let nextChunkIndex 0; const failed []; function spawnWorker() { const w new Worker(/upload-worker.js); w.onmessage (event) { const msg event.data; if (msg.type done !msg.ok) failed.push(msg.index); scheduleNext(w); }; return w; } function scheduleNext(worker) { if (nextChunkIndex totalChunks) return; const index nextChunkIndex; worker.postMessage({ index, blob: file.slice(index * CHUNK_SIZE, Math.min((index 1) * CHUNK_SIZE, file.size)), fileMd5, }); } // 启动 for (let i 0; i WORKER_COUNT; i) { const w spawnWorker(); scheduleNext(w); }这套结构的好处是任何 Worker 完成一个分片后主线程立即给它派发下一个分片永远不会出现某个连接空闲、其他连接排队的不均衡问题。4.2 断点状态不入 localStorage进 IndexedDB很多人做断点续传时喜欢用 localStorage 存已上传分片编号这在小文件上没问题。但一个 3GB 的文件可能切成 600 个分片localStorage 配额只有 5MB 左右而且每次更新都得整体序列化效率很低。IndexedDB 无论存储容量还是读写粒度都更合适。核心设计思路以fileMd5作为任务 ID存储完整的任务元信息和已上传的分片索引数组const db await new Promise((resolve, reject) { const req indexedDB.open(bigfile-upload, 1); req.onupgradeneeded () req.result.createObjectStore(tasks, { keyPath: id }); req.onsuccess () resolve(req.result); req.onerror () reject(req.error); }); function saveTask(task) { const tx db.transaction(tasks, readwrite); tx.objectStore(tasks).put(task); } function loadTask(id) { return new Promise((resolve) { const tx db.transaction(tasks, readonly); const req tx.objectStore(tasks).get(id); req.onsuccess () resolve(req.result || null); }); }上传前先查询服务端状态并合并本地 IndexedDB 记录最终决定跳过哪些分片const task await loadTask(fileMd5); const serverChunks await fetch(/api/upload/status?fileMd5${fileMd5}).then((r) r.json()); const doneSet new Set([...(task?.doneChunks || []), ...(serverChunks || [])]);这里有个经验一定要以服务端的已传分片为准本地 IndexedDB 记录只是用来减少一次服务端查询。服务端才是数据最终落地的真相本地记录可能因为浏览器崩溃、页面被强制关闭而延迟。4.3 并发开多少、分片子多大我的实测参照Web Worker 并发不是无脑越大越好。实测下来有两个明显现象第一个是 HTTP/1.1 时代浏览器单域名的连接上限在 6 个左右Worker 并发数超过 6 后并不会让服务器同时收到更多请求多余的 Worker 只是在等待。所以 HTTP/1.1 环境建议并发 4 到 6 个。第二个是在 HTTP/2 环境下虽然连接可以多路复用但每个分片请求都会独立进行 TCP 拥塞控制。当并发分片过多时每个连接分到的带宽会下降总吞吐量不升反降。我的项目里用 4 到 6 个 Worker 并发上传速度和并发 16 个 Worker 时几乎一样但 CPU 占用和内存开销明显更低。分片大小建议设在 5MB 到 10MB。分片太小时比如 1MB一个 20GB 文件会产生 2 万个请求服务端的临时文件数和网络请求上下文都会爆炸分片太大时比如 100MB断点续传的粒度太粗弱网下失败一次要重传整个 100MB代价太大。综合来看 5MB 到 10MB 是最均衡的区间。5. 三套方案的实测对比和真正的选型逻辑很多文章到这里就结束了但我觉得选型比实现更重要。下面给出我的实测结果、服务端协作约定以及几个让我加班修 bug 的坑。5.1 从 500MB 到 20GB不同规模怎么选文件规模推荐方案原因500MB 以内方案一XHR 分片 Blob 拼接实现简单、兼容性好内存压力可控500MB 到 2GB方案二Streams File System Access API内存占用低下载体验最好弱网下也稳2GB 以上方案三Worker IndexedDB为主下载再叠方案二需要断点续传、并发加速、持久化状态是唯一能长期扛大文件上传的架构具体到业务场景还要细分内部管理系统经常要传几百 MB 的报表方案一完全够用云盘类产品用户动辄传 2GB 以上的视频素材必须上方案三否则传一半断网重来用户直接流失。5.2 和服务端对接时必须敲死的几个约定任何前端方案都要服务端配合这几个约定一定要在接口文档里写死分片命名规则fileMd5_index.part保证按 index 排序即文件顺序。分片大小前后端必须一致服务端如果发现分片大小异常要直接报错。合并校验前端在合并接口里提交文件总大小和总 MD5服务端合并后计算整体 MD5 并比对不一致说明有分片损坏或缺失。断点状态接口的语义返回的是已完整接收并校验通过的分片 index 数组而不是正在接收的分片。否则前端会跳过没写完的分片合并时文件损坏。5.3 我踩过的坑和修正后的处理方式最后分享几个真实案例。第一个是进度条不准确。早期我用xhr.upload.onprogress除以文件大小 / 分片大小当整体进度结果发现进度条走到 80% 后要卡很久才结束因为服务端要合并文件。这个问题其实很简单上传进度只是传输进度服务端合并、校验是另一个阶段。正确做法是在界面上区分上传中 80%和合并中 20%不要混在一条进度条里。第二个坑是并发数太高导致服务端文件句柄耗尽。上线测试那天运维发现服务器的/tmp目录生成了 20 万个.part文件临时文件数直接把磁盘 inode 打满了。原因是客户端开了 50 个并发分片而服务端每接一个分片就建一个临时文件。后来我前端把并发降到 6服务端加了分片上传前的去重逻辑问题才平息。第三个坑是关于 MD5 计算。大文件在浏览器端算完整 MD5 非常耗时一个 5GB 的文件可能要 2 分钟以上而且会把 CPU 拉满。如果你不做秒传功能完全可以不计算整个文件的 MD5直接用文件大小 文件名 修改时间生成一个弱标识即可如果要做秒传建议用 Web Worker 分片计算并把计算结果缓存到 IndexedDB避免用户重复上传同一个文件时二次计算。第四个坑发生在方案二的下载上我当时以为showSaveFilePicker在任何浏览器都能调用结果 Firefox 上用户单击下载按钮没有任何反应控制台报showSaveFilePicker is not a function。后来加了能力检测并回退到方案一才算彻底解决。目前我的线上项目在公网环境下默认走方案三上传配合方案二做下载整体跑了两三百个批次的大文件传输稳定性比早期的单请求上传好了一个量级。前端大文件传输这件事没有银弹但把这三种方案吃透绝大多数场景都能找到最合适的解法。