
1. 为什么文件流是前端处理大文件的分水岭很多人第一次接触File对象都是从input typefile或者拖拽上传开始的。拿到File实例之后绝大多数人的第一反应是fileReader.readAsText(file)然后等onload回调把整个文件内容一次性读进内存。小文件没问题几 KB 到几 MB 的文本、图片这么干又快又省事。但只要你处理过一次几百 MB 的日志文件、一段几个 GB 的视频或者需要在浏览器里做分片上传、边下边解析你就会发现FileReader那套一次性读取的模式直接把页面卡死内存飙升标签页崩溃。这就是Streams API存在的意义。它把文件数据从一整块变成一段一段流动的水你可以按需读取、按需处理、按需写入内存占用始终维持在一个很小的水位。File对象本身在规范里就是Blob的子类而Blob从很早就提供了.stream()方法返回一个ReadableStream。也就是说浏览器原生就给了你一条从磁盘文件到内存处理管道的入口只是很多人没注意到。这篇内容适合两类人一类是已经会用FileReader、但对大文件处理感到吃力的前端开发者另一类是想搞清楚ReadableStream、WritableStream、TransformStream到底怎么配合使用、在真实项目里怎么落地的人。我会从File.stream()这个入口讲起把读取、转换、写入、背压、取消、错误处理这些环节全部串一遍并且给出可以直接抄的代码。中间会穿插我自己在分片上传和本地日志解析里踩过的坑这些是文档里不会写的。需要先明确一个前提Streams API不是某个框架的封装它是浏览器和 Node.js 都在逐步对齐的底层标准。你在前端学的这套ReadableStream模型搬到 Node.js 的web streams里基本能复用。所以花时间搞懂它收益不止在一个端。2. 从 File 对象拿到 ReadableStream 的三种姿势2.1 Blob.stream() 是最直接的入口File继承自Blob所以任何File实例都能直接调用.stream()。这个方法不接受参数返回一个ReadableStream它的内部队列会按块chunk吐出Uint8Array类型的数据。const input document.querySelector(input[typefile]); input.addEventListener(change, async () { const file input.files[0]; const stream file.stream(); const reader stream.getReader(); let received 0; while (true) { const { done, value } await reader.read(); if (done) break; received value.byteLength; console.log(收到一块累计字节, received); } console.log(读取完成总大小, received); });这段代码里value是Uint8Array不是字符串。很多人第一次用会下意识地console.log(value)看到一堆数字就懵了。这是二进制流要转成文本得自己用TextDecoder解码后面会专门讲。stream()默认的块大小由浏览器决定通常和底层文件系统的读取块对齐可能是 64KB 左右但这不是你能控制的。如果你需要固定块大小比如做分片上传时每片必须是 1MB那就不能依赖默认行为得自己切。2.2 用 slice 手动切块再转流分片上传场景里我一般不用file.stream()直接读而是用file.slice(start, end)切出固定大小的Blob再对每个Blob调.stream()或者直接.arrayBuffer()。原因很简单分片上传需要每一片有明确的边界和序号服务端要按序号重组如果让浏览器自由决定块大小你根本不知道第几块对应文件的哪个字节区间。const CHUNK_SIZE 1024 * 1024; // 1MB function createChunkStreams(file) { const chunks []; let start 0; while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size); chunks.push({ index: chunks.length, blob: file.slice(start, end), start, end, }); start end; } return chunks; }这里有个细节值得说slice是惰性的它不会立刻把数据读进内存只是创建了一个指向原文件的视图。真正读取发生在你调用.arrayBuffer()、.text()或者.stream()并消费的时候。所以哪怕你切了一千片内存里也不会同时存在一千份数据这是Blob设计得比较聪明的地方。2.3 用 Response 包装成流还有一种不太常见但很有用的方式把File塞进Response然后取response.body。这在需要给流附加 HTTP 语义、或者想复用fetch的流处理逻辑时很方便。const response new Response(file); const stream response.body;response.body同样是一个ReadableStream。这种写法的好处是你可以顺手拿到response.headers虽然对本地File来说头信息没什么意义但在处理网络返回的流时这套模式是统一的。我倾向于在写通用流处理函数时统一接收ReadableStream至于它是从File来的还是从fetch来的调用方自己决定。提示file.stream()和new Response(file).body在大多数浏览器里行为一致但前者语义更清晰处理本地文件时优先用它。3. ReadableStream 的读取模型与背压机制3.1 reader.read() 的 Promise 到底在等什么getReader()拿到的reader每次read()返回一个 Promiseresolve 出{ done, value }。这个 Promise 什么时候 resolve取决于流内部队列里有没有数据。如果队列里有块立刻 resolve如果没有就等到生产者这里是浏览器底层读文件的逻辑把下一块推进来。这就引出一个关键问题如果你读得比生产得快队列会空read()会等待如果你读得比生产得慢队列会堆积。ReadableStream内部有一个期望队列大小highWaterMark的概念默认值对字节流来说通常是 1 或者一个很小的数。当队列里的数据量超过这个水位流就会通知生产者暂停生产这就是背压backpressure。对File.stream()来说背压的意义在于浏览器不会傻乎乎地把整个文件预读进内存而是根据你消费的速度来决定读多少。你read()一次它才推进一块。所以哪怕文件有 10GB只要你一块一块处理完就丢弃内存占用始终是常数级。3.2 用 for await...of 简化读取手动while(true)加reader.read()写起来啰嗦ReadableStream实现了异步迭代器协议可以直接for await...of。async function consumeStream(stream) { let total 0; for await (const chunk of stream) { total chunk.byteLength; } return total; }注意for await...of会隐式调用getReader()并且在循环正常结束或抛出异常时自动释放锁。但如果你在循环中途break它也会释放 reader这点比手动管理省心。不过有个坑一旦流被某个 reader 锁定locked你就不能再对它调getReader()会直接抛TypeError。所以别在for await循环外面又去拿 reader。3.3 取消读取与释放资源用户点了取消上传或者组件卸载了你得主动取消流否则底层可能还在读文件白白消耗资源。const controller new AbortController(); const reader stream.getReader(); // 某个时刻取消 controller.abort(); await reader.cancel(用户取消);reader.cancel(reason)会关闭流并且让后续的read()返回{ done: true }。如果你用的是for await...of可以在外部用一个标志位配合break或者直接对stream调cancel。我一般会在封装的分片上传类里维护一个aborted标志每次循环开头检查这样逻辑最清晰。注意cancel之后流就废了不能重新读。如果需要重试得重新从File调.stream()生成一个新的流。File对象本身是可重用的这点和一次性消费的流不一样。4. 把字节流变成文本TextDecoderStream 的正确用法4.1 为什么不能直接对每个 chunk 调 TextDecoder假设你要解析一个 UTF-8 的日志文件很自然会想到每个 chunk 用new TextDecoder().decode(chunk)转成字符串然后拼接。这个做法在绝大多数情况下会出问题因为一个多字节字符可能被切在两个 chunk 的边界上。比如中字的 UTF-8 编码是三个字节E4 B8 AD。如果第一个 chunk 以E4 B8结尾第二个 chunk 以AD开头你分别解码第一个 chunk 会得到一个替换字符第二个 chunk 也会得到一个拼起来就是乱码。这不是理论问题是实际处理中文日志时必然遇到的。4.2 TextDecoderStream 帮你处理跨块边界TextDecoderStream是一个TransformStream它内部持有一个TextDecoder实例并且用{ stream: true }模式解码会把不完整的字节序列缓存起来等下一块数据到了再一起解。const file input.files[0]; const textStream file.stream().pipeThrough(new TextDecoderStream()); for await (const text of textStream) { console.log(解码后的文本片段, text); }pipeThrough把ReadableStream接到TransformStream上返回一个新的ReadableStream。这个新流吐出的就是字符串了。TextDecoderStream默认用 UTF-8如果你的文件是 GBK 编码可以传{ encoding: gbk }不过浏览器对非 UTF-8 的支持参差不齐处理 GBK 文件时我一般还是老老实实读成ArrayBuffer再用第三方库解码。4.3 按行解析大文件的实战写法日志文件通常按行组织但流是按块给的一行可能跨块。所以需要一个缓冲区把收到的文本片段拼起来按\n切分最后一段不完整的留在缓冲区里等下一块。async function* readLines(stream) { const textStream stream.pipeThrough(new TextDecoderStream()); let buffer ; for await (const chunk of textStream) { buffer chunk; const lines buffer.split(\n); buffer lines.pop(); // 最后一段可能不完整留到下次 for (const line of lines) { yield line; } } if (buffer) yield buffer; } // 使用 for await (const line of readLines(file.stream())) { if (line.includes(ERROR)) { console.log(发现错误行, line); } }这个readLines异步生成器是我处理大日志时最常用的工具。它把流式读取和按行处理两个关注点分开了调用方只需要关心每一行内容不用管块边界。实测下来解析一个 500MB 的日志文件内存占用稳定在几十 MB比readAsText一次性读进来再split要稳得多。提示buffer.split(\n)在超大单行文件比如压缩后的 JSON 一行到底上会退化成把整行都堆在内存里。如果你的文件有这种特征得换成分隔符扫描的方式或者干脆按固定字节数切。5. WritableStream 与文件写入的浏览器边界5.1 浏览器里能直接写文件吗这是很多人会问的问题既然能流式读能不能流式写回磁盘答案是在标准浏览器环境里不能直接写任意路径的文件。WritableStream本身是一个抽象接口它描述的是一个可以接收数据的目的地但这个目的地是什么取决于你把它接到哪里。常见的WritableStream目的地有几种通过File System Access API拿到的文件句柄可以创建FileSystemWritableFileStream这是真正能写磁盘的。通过fetch的请求体把流作为上传数据发出去。内存中的收集器比如把流内容拼成一个Blob。File System Access API的可用性有限而且需要用户显式授权选择文件或目录不是所有场景都能用。所以实际项目里WritableStream更多是用在把处理后的数据送到某个消费者这个抽象层面而不是真的写本地文件。5.2 用 WritableStream 收集处理结果一个典型场景读取大文件过滤出需要的行把结果收集起来最后下载。这时候可以自定义一个WritableStream在write里把数据推进数组。function createCollector() { const chunks []; return new WritableStream({ write(chunk) { chunks.push(chunk); }, close() { console.log(收集完成共, chunks.length, 块); }, abort(reason) { console.warn(被中止, reason); }, }); }WritableStream的构造函数接收一个底层 sink 对象里面最重要的是write(chunk)方法它返回一个 Promiseresolve 表示这块数据处理完了可以接收下一块。如果你在write里做异步操作比如写 IndexedDB返回的 Promise 没 resolve 之前上游会被背压住不会继续推数据。这个机制保证了写入端不会被打爆。5.3 pipeTo 把读写两端接起来ReadableStream.pipeTo(writableStream)是最直接的连接方式它会自动处理背压、错误传播和关闭。const result await file.stream() .pipeThrough(new TextDecoderStream()) .pipeTo(createCollector());pipeTo返回一个 Promise当整个管道正常完成时 resolve出错时 reject。它比手动for await加writer.write要省事因为背压是自动的下游write慢上游read就会慢下来。不过pipeTo有个限制它默认在出错时不会自动取消上游。如果你需要更精细的错误处理可以传第二个参数{ preventCancel: false }或者干脆手动管理 reader 和 writer。我在需要区分读取错误和写入错误的场景里会放弃pipeTo改用显式的循环这样能精确知道是哪一端出的问题。6. TransformStream在管道中间做数据加工6.1 TransformStream 的基本结构TransformStream是流的中间环节它一头是writable一头是readable。你往writable写从readable读中间经过你定义的转换逻辑。const upperCaseTransform new TransformStream({ transform(chunk, controller) { controller.enqueue(chunk.toUpperCase()); }, flush(controller) { console.log(所有数据转换完毕); }, });transform每收到一块就调用一次你可以选择enqueue零个、一个或多个结果。flush在写入端关闭后调用适合做收尾工作比如把缓冲区里剩下的数据吐出去。6.2 用 TransformStream 做分片计数与进度上报上传大文件时进度条是刚需。用TransformStream可以在数据流经时顺手统计已处理的字节数并触发进度回调。function createProgressTransform(totalSize, onProgress) { let loaded 0; return new TransformStream({ transform(chunk, controller) { loaded chunk.byteLength; onProgress(loaded, totalSize); controller.enqueue(chunk); }, }); } // 使用 const progressStream file.stream() .pipeThrough(createProgressTransform(file.size, (loaded, total) { const percent ((loaded / total) * 100).toFixed(1); progressBar.style.width percent %; }));这个模式的好处是进度统计和业务处理解耦。你可以在管道里串多个TransformStream一个负责统计一个负责加密一个负责压缩每个只干一件事。这种组合方式比写一个大函数里塞满逻辑要清晰得多也更容易单独测试。6.3 处理背压时的常见误区TransformStream的transform方法如果返回 Promise流会等这个 Promise resolve 之后才处理下一块。这意味着你可以在transform里做异步操作比如调用一个异步的加密函数。但要注意如果你在transform里await一个很慢的操作整个管道的吞吐就会被拖慢这是背压的正常表现不是 bug。我见过有人在transform里把每块数据都enqueue到一个外部数组然后异步慢慢处理这样等于绕过了背压内存会无限增长。正确做法是让transform返回的 Promise 反映真实处理完成的时间让背压自然生效。注意TransformStream的transform里不要做攒够 N 块再一起处理这种逻辑除非你明确知道 N 的上限和每块大小。否则遇到超大文件攒的数据会把内存吃光。需要批量处理时用固定大小的缓冲区并设置上限。7. 分片上传中的流式处理完整链路7.1 为什么分片上传要用流而不是一次性读分片上传的核心诉求是把大文件切成固定大小的片逐片上传支持断点续传和并发控制。如果用FileReader.readAsArrayBuffer一次性读整个文件那分片就失去意义了因为内存里已经有一份完整数据了。用流的方式每一片独立读取、独立上传、上传完就释放内存占用只和并发数有关。假设并发 3 片每片 5MB峰值内存也就 15MB 左右和文件总大小无关。7.2 分片读取与上传的代码骨架const CHUNK_SIZE 5 * 1024 * 1024; const MAX_CONCURRENT 3; async function uploadFile(file, uploadUrl) { const totalChunks Math.ceil(file.size / CHUNK_SIZE); const tasks []; for (let i 0; i totalChunks; i) { const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const blob file.slice(start, end); tasks.push(async () { const formData new FormData(); formData.append(index, i); formData.append(total, totalChunks); formData.append(chunk, blob, chunk-${i}); const res await fetch(uploadUrl, { method: POST, body: formData, }); if (!res.ok) throw new Error(分片 ${i} 上传失败); return i; }); } // 并发控制 const results []; const executing new Set(); for (const task of tasks) { const p task().then((idx) { executing.delete(p); return idx; }); executing.add(p); results.push(p); if (executing.size MAX_CONCURRENT) { await Promise.race(executing); } } await Promise.all(results); return results; }这段代码里file.slice创建的是视图FormData.append时浏览器会按需读取对应区间的数据。并发控制用Promise.race配合一个Set来限制同时进行的任务数这是比较经典的写法。7.3 断点续传时如何记录已完成分片断点续传需要在本地记录哪些分片已经上传成功。我一般用IndexedDB存一个{ fileHash, uploadedIndexes }的记录文件哈希可以用文件大小加最后修改时间做一个轻量标识不一定要算完整的内容哈希算完整哈希本身就要读一遍全文件对大文件来说代价太高。async function getFileIdentifier(file) { const raw ${file.name}-${file.size}-${file.lastModified}; const buf new TextEncoder().encode(raw); const hash await crypto.subtle.digest(SHA-256, buf); return Array.from(new Uint8Array(hash)) .map((b) b.toString(16).padStart(2, 0)) .join(); }这个标识不是内容级的唯一但用于断点续传足够了。如果用户改了文件内容但大小和修改时间没变理论上会误判但这种情况极少。真要严格就得在服务端做分片校验客户端只负责按记录跳过已上传的分片。提示分片上传时服务端要能处理乱序到达的分片因为并发上传不保证顺序。合并分片时按 index 排序别按到达顺序拼。8. 流处理中的错误、取消与资源回收8.1 错误如何沿着管道传播在pipeTo和pipeThrough组成的管道里任何一环出错错误会向下游传播最终让pipeTo返回的 Promise reject。但上游不一定会自动取消这取决于具体实现和参数。try { await file.stream() .pipeThrough(new TextDecoderStream()) .pipeTo(collector); } catch (err) { console.error(管道出错, err); }如果错误发生在TextDecoderStream里比如遇到非法字节序列它会变成流的错误pipeTo会 reject。这时候file.stream()那个上游流可能还处于锁定状态需要确保它被取消。稳妥的做法是在catch里显式取消所有相关流或者用AbortController统一管理。8.2 用 AbortSignal 统一取消ReadableStream的pipeTo和pipeThrough都支持传入signal选项这样可以用一个AbortController取消整条管道。const controller new AbortController(); // 用户点击取消 cancelButton.onclick () controller.abort(); try { await file.stream() .pipeThrough(new TextDecoderStream(), { signal: controller.signal }) .pipeTo(collector, { signal: controller.signal }); } catch (err) { if (err.name AbortError) { console.log(用户取消了操作); } else { console.error(出错了, err); } }用AbortSignal的好处是无论管道有多长一个abort()就能全部停掉而且错误类型统一是AbortError方便区分用户主动取消和真实错误。8.3 释放 reader 锁与避免内存泄漏流被 reader 锁定后如果不释放其他代码就无法再消费这个流。reader.releaseLock()可以释放锁但前提是流没有被关闭或出错。更安全的做法是让for await...of或pipeTo自动管理它们会在结束时释放。如果你手动getReader()一定要用try...finally确保releaseLock或cancel被调用const reader stream.getReader(); try { while (true) { const { done, value } await reader.read(); if (done) break; // 处理 value } } finally { reader.releaseLock(); }我踩过的一个坑是在 React 组件里启动了一个流读取组件卸载时没有取消结果流还在后台跑回调里访问了已经卸载的组件状态报了一堆警告。后来统一在useEffect的清理函数里调controller.abort()问题就没了。9. 几个容易翻车的细节与我的实操心得9.1 chunk 是 Uint8Array不是 ArrayBufferReadableStream从File读出来的 chunk 是Uint8Array它是对ArrayBuffer的一个视图。如果你需要ArrayBuffer得用chunk.buffer但要注意chunk.buffer可能比chunk本身大因为视图可能只覆盖了 buffer 的一部分。正确做法是chunk.slice().buffer或者直接用chunk本身因为大多数 API 都接受Uint8Array。9.2 流只能消费一次这是最容易被忽略的一点。ReadableStream是一次性的读完了就没了。如果你需要多次处理同一份数据要么重新从File生成流要么在第一次读的时候把数据存下来。别想着我再读一遍流那是不行的。9.3 大文件不要用 readAsTextFileReader.readAsText会把整个文件解码成一个字符串对于几百 MB 的文件这个字符串本身就会占用大量内存而且解码过程是同步的会阻塞主线程。用TextDecoderStream配合流式读取内存和响应性都好得多。9.4 注意 TextDecoderStream 的编码参数TextDecoderStream默认 UTF-8如果文件是其他编码需要显式指定。但浏览器对非 UTF-8 编码的支持不一致处理 GBK 等编码时我一般用ArrayBuffer读出来再用TextDecoder手动解码或者引入专门的编码库。9.5 进度上报不要过于频繁在TransformStream里每收到一块就更新一次 DOM如果块很小、文件很大会导致大量的重排重绘。我一般会做节流比如每 100ms 更新一次进度或者累计到一定字节数再更新。let lastUpdate 0; function onProgress(loaded, total) { const now Date.now(); if (now - lastUpdate 100 loaded total) return; lastUpdate now; // 更新 UI }9.6 Node.js 里的 web streams 可以复用这套逻辑Node.js 从 18 开始稳定支持web streamsfs.createReadStream返回的是 Node 的流但可以通过stream.Readable.toWeb()转成ReadableStream。这样你在前端写的TransformStream、pipeThrough逻辑在 Node 里基本能直接跑。我在做本地日志分析工具时就是前端和 Node 共用一套流处理模块省了不少重复代码。10. 把流式思维用到文件处理之外Streams API的价值不只在文件处理。任何数据量大到不能一次性放进内存或者数据是逐步产生的场景都适合用流。比如处理fetch返回的大响应边下边解析 JSON 行。在 Web Worker 和主线程之间传递大量数据用流做分块传输。处理用户输入的实时数据比如录音、摄像头帧用流做管道处理。我自己的体会是一旦习惯了数据是流动的这个思维模型很多原本觉得棘手的问题会变得清晰。你不再问我怎么把这一大坨数据读进来而是问数据从哪来、经过哪些处理、到哪去然后每一段用一个流或者TransformStream表示组合起来就是完整的管道。这套模型的学习曲线主要在前期getReader、pipeTo、TransformStream这些概念第一次接触会觉得绕。但写过两三个真实的流处理任务之后你会发现它的抽象其实很自然而且一旦掌握处理大文件、实时数据、分片传输这些场景都会变得顺手。