简介:StreamSaver.js 提供了一种突破浏览器内存与 Blob 大小限制的客户端流式保存方案,它借助 Service Worker 和响应头模拟服务器下载行为,让数据以可写流方式直接落盘,特别适合移动端等 RAM 受限场景下的大文件生成与下载。这份资源包共 18 个文件、约 26KB,其中 10 个 HTML 示例覆盖了普通文本、视频流、Blob 保存、多文件保存、zip 流、torrent 等典型用法,另有 3 个 JS 脚本(含核心 StreamSaver.js 与 sw.js)和 README 说明文档,便于快速上手与二次修改。目前已有 2922 人浏览学习。通过阅读示例与源码,开发者可掌握 StreamSaver 的集成方式、Service Worker 注册要点以及流式写入的完整思路,适合需要在浏览器端可靠保存大体积数据的 Web 工程师。
1. 为什么浏览器里下载大文件总让人血压升高
我说个场景,你应该也经历过:页面上有个文件要下载,几百 MB 甚至几个 GB,点击之后先是等半天,然后浏览器标签页一直转圈,下载进度条走到一半突然断了,重新再来一遍。更难受的是,如果这个文件是动态生成的,比如导出报表、打包图片素材、合成视频,服务器那边付出了大量计算和 IO 成本,结果你这边连接一断,释放连接,一切白干。
传统浏览器下载方案的核心痛点是:要么让服务器先攒出完整文件再给浏览器,要么用 Blob 把整个内容塞进内存再触发下载。前者造成服务端临时文件堆积,后者直接吃光客户端内存。你当然可以用分片下载、断点续传,但那是服务端配合的情况下;如果文件是实时生成的流,你根本没机会让它“先完整存在”。
StreamSaver.js 解决的就是这个场景:让浏览器把流式数据直接异步写入文件系统,不需要暂存内存,不需要服务端生成临时文件。你从网络请求里拿到一个 ReadableStream,往 StreamSaver 创建的写入端一推,浏览器就会借助 Service Worker 把数据块持续落到磁盘,用户看到的是“保存文件”窗口,感觉和在网盘里下载一个已有文件一模一样。
这不是一个什么花哨的库,但它解决了一个非常“硬”的工程问题。凡是做过大文件导出、实时日志下载、音视频录制结果保存的开发者,都应该认识它。接下来的内容,我会从原理、实操、踩坑、性能优化四个维度把它讲透。
2. StreamSaver 的工作机制:Service Worker 如何充当“隐形搬运工”
2.1 从 Blob URL 到流式写入的本质差异
先搞懂一个概念:浏览器下载一个“已存在”的文件,和下载一个“正在生成”的文件,走的完全是两条路。
普通下载的本质是把完整数据变成一条本地文件,浏览器在拿到完整 HTTP 响应体之前,其实已经把接收到的字节写入临时文件了。下载管理器里看到一个进度条,是因为浏览器自己处理了流式落盘。你写的普通前端代码接触不到这个机制,你只能用URL.createObjectURL(blob)生成一个链接,然后让用户点击它。Blob 的问题在于,整个文件必须完整存在于内存或磁盘缓存中,才能生成对象 URL。一个 2GB 的文件,先把 2GB 攒出来,再让用户下载,这中间的等待和内存占用基本都是不可接受的。
StreamSaver 的思路是:利用 Service Worker 拦截浏览器的下载请求,然后劫持响应流。你把生成的数据块通过WritableStream写入,Service Worker 再把这些数据块转发给浏览器的下载会话。用户感觉不到 Service Worker 的存在,但实际数据是一块一块被“搬运”到磁盘的,而不是先堆在内存里。
2.2 建立下载链路的三个关键角色
要跑通 StreamSaver,整个链路里有三个角色必须配合:
- 前端页面:负责创建
WritableStream,并从网络或其他异步源获取数据。 - Service Worker:负责注册、拦截下载链接,把流从页面转发到浏览器底层下载机制。
- 浏览器下载会话:负责把收到的数据块持久化到磁盘,展示下载进度。
其中一个关键点是:StreamSaver 依赖一个公用的 Service Worker 文件(streamsaver.sw.js),它需要被部署在应用的根路径下或者你能控制的静态目录中。为什么必须要同一个作用域?因为 Service Worker 作用域决定了它能拦截哪些请求。通俗讲,它只能管住自己目录下的请求。如果你把streamsaver.sw.js放在/static/下,那它的拦截范围就局限在/static/路径内的请求。为了稳妥不折腾,就把它放到根目录,让作用域覆盖整个站点。
一个典型的初始化代码是:
import streamSaver from 'streamsaver'; streamSaver.mitm = '/mitm.html'; // 这是一个可选的中间页面,用于跨域场景 const fileStream = streamSaver.createWriteStream('report.csv');createWriteStream返回一个WritableStream,你可以像使用普通流一样往里面写数据。如果不设置mitm,该库会在同源模式下工作,适合大多数常规场景。
2.3 为什么异步在这里是“必须”而不是“可选”
既然是“将流直接异步写入文件系统”,那“异步”二字就不是多余的修饰。StreamSaver 的接口设计完全基于 Streams API,你往WritableStream写入时,写入操作返回的是一个 Promise,调用方可以await它来确定这一块数据是否已经被消费。
这带来一个重要好处:背压控制。假设你的数据源是一个不断产生数据的 WebSocket,或者一个高频读取的本地文件,你如果同步把所有数据怼进流里,数据会堆积在内存中。而使用异步写入,每一块写入都有明确的完成信号,你可以根据写入速度选择暂停生产数据或丢弃旧数据。对应到流式下载场景,就是用户那边磁盘写入速度慢,你这边不会无限积压数据导致内存暴涨。
const writeStream = streamSaver.createWriteStream('video.mp4'); const writer = writeStream.getWriter(); while (hasMoreData) { const chunk = await fetchNextChunk(); await writer.write(chunk); } await writer.close();这段代码看起来平淡无奇,但await writer.write(chunk)并不是象征性的。它意味着每次写入都要等这一块数据真正被 Service Worker 转发出去才会执行下一步。如果省略这些 await,生产者速度远高于消费者,内存占用数字会变得相当难看。
3. 实操:从零搭建一个支持流式下载的前端模块
3.1 环境准备和服务端部署
StreamSaver.js 可以从 npm 直接安装:npm install streamsaver。但真正的部署重点在 Service Worker 文件。
你需要把node_modules/streamsaver/streamsaver.sw.js复制到你项目的静态资源根目录。这一步很多人会忘记,结果运行时发现 Service Worker 注册失败,下载没有任何反应。这个文件不能改路径吗?可以改,但你在调用createWriteStream时需要同步指定streamSaver.sw = '/你的路径/streamsaver.sw.js'。不同版本 API 可能不同,以你安装的版本文档为准,但原则就是一个:Service Worker 文件必须能被浏览器以 HTTPS 或 localhost 方式访问到,并且作用域覆盖你要下载的页面。
Service Worker 文件具体负责什么?它运行在浏览器后台线程,不在页面主线程里,因此可以维持一个与下载会话的连接。页面把数据写入,Service Worker 接收后转发到下载模块。这个中间层让数据不经过主线程的 Blob 转换,也不需要把整个文件加载进 JS 堆。
3.2 生成一个完整的下载任务
下面用一个实际例子串起整个过程。假设我们后端提供了一个接口,会返回一个大文件的分块数据流:
async function downloadLargeFile(url, filename) { const response = await fetch(url); const reader = response.body.getReader(); const fileStream = streamSaver.createWriteStream(filename); const writer = fileStream.getWriter(); const pump = async () => { while (true) { const { done, value } = await reader.read(); if (done) { await writer.close(); break; } await writer.write(value); } }; await pump(); }运行这段代码后,浏览器会弹出保存文件对话框,用户选择路径,然后数据开始后台写入。你可以继续在页面上操作其他功能,不会因为下载大文件而卡住页面。
源码里reader.read()每读出一块数据,马上就writer.write(value),整个管线是逐块流动的,最大内存占用只是当前这一块数据的大小,而不是整个文件大小。这就是流式下载最核心的优势。
3.3 事件监听与下载状态反馈
下载文件时,最好给用户展示一个进度条。StreamSaver 本身不提供“总进度”概念,因为创建流的时候你往往不知道总字节数。可行的做法是:如果后端接口能在响应头里给出Content-Length,你可以提前在 fetch 的响应对象里读取它,然后统计累计写入的字节数,计算百分比。
const response = await fetch(url); const totalBytes = Number(response.headers.get('Content-Length')) || 0; let receivedBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; receivedBytes += value.length; updateProgress(receivedBytes / totalBytes * 100); await writer.write(value); }这里有个坑需要提醒:如果接口用的是 chunked 传输编码,或者服务端应用了 gzip 压缩,那Content-Length可能是压缩后的大小,甚至是无法获取的。经验做法是总长度显示为“未知”,进度条改为“正在下载”的动画。等文件流结束,再按真实写入量展示。
4. 实际项目里的避坑记录与排查思路
4.1 Service Worker 不生效:最常见的翻车点
我在第一次集成的时候,遇到下载对话框始终不弹出,控制台也没有报错。后来排查发现是 Service Worker 没有正确注册。因为streamsaver.sw.js被构建工具打上了 hash 指纹,路径变成了/static/js/streamsaver.sw.abc123.js,而这个库内部是相对自身文件路径去寻找 SW 的,找不到就直接静默失败。
解决方法:要么把 Service Worker 文件拷贝到public目录并保持固定文件名,要么在初始化时手动指定streamSaver.sw = '/streamsaver.sw.js'。建议前者,越简单的方案越不容易坏。
另一个容易忽略的问题是:如果应用部署在子路径下,比如example.com/tools/,而你希望页面在/tools/download下使用,那么 Service Worker 文件路径和 scope 都要匹配。否则,SW 只能拦截/tools/末尾的请求,导致下载目标 URL 不在控制范围内。
4.2 CORS 与中间页面配置
如果你从前端页面请求的是一个跨域接口,浏览器会先执行一次 CORS 预检。如果接口服务器没有正确返回Access-Control-Allow-Origin,fetch 会直接抛错,你根本拿不到response.body。
这里和 StreamSaver 本身关系不大,但它会让流式下载无法启动。如果你遇到了在控制台直接报 NetworkError,先用普通 fetch 测试一下这个接口能否在仓库环境正常访问。如果确认 CORS 正常但还是没反应,再检查 Service Worker 路径。
在某些场景下,StreamSaver 建议配置一个中间页面mitm.html,用于绕过某些浏览器对响应流的限制。它的原理是:页面在同一个标签页里打开另一个 iframe,在这个 iframe 中发起实际响应转发,从而让数据在拓扑上变成同源请求。配置方式:
streamSaver.mitm = '/mitm.html';如果你在开发环境一切正常,但生产环境下载失败,先检查是否漏掉了这一步。部分浏览器安全策略更严格,必须有中间页面配合才能把流成功写进下载会话。
4.3 内存占用为什么会随下载时间线性增长
有朋友问我:“你不是说流式写入不会吃内存吗?为什么下载一个 2GB 文件,浏览器内存涨到了 1GB?”
这里要区分两个概念:写入端的内存占用,和整个页面上下文的内存占用。如果你从 fetch 拿流,reader.read()每取出一块数据就写入、释放,这个循环本身内存占用很小。但如果你的代码在循环里把每个 chunk push 到一个数组里,或者记录日志时保留了全部 chunk 的引用,那内存自然线性增长。
还有一种情况:浏览器对读取流的分块大小有内部缓冲策略,当你写入太快,而磁盘写入慢时,缓冲区可能堆积。为了解决这个,你可以让写入速率受控于writer.desiredSize。如果desiredSize很小或为 0,说明写入端背压了,你应该暂停生产。这也是我在 2.3 里强调异步写入必要性的原因:它让你能感知背压,而不是盲目生产。
5. 兼容性评估与生产环境性能调优
5.1 哪些浏览器能跑,哪些会出问题
StreamSaver.js 依赖以下三项技术:Service Worker、ReadableStream、WritableStream。这三项在现代 Chrome、Edge、Firefox 里都表现不错,在 Safari 上属于“勉强可用”状态,特别是一些流式 API 在旧版本 Safari 上存在实现差异。如果你维护的是一个工具站,用户群体浏览器版本较新,问题不大;如果你要面向企业客户的旧浏览器,就得做功能降级。
降级方案并不复杂:判断window.WritableStream && navigator.serviceWorker,如果条件不满足,就回退到传统 Blob 下载方案。这样至少能保证功能可用,只是大文件可能比较卡。
| 浏览器 | Service Worker | Streams API | StreamSaver 表现 |
|---|---|---|---|
| Chrome 80+ | 支持 | 支持 | 很好 |
| Edge 80+ | 支持 | 支持 | 很好 |
| Firefox 75+ | 支持 | 支持 | 基本可用 |
| Safari 14+ | 支持 | 部分支持 | 有限支持 |
| IE | 不支持 | 不支持 | 无法使用 |
常见的网络下载场景中,Chrome 用户量最大,所以技术选型上可以优先保证 Chromium 内核的表现,再把 Safari 作为第二优先级测试。
5.2 让下载进度更精准的实践经验
前面提到过通过Content-Length计算进度。在生产环境,接口经常会做 gzip 压缩,这会让Content-Length变成压缩体积,而实际写入文件的是解压后的数据,这会导致进度比超过 100%。
处理思路有两个:
- 后端在接口层明确设置
Content-Encoding: identity,保证传输体积等于文件实际体积。代价是传输带宽变大。 - 后端在自定义响应头里返回一个
X-File-Size,明确告知文件未压缩大小。前端优先读取这个头,读取不到再用Content-Length。
我在项目里选的是第二种,因为很多文件是已经压缩过的视频和图片,再压一遍纯属浪费 CPU。自定义响应头的方式比较干净,也不用改动现有网关层。
const totalBytes = Number(response.headers.get('X-File-Size')) || Number(response.headers.get('Content-Length')) || 0;如果你把X-File-Size也拿不到,那就别显示具体百分比了,改用“已下载 x MB”这种累计数字,体验也不会差到哪里去。
5.3 同时下载多个文件的并发控制
StreamSaver 每次调用createWriteStream都会生成一个新的下载会话。理论上你可以同时创建多个流并行下载,但我不建议这么做。
原因很直接:每个下载任务会注册一个独立的 Service Worker 响应流通道,浏览器同时维护多个这样的通道会产生明显开销,而且用户同时面对多个下载对话框,体验也不好。更合理的做法是做个下载队列,一次只触发一个文件,或者至少限制并发数为 2 以内。
关于你说并发数定 2 的理由,简单说就是操作系统层面的磁盘写锁竞争:同一时间段内写入两个大文件会导致磁盘寻址频繁切换,机械盘尤其明显,SSD 好一些,但只要并发过多,总吞吐反而下降。
实现一个简单的串行队列并不复杂:
async function downloadAll(items) { for (const item of items) { await downloadLargeFile(item.url, item.filename); } }downloadLargeFile函数本身会等到writer.close()完成,所以循环天然是串行的,不用额外维护状态机。
6. 几个值得沉淀的代码封装模式
6.1 适配多种数据源的统一写入器
实际开发里,数据源不一定都是 fetch。有时候是 WebSocket 推送,有时候是本地生成的分块数据,还有可能是从 IndexedDB 里读出来的二进制片段。为了方便复用,可以封装一个独立于数据来源的写入器:
class StreamSaverWriter { constructor(filename) { this.filename = filename; this.writer = null; this.receivedBytes = 0; } init() { const fileStream = streamSaver.createWriteStream(this.filename); this.writer = fileStream.getWriter(); } async writeChunk(chunk) { if (!this.writer) this.init(); this.receivedBytes += chunk.byteLength || chunk.length || 0; await this.writer.write(chunk); } async end() { await this.writer.close(); } abort() { this.writer.abort(); } }这样,在业务代码里不管数据从哪里来,只要调用writeChunk就完事了,底层究竟是文件还是网络,对上层完全透明。
6.2 错误恢复与断流处理
流式写入过程中,网络断开会触发reader.read()抛出异常。异常后的正确处理方式不是直接把错误抛给用户就完事,而应该执行以下几步:
- 取消当前写入任务,调用
writer.abort(),释放资源。 - 记录已写入的字节数。
- 提示用户选择“重试”或“尝试断点续传”。
由于 HTTP 请求没有断点续传的天然支持,你只能通过 Range 请求从根本上解决。若后端接口支持 Range,你可以在错误发生后重新发起fetch(url, { headers: { Range: 'bytes=已写入字节-' } }),从上次位置继续读流。这依赖服务端实现,但一旦配上,体验会好很多。
async function readWithRetry(url, offset, onChunk) { const headers = offset > 0 ? { Range: `bytes=${offset}-` } : {}; const response = await fetch(url, { headers }); const reader = response.body.getReader(); while (true) { try { const { done, value } = await reader.read(); if (done) break; await onChunk(value); } catch (err) { console.error('read stream broken, retrying', err); // 归还资源,然后重新调用自身 await reader.cancel(); await readWithRetry(url, offset + receivedBytes, onChunk); } } }这个封装里没有处理无限重试的风险,你可以加一个最大重试次数,超过后提示“下载失败,请手动重试”。
7. 我在实际项目中总结的最后几点经验
第一,不要把 StreamSaver 当成一个“万能下载库”。它的使用场景非常集中:动态生成的大文件、实时流、服务端临时数据。如果你的文件已经静态存在于 OSS 或 CDN 上,直接用普通文件下载地址让浏览器下载就好,绕一圈接入 StreamSaver 反而增加了 Service Worker 的复杂度和潜在故障点。
第二,测试时一定要分别验证“有进度”“无进度”“下载中断”“跨域请求”这四种情况。只测 happy path 会在上线后被真实用户教做人。至少准备一个接口跑通 HTTP 分块传输的场景,一个接口跑通普通Content-Length的场景,再用跨域接口验证 CORS 配置。
第三,留意 Service Worker 的更新机制。你部署了新的streamsaver.sw.js,浏览器不会立刻使用它,可能需要刷新两次或者手动在 DevTools 里触发更新。调试时如果改了 SW 文件不起作用,不要怀疑自己的代码,先想想是不是浏览器缓存了旧 SW。
最后想多说一句,前端这几年能处理越来越大的文件,靠的不是单个 API 的魔法,而是 Service Worker、Streams API、文件系统访问 API 这堆底层能力组合起来的结果。StreamSaver.js 把这些能力封装成了可一键接入的库,但理解它背后的数据搬运链路,比单纯会调用几个方法重要得多。遇到诡异问题时,顺着“页面发数据 → SW 接数据 → 浏览器下载模块落盘”这条链路逐层排查,基本都能找到答案。
本文还有配套的精品资源,点击获取