Stream 基础:四种流与 pipe 管道

本节目标

为什么需要流? 假设你要把一个 2GB 文件从 A 复制到 B。如果一次性 readFile 读进内存,内存直接爆掉;如果用流,数据像水流:源头一块块流出,中途可以边流边处理,终点一块块接收——全程内存只保留"当前这一小块"。这就是流的核心价值:处理的是"流动的数据",而不是"完整的数据集"。

四种流:

// 运行环境:Node.js 14+(无需联网/依赖)
// 保存为 buf-l2.js,执行:node buf-l2.js
const { Transform, Readable, Writable } = require('stream')

// 1) 一个最简单的"转换流":把读到的每一块都转成大写
class Upper extends Transform {
  // _transform(chunk, encoding, callback):拿到一块数据,处理后用 callback(null, 新块) 交出去
  _transform(chunk, encoding, callback) {
    const up = chunk.toString().toUpperCase()
    callback(null, Buffer.from(up))
  }
}
const upper = new Upper()
upper.on('data', (c) => process.stdout.write('[流出] ' + c)) // 'data' 事件:有块流出
upper.on('end', () => console.log('\n[结束] 流已读完'))
upper.write('hello ')   // 写入数据(会触发 _transform)
upper.write('stream\n')
upper.end()             // 声明"写完了"

// 2) 用 pipe 把"可读流"自动接到"可写流"(无需手动监听 data 事件)
const src = Readable.from(['行一\n', '行二\n', '行三\n']) // 从数组造一个可读流
const out = new Writable({
  write(chunk, enc, cb) { process.stdout.write('>> ' + chunk); cb() } // cb() 表示这块写完了
})
src.pipe(out) // 自动把 src 的数据块"搬运"给 out,直到结束

背压(backpressure)是什么? 想象水龙头(源头)出水太快,而下水管(终点)处理太慢,水就会溢出来。流里同理:源头产数据比终点消费快时,多余数据会堆在内存。pipe 内置了背压机制——终点忙不过来时会通知源头"暂停",等空了再"继续",从而内存稳定。所以优先用 pipe / pipeline,不要手动 'data' 狂读。

名词解释

课后练习

  1. 什么场景必须上流,而不是 readFile / readFileSync?
    • 答案:数据量大到放不进内存(大文件复制、日志分析、视频转码),或数据是"持续产生"的(网络请求、实时日志)。流能保持内存恒定。
  2. 手写 Transform 时,callback(err, data) 的两个参数分别是什么?
    • 答案:第一个是错误(正常传 null),第二个是"这一块处理完要交出去的数据"。忘了调 callback 会导致流"卡死"不再处理后续块。
  3. 为什么说 pipe 自动处理了背压,而你手动 on('data') 不一定?
    • 答案:pipe 内部实现了暂停/恢复逻辑:终点 write 返回 false(忙)时,源头会被 pause(),等 'drain' 再 resume()。手动监听 data 若不自己处理"返回值 false 就暂停",就会无限堆积数据撑爆内存。

总结

流是 Node 处理"大"与"持续"数据的统一范式,它的思想一句话概括就是:别等全部到齐,来一块处理一块。我刻意把"背压"放在这一节强调,是因为它是流的灵魂——很多人第一次手写流时,只记得 pipe 很方便,却不知道底层那套"源头太快→终点喊停→源头暂停→终点恢复"的节奏控制才是它稳如泰山的真正原因。你今天写的 Upper 转换流,和 Node 内置的 zlib.createGzip、TLS 加密流是同一个祖宗(都继承 Transform)。理解了这一点,下一节的"文件压缩管道""网络分包"就不再是新概念,而只是"把不同的 Transform 串到 pipe 上"而已。请记住:凡是看到"实时""大文件""边传边处理"这几个词,第一反应就应该是——上流。