乐于分享
好东西不私藏

下载 5GB 文件导致浏览器崩溃?教你用 Fetch 流式直写硬盘,内存 0 消耗!

下载 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.bodythrow new Error('网络异常');    // 4. 🌟 终极魔法:直接把“网络可读流”接入“硬盘可写流”!    // 这一行代码,底层会自动处理背压(Backpressure),内存消耗近乎为 0    await 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 粘贴入库”

👉点击上方蓝字,偷偷把高阶套路融进你的日常开发里。