ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

前端大文件分片下载:Range并发控制与断点续传完整实践

前端大文件分片下载:Range并发控制与断点续传完整实践 1. 分片下载到底解决了什么先搞清楚你面对的真实问题我前阵子在公司做内部工具时接到一个需求页面上要能下载一批几百 MB 到 2GB 左右的离线地图包、模型文件偶尔还要配合录播视频做本地回放。看到这个需求的第一反应是——这不就是个a标签加download属性的事直到测试环境里,Chrome 老老实实下了 300MB 的文件速度就是上不去还时不时整个下载失败从头再来。这才意识到前端大文件下载的核心问题从来不是能不能下载而是怎么把一个大文件稳定、快速、可恢复地拿下来。分片下载或者说基于 HTTP Range 的并发分片下载就是目前前端解决这个问题最实际、最通用的一套思路。这篇文章整理的是我实测下来的一套完整方案从头到尾讲清楚Range 协议怎么用、并发控制怎么写、断点续传怎么做、内存峰值怎么压以及什么时候根本不建议用分片。适合正在做下载功能的前端开发也适合想彻底搞懂这套机制的面试准备者——网上讲分片上传的多把分片下载完整讲透的少这篇算一个补漏。2. Range 断点协议分片下载的地基原理聊分片之前必须先想清楚一个问题浏览器凭什么能从文件的第 100MB 开始下载答案就是 HTTP 协议里的 Range 头。2.1 服务端必须支持的范围请求当浏览器或者我们用fetch在请求头里带上Range: bytesstart-end时服务端如果支持范围请求会返回206 Partial Content并且在响应头里附带Content-Range和Content-Length分别表示当前切片在整个文件中的起止位置和当前切片大小。Range: bytes104857600-209715199对应的响应头大概是HTTP/1.1 206 Partial Content Content-Range: bytes 104857600-209715199/1048576000 Content-Length: 104857600如果服务端不支持 Range它会直接返回200 OK和整个文件的内容。这条路子在排查问题的时候很重要——你以为是前端并发代码写错了搞了半天服务端每次都在返回整个文件分片形同虚设。所以工程上第一步不是写代码是先确认服务端开了范围请求。Nginx 默认开着但如果你是内部系统、后端 Java 写的文件下载接口很可能在网关层把那几个头给吞了。常见的排查方式是在浏览器地址栏直接输入文件 URL看响应头里有没有Accept-Ranges: bytes。这是最省事的前置验证。2.2 Content-Length 与切片规划的计算逻辑拿到总长度之后前端要做的是把整个文件切成若干个区间。这里有一个容易忽略的点Content-Length返回的是整个资源的字节数不管有没有Content-Encoding压缩处理。如果你在搞 JS/CSS 之类的文本资源服务端开了 gzip长度就会变得很微妙好在我们做的是大文件下载绝大多数是二进制文件不会走压缩逻辑。遇到不确定的情况可以用HEAD请求先探一下。切片规划公式很简单总字节数除以目标切片数量向上取整得到每片大小。比如 1GB 文件1073741824 字节目标并发 8 片chunkSize Math.ceil(1073741824 / 8) 134217728 字节每一片的起止位置就是第i片的start i * chunkSize、end Math.min(start chunkSize - 1, total - 1)。注意最后一个字节是total - 1不是total因为字节区间是左闭右闭的。这个规划看起来简单却直接决定了下载效率。分片数太少单片的下载时间依然很长某一层网络抖动就可能导致整个片失败重来分片数太多并发请求把带宽打满的同时也把服务器压力拉满了甚至可能触发服务器的连接数保护。我的经验是先定并发数、再反推分片大小而不是死板地固定每片 10MB。3. 两种主流实现形态的取舍fetch 拼接与直写磁盘确认服务端支持 Range 后接下来要选实现路径。前端主流的下载方案大致分两类一种是用fetch拉分片到内存全部完成后再合并成一个 Blob 触发下载另一种是借助浏览器的新 API 直写磁盘。这俩差距非常明显直接影响你能下多大的文件。3.1 传统方案fetch 分片 Blob 合并最稳妥、绝大多数场景都适用的方案是用fetch或XMLHttpRequest请求每个分片的Range区间把返回的ArrayBuffer存起来所有分片下载完成后拼接成一个大的Blob再通过URL.createObjectURL生成临时链接触发下载。这方案的优点很直接兼容性好几乎所有现代浏览器都能跑fetch配合response.arrayBuffer()写起来非常顺手控制逻辑都在主线程 JS 里想加进度、加超时、加重试都方便。缺点也很致命——大文件会直接吃爆内存。举个例子一个 2GB 的文件缓存对应ArrayBuffer或Blob的方式不同在 V8 堆内存里占的空间少则翻倍多则更多字符串、TypedArray 都可能触发拷贝一顿操作下来内存直接 3~4GB 起步普通办公电脑瞬间卡死甚至直接把标签页干崩溃。3.2 进阶方案File System Access API 直写磁盘对于超大文件一般指超过 1.5GB 或设备内存有限的场景Chrome 系浏览器以及部分基于 Chromium 的桌面端支持showSaveFilePicker拿到用户授权后的文件句柄配合createWritable可以直接把分片拿到的数据写到磁盘文件写完全部后 close。这样内存里几乎不留全量数据峰值可控得多。const handle await window.showSaveFilePicker({ suggestedName: target.bin, }); const writable await handle.createWritable(); // 每拿到一片 ArrayBuffer就 await writable.write(new Uint8Array(buffer)) await writable.close();每个分片拿到之后即时write到文件句柄内存里只存当前分片的数据写完之后释放。对超大文件来说这是很实用的保命方案。代价是 Safari 和 Firefox 目前不支持所以这是典型的能加则加、不能加则降级我在实际项目里就是把它作为fetch拼接方案的后备增强。3.3 为什么不建议用 XMLHttpRequest很多老教程习惯用XMLHttpRequest下载分片理由是它支持responseType blob看起来比较原生。但fetch在这件事上功能更强、写法更干净尤其配合AbortController做超时中断比 XHR 舒服得多。XHR没有流式读取的能力数据一样要一次性进内存。除非你需要兼容 IE 系浏览器否则没有理由用 XHR 做分片下载。4. 核心代码一个可直接复用的并发分片下载器不纸上谈兵直接放一个我在项目里打磨过的版本。这个版本基于fetch支持并发控制、进度回调、失败重试最后合并成 Blob 触发浏览器保存。代码里有不少注释是踩坑后补上去的建议仔细看。4.1 请求文件信息 切片规划先发一个HEAD请求或带Range: bytes0-0的 GET 请求拿到文件总大小和是否支持范围请求。我习惯用后者因为在某些服务端配置里HEAD会被拦掉或者漏了关键头。async function getFileMeta(url) { const res await fetch(url, { headers: { Range: bytes0-0 }, cache: no-store, }); const total Number(res.headers.get(Content-Range).split(/)[1]); const contentLength Number(res.headers.get(Content-Length)); const acceptRanges res.headers.get(Accept-Ranges); if (res.status ! 206 || acceptRanges ! bytes) { throw new Error(服务端不支持范围请求无法分片下载); } return { total, chunkSize: Math.ceil(total / DEFAULT_CONCURRENCY), supportRange: true, }; }注意这里的细节Content-Range长这样bytes 0-0/1073741824用split(/)[1]拿到的才是总大小。有些服务端返回的Accept-Ranges是none或者压根没这头那么你得走整文件下载的降级逻辑不要硬着头皮分片。4.2 分片下载函数带重试和超时每个分片的下载函数要独立这样后续加哪片失败重试哪片的逻辑才方便。async function downloadChunk(url, start, end, signal) { const res await fetch(url, { headers: { Range: bytes${start}-${end} }, signal, cache: no-store, }); if (res.status ! 206) { throw new Error(分片下载失败状态码: ${res.status}); } const total Number(res.headers.get(Content-Length)); const reader res.body.getReader(); const chunks []; let received 0; while (true) { const { done, value } await reader.read(); if (done) break; chunks.push(value); received value.length; } if (received ! total) { throw new Error(分片长度异常: 期望 ${total}实际 ${received}); } return new Blob(chunks); }这段代码里有一个我踩过的坑不能只判断res.status 200就当作成功有的服务端不管你传了什么 Range 都返回 200 整个文件。所以必须强制校验206且校验实际收到的字节数和Content-Length一致。不一致的话说明连接中途断了但 TCP 没报错——这种情况不校验就合并最后会拼出一个损坏文件而且很难排查。cache: no-store也要加。之前遇到过一个诡异问题每次重新下载进度条秒满但文件打不开。后来查出来是浏览器 HTTP 缓存把之前的响应直接返回了根本没走网络——大文件下载必须绕开缓存。4.3 并发控制与整体流程用最简单的信号量思路控制并发数不要用什么高级的线程池库核心就是一个计数器加一个任务队列。我这边把并发数调成 6实测下来 Nginx 和小型文件服务不会觉得有压力同时速度也够跑满 500M 宽带。如果你想猛一点8 也没问题但超过 10 之后收益就很不明显了反而容易触发一些网关对单 IP 连接数的限制。async function runWithConcurrency(tasks, concurrency) { const results new Array(tasks.length); let index 0; const worker async () { while (true) { const current index; if (current tasks.length) break; results[current] await tasks[current](); } }; const workers Array.from({ length: concurrency }, () worker()); await Promise.all(workers); return results; } async function downloadFile(url) { const { total, chunkSize } await getFileMeta(url); const chunkCount Math.ceil(total / chunkSize); const tasks Array.from({ length: chunkCount }, (_, i) { const start i * chunkSize; const end Math.min(start chunkSize - 1, total - 1); return () downloadChunk(url, start, end); }); const blobs await runWithConcurrency(tasks, DEFAULT_CONCURRENCY); return new Blob(blobs); }拿到全部 Blob 之后下一步是触发保存。这里也有讲究不要直接用a.download触发就算了对大文件最好加一层确认避免用户以为页面卡死了function triggerDownload(blob, filename) { const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download filename; link.click(); URL.revokeObjectURL(url); }URL.revokeObjectURL放在click()之后立即执行不要想着等文件下载完再释放。这个 URL 对象此时已经和点击行为绑定立即释放不会中断下载。但要注意如果你是 Spinner 显示进度并在进度完成时触发记得在触发后把大 Blob 引用设null尽早交给 GC。5. 断点续传与失败重试刷新页面也不能断分片下载只解决快还解决不了网络抖一下全盘重来的尴尬。真正能打的方案一定要有断点续传。这部分的实现核心是记录分片状态。5.1 持久化分片元数据每个分片下载完成后把它的索引、范围、下载状态、服务器返回的 ETag如果有存到localStorage或IndexedDB。量小就用localStorage分片多就上IndexedDB不然会爆 5MB 容量。我的做法是设计一个任务对象维度大概是这样const taskMeta { fileUrl, fileName, totalBytes, chunkSize, completedChunks: [0_104857599, 104857600_209715199], timestamp: Date.now(), };每次某个分片完成就把它对应的start_end字符串加进completedChunks然后写回存储。下次页面打开先把completedChunks里的分片跳过只下载缺失部分。理论上有比这个更细致的方案比如记录每个分片的进度百分比半途中断的片从断点继续请求而不是整片重下。这种做法的复杂度会高不少——因为单片请求是Range语义要支持子分片就必须在代码里再加一层范围切片。我做过一次收益很低不值得。分片大小控制在几十 MB 级别的话重新拉一片的成本完全可以接受没必要做两级切片。5.2 失败重试策略指数退避避免雪崩下载过程中网络抖动、服务端瞬间过载、某一片超时都是很常见的。重试策略必须要有但绝不能设计成失败了就立刻重试。瞬时的大量重试会放大服务器压力造成雪崩。我给每个分片加了指数退避重试逻辑async function downloadChunkWithRetry(url, start, end, maxRetries 3) { let lastError; for (let attempt 1; attempt maxRetries; attempt) { try { return await downloadChunk(url, start, end); } catch (err) { lastError err; const delay Math.min(1000 * Math.pow(2, attempt - 1), 8000); await new Promise(resolve setTimeout(resolve, delay)); } } throw lastError; }第一次失败等 1 秒第二次 2 秒第三次 4 秒封顶 8 秒。这个策略在弱网环境的下载成功率提升非常明显。注意一点单一分片连续多次失败不应该立刻影响整体任务可以把它标记为失败片等正常分片下载完后再用剩余并发额度统一重试。这样能把爱出问题的大片和正常片解耦整体等待时间不会因为一个坏片拖死所有片。5.3 刷新恢复的工程细节断点续传能不能真正落地取决于你更新持久化存储的时机。最稳妥的做法是每个分片完成下载后立即把元信息写入存储用事务性写入保证不丢。localStorage的setItem是同步的代码简单写坏的风险低IndexedDB则是异步的注意别让写库操作阻塞下载流程。一个常见缺陷是把状态存在内存里、关闭页面才写一次这样刷新或崩溃就丢了。另一个缺陷是不区分任务多个不同文件的下载任务共用一个 key互相覆盖。推荐的做法是给文件 URL 总大小算一个 hash 作为任务 ID每个任务单独存。这样同一个文件刷新恢复很自然而不同文件互不干扰。6. 内存峰值、真实下载与避免方案2GB 以上文件怎么扛写到这里整个分片下载器的核心功能已经完整了。但在真实生产中这还不够。内存管理、触发真实下载边界、以及超大文件直写磁盘这三件事处理不好依然会踩大坑。6.1 内存峰值的真正来源很多文章说分片下载天然省内存这是个误导。拿fetch拿分片然后存Blob拼成一个大Blob这个过程中内存占用大约是文件体积的 2~3 倍。原因在于每个分片返回的是Uint8Array存在chunks数组里合并成Blob时V8 里可能会有数据拷贝最后URL.createObjectURL会在 Blob 生命周期内继续占用内存。对于 1GB 以下的文件现代笔记本强压一下问题不大超过 1.5GB小内存设备会非常难受。所以我做降级判断预估内存峰值 ≈ 文件总大小 × 2.2 可用内存不够时提示用户走后台下载或服务端打包下载6.2 File System Access API 直写磁盘的进阶形态前面第 3 节简单提到了直写磁盘。这里给一个更完整的用法配合我们已有的分片下载函数async function downloadFileDirectToDisk(url, suggestedName) { const { total, chunkSize } await getFileMeta(url); const handle await window.showSaveFilePicker({ suggestedName, }); const writable await handle.createWritable(); const chunkCount Math.ceil(total / chunkSize); const tasks Array.from({ length: chunkCount }, (_, i) { const start i * chunkSize; const end Math.min(start chunkSize - 1, total - 1); return async () { const blob await downloadChunk(url, start, end); await writable.write(blob); }; }); await runWithConcurrency(tasks, DEFAULT_CONCURRENCY); await writable.close(); }这样每个分片下载完就立即写入文件句柄内存中只保留正在下载的那一片峰值从文件大小降到了单个分片大小。以 2GB 文件、每片约 128MB 为例内存峰值大约几百 MB 级别完全可控。这个 API 目前 Chrome/Edge 支持得不错Safari 和 Firefox 不支持。更好的做法是if (window.showSaveFilePicker)时用直写方案否则降级到 Blob 合并方案。但是注意降级前必须做一次预检看预估内存能否扛住扛不住直接提示用户换浏览器而不是让页面崩溃。6.3 分片下载与浏览器真实下载的行为差异最后讲一个容易被忽略的语义问题分片下载最终是通过Blob和a.download触发的保存行为。这与用户在浏览器里点击普通下载有几个差异不会显示在浏览器下载列表里。用户如果在下载过程中去看下载管理会发现没有这个任务容易误判为下载没开始。无法断点自己点暂停。就算你代码里做了断点续传刷新后能恢复下载管理里也是看不到的。CA 证书、代理等企业环境限制下fetch的 CORS 策略可能直接拦截跨域请求而普通浏览器下载不报跨域错误。这意味着从 CDN 拉文件时服务端必须正确配置Access-Control-Allow-Origin。如果你需要给用户一个接近原生下载的体验还有一种思路是用分片下载先把文件拿下来存到 IndexedDB 或 OPFS再通过 File System Access API 保存到本地或者直接提示用户使用另存为。这听起来绕但在离线包更新大模型权重文件下载这类需要校验文件完整性的场景里这套流程其实更可靠因为你可以在保存前做哈希校验避免保存到一个损坏的文件。7. 实测数据与选型结论什么场景用什么方案按我的实测不同方案在不同文件大小下的表现差异非常大直接给结论文件大小推荐方案原因0 ~ 200MB直接fetch整个文件 Blob 下载分片带来的复杂度收益极小直接下载简单可靠200MB ~ 1GB并发分片 Blob 合并内存压力可控下载速度提升明显代码复杂度适中1GB ~ 3GB并发分片 File System Access API 直写内存峰值是主要矛盾直写磁盘才能稳定跑完3GB 以上服务端打包/异步导出 浏览器轮询前端直下对网络、设备、服务端压力都太大交给后台任务更合理我在一次 1.8GB 语音模型文件的下载测试里用 6 并发、每片约 150MB从原来整包下载的三四分钟波动、经常断压到了稳定 1 分半左右而且分了两个阶段跑内存峰值约 2.5GB1.8GB 文件的 Blob 合并极限改用 File System Access API 后内存峰值降到了大概 500MB用户体感明显变好。8. 避坑清单这个方案最容易被忽视的几个细节挑几个我在排障时花过最多时间的细节单列一节。这些点看起来都不起眼但每一个都足以让整个下载链路崩掉或者数据损坏。8.1 校验分片字节数而不是只信状态码前面代码里已经写到了。206状态码只代表服务端打算给你发一段数据不保证这一段数据的完整性。TCP 层一般会保证有序不丢但应用层代理、网关、缓存中间件都可能拦一脚。我在某个环境里遇到过代理服务器对长连接做了超时静默断开导致最后一个分片缺了几 KB不校验根本发现不了。8.2Content-Length可能缺失或被压缩干扰如果服务端对某些文本资源开启了 gzipContent-Length会显著小于原始大小。还好大文件下载场景里基本都是二进制文件不做压缩。但保不准有人拿它下载 JSON 配置、日志之类的文本文件这种情况要么关闭压缩、要么不要拿Content-Length校验否则必然误判。8.3 并发数不能一味调大有人觉得并发越高越快我实际测过在 100Mbps 的宽带下6 并发和 10 并发的总时间几乎一样在千兆内网下10 并发大约比 6 并发快 15% 左右但服务器 CPU 占用率高出不少。像 Nginx 默认的worker_connections一旦被打满其他正常业务可能跟着遭殃。维护一个合理的默认值让用户能手动调是更健康的设计。8.4 取消下载的时机用户切走了、点了取消需要中断所有未完成的分片请求。用AbortController给每个分片请求绑定同一个 abort signal取消时只要controller.abort()所有在途请求都会立刻中止内存也能尽快释放。8.5 文件名与跨域建议Content-Disposition里如果服务端返回了文件名最好优先用它没有的话再回退到 URL 末尾的文件名或自定义suggestedName。跨域场景配合 CORS这块前面提过不再展开。9. 写在最后的工程经验我在做这个下载器的过程中最大的体会是前端下载这件事技术方案本身并不复杂难的是把边界情况处理到位。文件大到一定程度真正决定方案可不可用的往往不是代码的并发技巧而是你对内存模型、浏览器 API 边界和服务端协商细节的理解。遇到了问题先分清是网络问题、服务端配置问题还是浏览器特性限制再决定从哪一层下手。另外有一件事必须提醒任何分片下载方案都对服务端有真实的压力冲击不要把它当作免费的性能提升魔法。合理评估业务场景小文件不要分片中间文件用并发超大文件优先考虑换一条路——比如后台任务、离线渠道、P2P 分发。前端只是解决方案链路里的最后一公里整条链路稳了才是真正的看这一篇就够了。
返回列表