下载 5GB 文件导致浏览器崩溃?教你用 Fetch 流式直写硬盘,内存 0 消耗!
大家周三好。
在日常开发中,前端其实很少直接和系统的“硬盘”打交道。
以前我们下载文件,本质上是把数据拉到浏览器的“沙箱内存”里,然后再由浏览器出面,把数据写进用户的默认下载文件夹。
但当数据量来到GB 级别时,浏览器的内存限制(通常单页限制在 1.5G – 2G 左右)就会成为不可逾越的高墙。
面试官问:“如果要求带鉴权 Token 下载一个 5GB 的文件,并且有进度条,同时页面不能卡死崩溃,你怎么做?”
如果你还在死磕Blob和createObjectURL,那说明你对内存管理毫无概念。
今天,我们用一套纯前端的管道流(Streams)技术,实现数据从网卡到硬盘的“直通车”。
01. 核心破局:Streams API 的魔法
把网络请求比作水流。
Axios Blob的做法是:拿一个巨大的水缸(内存)把水全部接满,然后再一口气倒进下水道(硬盘)。水缸不够大,就炸了。
Streams API的做法是:直接接一根水管(Pipe)。网卡收到一滴水,就直接流进硬盘里,水缸(内存)里永远只有一点点水。
在现代浏览器中,fetch请求的response.body本身就是一个ReadableStream(可读流)。
02. 实战:打开通向硬盘的“下水道”
要实现流式写入,我们需要借助 2026 年已经非常成熟的File System Access API。
它允许浏览器直接向操作系统申请创建一个“可写的文件句柄”。
代码实战(极其震撼的极简写法):
// 🌟 点击下载按钮时触发async function downloadHugeFile() {try {// 1. 唤起系统原生的“另存为”对话框,让用户选保存在哪const fileHandle = await window.showSaveFilePicker({suggestedName: '2026_system_logs_5GB.zip',});// 2. 创建一根通向该文件的“下水管”(可写流)const writableStream = await fileHandle.createWritable();// 3. 发起网络请求(完美支持带 Token)const response = await fetch('/api/download/huge-log', {headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}});if (!response.ok || !response.body) throw new Error('网络异常');// 4. 🌟 终极魔法:直接把“网络可读流”接入“硬盘可写流”!// 这一行代码,底层会自动处理背压(Backpressure),内存消耗近乎为 0await response.body.pipeTo(writableStream);console.log('✅ 5GB 文件下载并写入硬盘完成!');} catch (err) {if (err.name !== 'AbortError') {console.error('下载失败:', err);}}}
发生了什么?
就一句response.body.pipeTo(writableStream),浏览器在底层用极低的 C++ 级别开销,把数据直接从网卡倒进了硬盘。
不管文件是 5MB 还是 50GB,你的页面依然能保持 60FPS 的丝滑滚动!
03. 进阶需求:老板说必须要有进度条!
pipeTo虽然爽,但它是个黑盒,无法汇报进度。
为了实现进度条,我们需要自己接管“水流”的搬运过程。
手写流式搬运代码:
<scriptsetuplang="ts">import { ref } from 'vue';const progress = ref(0);const downloadWithProgress = async () => {const fileHandle = await window.showSaveFilePicker({ suggestedName: 'data.zip' });const writable = await fileHandle.createWritable();const response = await fetch('/api/download', {headers: { 'Authorization': 'Bearer xxx' }});// 获取文件总大小 (后端必须配置 Access-Control-Expose-Headers: Content-Length)const totalLength = Number(response.headers.get('Content-Length')) || 0;const reader = response.body.getReader();let receivedLength = 0;// 🌟 循环:一瓢一瓢地把水舀到硬盘里while (true) {const { done, value } = await reader.read();if (done) {// 读完了,关掉硬盘写入流await writable.close();progress.value = 100;break;}// 将这一瓢水(Uint8Array 切片)写入硬盘await writable.write(value);// 更新进度条receivedLength += value.length;if (totalLength > 0) {progress.value = Math.floor((receivedLength / totalLength) * 100);}}};</script>
这段代码不仅保证了极低内存占用,同时你拿到了每一块数据流(value),进度条实时跳动,用户体验拉满!
04. 避坑指南:Safari 兼容性降级方案
肯定会质问:“showSaveFilePicker是 Chrome/Edge 的专属 API,如果用户用的是 Safari 怎么办?”
高分回答:Service Worker 拦截方案 (StreamSaver.js 原理)
如果遇到不支持的浏览器,我们会退回到经典的下载模式。但是,如何解决 Header 带 Token 的问题?
降级架构:
1、前端依然用fetch去拉取流,拿到ReadableStream。
2、我们利用Service Worker创建一个虚拟的 URL(如/download-proxy)。
3、让<a>标签指向这个虚拟 URL。
4、Service Worker 拦截这个请求,把我们前端拿到的那个ReadableStream直接返回给浏览器底层的下载管理器。
这个方案极其巧妙,社区已经有非常成熟的轮子:StreamSaver.js。
在实际企业级开发中,我们通常这样写:
import streamSaver from 'streamsaver';if (window.showSaveFilePicker) {// 首选:Native API 硬盘直写useNativeFilePicker();} else {// 降级:使用 StreamSaver 处理数据流const fileStream = streamSaver.createWriteStream('huge_data.zip');response.body.pipeTo(fileStream);}
结语
从“全量加载到内存”到“基于 Stream 的管道流处理”。
这不仅仅是解决了一个 5GB 文件下载的 Bug,更是前端工程师迈向系统级编程思维的重要一步。
不要再把浏览器当成简单的“页面渲染器”了,它现在是一台极其强大的虚拟机。
当你掌握了 Stream,无论多大的数据,在你的代码里,也不过是静静流淌的一条小溪。
📚 往期硬核架构与提效推荐 (点击补课)
如果你也是在复杂的 B 端业务中摸爬滚打,这几篇“避坑指南”千万别错过:
[前端导出大解密]:实用小技能 (02):教你用纯前端导出“带底色、合并单元格”的完美 Excel
[高频假死终结者]:WebSocket 每秒推送 50 次,Vue 3 直接卡死?这 3 个底层 API 救了我的命
[AI 全栈实录]:实用小技能 (06):我把 DeepSeek 接进中后台,干掉了 1000 行 Excel 校验代码
[剪贴板黑科技]:别再让用户上传 Excel 了!教你用 Clipboard API 实现“Ctrl+C 粘贴入库”
👉点击上方蓝字,偷偷把高阶套路融进你的日常开发里。
夜雨聆风