在学习流之前,我们先回顾下迭代器的基本知识。
迭代器(Iterator)
迭代器就是一个能按顺序遍历数据的对象,它有固定规则:
- 必须有一个
next()方法 - 每次调用
next()返回{ value: 值, done: 是否结束 }
最简单的迭代器例子:
const iterator = {
current: 1,
next() {
if (this.current <= 3) {
return { value: this.current++, done: false }
} else {
return { value: undefined, done: true }
}
},
}
console.log(iterator.next()) // { value:1, done:false }
console.log(iterator.next()) // { value:2, done:false }
console.log(iterator.next()) // { value:3, done:false }
console.log(iterator.next()) // { value:undefined, done:true }
什么是可迭代对象呢?
一个对象只要有 Symbol.iterator 方法,且返回迭代器,就是可迭代对象,就能用 for...of。
const obj = {
[Symbol.iterator]() {
let current = 1
return {
next() {
if (current <= 3) {
return { value: current++, done: false }
} else {
return { done: true }
}
},
}
},
}
for (const v of obj) {
console.log(v) // 1,2,3
}
数组、Set、Map、字符串都是可迭代对象。
那什么是异步迭代器(Async Iterator)?
规则和迭代器几乎一样,只有两点不同:
- 方法名是
Symbol.asyncIterator next()返回 Promise- 遍历必须用
for await...of
const asyncIterable = {
[Symbol.asyncIterator]() {
let current = 1
return {
async next() {
// 返回 Promise
await new Promise((resolve) => setTimeout(resolve, 500))
if (current <= 3) {
return { value: current++, done: false }
} else {
return { done: true }
}
},
}
},
}
;(async () => {
for await (const v of asyncIterable) {
console.log(v)
}
})()
生成器
迭代器(Iterator)是最终的一个结果,成品,是一个固定规则的对象:必须有 .next() 方法,每次返回:{ value: 产出值, done: 有没有结束 }。
但是,原生手写迭代器很麻烦:
// 这就是【迭代器成品】
const myIterator = {
count: 1,
next() {
if(this.count <=3) return {value: this.count++, done:false}
return {done:true}
}
}
你自己要手动写状态、写next、写判断,很累。
生成器(Generator)是一个工厂、工具、语法糖。
function* 就是专门帮你自动生成迭代器的函数:你只需要写 yield 丢值,不用手写 next、不用管状态。
// 生成器:造迭代器的工厂
function* gen() {
yield 1;
yield 2;
yield 3;
}
调用生成器,直接产出一个迭代器:
const it = gen();
// it 就是【标准迭代器】,自带 next()
console.log(it.next()); // {value:1, done:false}
console.log(it.next()); // {value:2, done:false}
Symbol
Symbol 是 JS 里一种全新的、独一无二的值类型。它的作用只有一个:用来做绝对不会重复的 “唯一标识”
const s1 = Symbol();
const s2 = Symbol();
console.log(s1 === s2); // false
最常用场景:做对象的 “私有键”,以前对象键都是字符串,容易重名冲突:
const obj = {
name: '张三',
name: '李四' // 覆盖了
};
用 Symbol 就绝对不会冲突:
const id1 = Symbol('id');
const id2 = Symbol('id');
const obj = {
[id1]: 100,
[id2]: 200
};
console.log(obj[id1]); // 100
console.log(obj[id2]); // 200
JS 内置了一些特殊 Symbol,用来表示内部行为接口。
比如,上面的Symbol.iterator,Symbol.asyncIterator。它们就是JS 规定的 “特殊标记” 。
const obj = {
[Symbol.iterator]() { ... }
};
意思就是告诉 JS:我这个对象实现了迭代接口,你可以用 for...of 遍历我。
stream(流)
先看一个简单的例子:
import http from 'node:http';
import fs from 'node:fs';
const server = http.createServer(async function (req, res) {
const data = fs.readFileSync(import.meta.dirname + '/index.html', 'utf-8');
res.end(data);
});
server.listen(8000);
我们跑了个 http 服务,用 fs.readFileSync 读取 data.txt 的内容返回。
然后用 curl 访问下:
curl -i http://localhost:8000
因为是全部读完返回的,所以可以知道 Content-Length,也就是响应体的长度。
当文件比较小的时候,这样读取、返回没啥问题。
那如果文件非常大呢?
比如有好几百 M,这时候全部读取完再返回是不是就合适了?
因为要等好久才能读取完文件,之后才有响应。
这就需要用到流了:
const readStream = fs.createReadStream(import.meta.dirname + '/data.txt', 'utf-8');
readStream.pipe(res);
结果一样,但是因为现在是流式返回的,并不知道响应体的 Content-Length。
所以是用 Transfer-Encoding: chunked 的方式返回流式内容。
这个是面试常考题。
从服务器下载一个文件的时候,如何知道文件下载完了呢?
有两种方式:
一种是 header 里带上 Content-Length,浏览器下载到这个长度就结束。
另一种是设置 transfer-encoding:chunked,它是不固定长度的,服务器不断返回内容,直到返回一个空的内容代表结束。
比如这样:
5
Hello
1
,
5
World
1
!
0
这里分了 “Hello” “,” “World”“!” 这 4 个块,长度分别为 5、1、5、1
最后以一个长度为 0 的块代表传输结束。
这样,不管内容多少都可以分块返回,就不用指定 Content-Length 了。
这就是大文件的流式传输的原理,就是 transfer-encoding:chunked。
当然,这是 http 传输时的流,在用 shell 命令的时候,也经常会用到流:
ls | grep pack
ls 命令的输出流,作为 grep 命令的输入流。
当然,我们也可以把 grep 命令的输出流,作为 node 脚本的输入流。
process.stdin.on('readable', function () {
const buf = process.stdin.read();
console.log(buf?.toString('utf-8'));
});
process.stdin 就是输入流,监听 readable 事件,用 read 读取数据。
ls | grep pack | node src/read.mjs
可以看到,我们的 node 脚本接收到了 grep 的输出流作为输入流。
这就是管道 pipe 的含义。
综上,可以小结下我们对流的认识:
流就是分段的传输内容,比如从服务端向浏览器返回响应数据的流,读取文件的流等。
流和流之间可以通过管道 pipe 连接,上个流的输出作为下个流的输入。
stream 流的分类
在 node 里,流一共有 4 种:可读流 Readable、可写流 Writable、双工流 Duplex、转换流 Transform:
import stream from 'node:stream';
// 可读流
const Readable = stream.Readable;
// 可写流
const Writable = stream.Writable;
// 双工流
const Duplex = stream.Duplex;
// 转换流
const Transform = stream.Transform;
其余的流都是基于这 4 种流封装出来的。
Readable
Readable 要实现 _read 方法,通过 push 返回具体的数据。
import { Readable } from 'node:stream';
const readableStream = new Readable();
readableStream._read = function() {
this.push('阿门阿前一棵葡萄树,');
this.push('阿东阿东绿的刚发芽,');
this.push('阿东背着那重重的的壳呀,');
this.push('一步一步地往上爬。')
this.push(null);
}
readableStream.on('data', (data)=> {
console.log(data.toString())
});
readableStream.on('end', () => {
console.log('done');
});
当 push 一个 null 时,就代表结束流。
创建 Readable 流也可以通过继承的方式:
import { Readable } from 'node:stream';
class ReadableDong extends Readable {
_read() {
this.push('阿门阿前一棵葡萄树,');
this.push('阿东阿东绿的刚发芽,');
this.push('阿东背着那重重的的壳呀,');
this.push('一步一步地往上爬。')
this.push(null);
}
}
const readableStream = new ReadableDong();
readableStream.on('data', (data)=> {
console.log(data.toString())
});
readableStream.on('end', () => {
console.log('done');
});
可读流是生成内容的,那么很自然可以和生成器结合:
import { Readable } from 'node:stream';
class ReadableDong extends Readable {
constructor(iterator) {
super();
this.iterator = iterator;
}
_read() {
const next = this.iterator.next();
if(next.done) {
return this.push(null);
} else {
this.push(next.value)
}
}
}
function *songGenerator() {
yield '阿门阿前一棵葡萄树,';
yield '阿东阿东绿的刚发芽,';
yield '阿东背着那重重的的壳呀,';
yield '一步一步地往上爬。';
}
const songIterator = songGenerator();
const readableStream = new ReadableDong(songIterator);
readableStream.on('data', (data)=> {
console.log(data.toString())
});
readableStream.on('end', () => {
console.log('done');
});
- 和 yield 是 js 的 generator 的语法,它是返回 yield 后的内容,通过 iterator 的 next 来取下一个。
我们封装个工厂方法:
function createReadStream(interator) {
return new ReadableDong(interator);
}
const readableStream = createReadStream(songIterator)
readableStream.on('data', (data)=> {
console.log(data.toString())
});
readableStream.on('end', () => {
console.log('done');
});
是不是就和 fs.createReadStream 很像了?
import fs from 'node:fs';
const readStream = fs.createReadStream(import.meta.dirname + '/data.txt', 'utf-8');
readStream.on('data', (data)=> {
console.log(data.toString())
});
readStream.on('end', () => {
console.log('done');
});
其实文件的 ReadStream 就是基于 stream 的 Readable 封装出来的。
这就是可读流。
http 服务的 request 就是 Readable 的实例:
所以我们可以这样写:
import http from 'node:http';
import fs from 'node:fs';
const server = http.createServer(async function (req, res) {
const writeStream = fs.createWriteStream('aaa.txt', 'utf-8');
req.pipe(writeStream);
res.end('done');
});
server.listen(8000);
Node.js 可读流有两种工作模式,本质是数据要不要主动往外推:
- 流动模式(Flowing) :流主动疯狂吐数据,自动发
data事件 - 暂停模式(Paused) :流把数据存在缓冲区,你不喊,它不给,要自己
.read()拿。
满足任意一个就行就是进入流动模式:
- 监听了
data事件 - 调用了
.resume() - 用了
.pipe()导给可写流
适用简单读、不用精细控制、一次性拿数据的场景。
缺点是数据来得太快,容易背压(下游处理不过来,内存爆)。
暂停模式 Paused Mode(默认模式):
-
是可读流初始化默认状态
-
数据先塞进内部缓冲区,憋着不发
-
你想读:等
readable事件触发,手动调用.read()捞数据
怎么进入暂停模式:
- 新建可读流默认就是
- 调用
.pause()可从流动切回暂停
适合需要精细控制读取速度、自己处理背压、自定义读取逻辑的场景。
Writable
Readable 是实现 _read 方法,通过 this.push 返回内容
Writable 则要实现 _write 方法,接收写入的内容。
import { Writable } from 'node:stream';
class WritableDong extends Writable {
constructor(iterator) {
super();
}
_write(data, enc, next) {
console.log(data.toString());
// 模拟慢消费:延迟1秒才告诉流“我处理完了,可以收下一条”
setTimeout(() => {
next();
}, 1000);
}
}
function createWriteStream() {
return new WritableDong();
}
const writeStream = createWriteStream();
writeStream.on('finish', () => console.log('done'));
writeStream.write('阿门阿前一棵葡萄树,');
writeStream.write('阿东阿东绿的刚发芽,');
writeStream.write('阿东背着那重重的的壳呀,');
writeStream.write('一步一步地往上爬。');
writeStream.end();
Writable 的特点是可以自己控制消费数据的频率,只有调用 next 方法的时候,才会处理下一部分数据。
我们每 1s 处理一次写入。
其实我们常用的 fs.createWriteStream 就是这样封装出来的。
import fs from 'node:fs';
const writeStream = fs.createWriteStream('tmp.txt', 'utf-8');
writeStream.on('finish', () => console.log('done'));
writeStream.write('阿门阿前一棵葡萄树,');
writeStream.write('阿东阿东绿的刚发芽,');
writeStream.write('阿东背着那重重的的壳呀,');
writeStream.write('一步一步地往上爬。');
writeStream.end();
http 服务的 response 就是 Writable 的实例。
Duplex
Duplex 是可读可写,同时实现 _read 和 _write 就可以了,也就是双工流。
import { Duplex } from 'node:stream';
class DuplexStream extends Duplex {
_read() {
this.push('阿门阿前一棵葡萄树,');
this.push('阿东阿东绿的刚发芽,');
this.push('阿东背着那重重的的壳呀,');
this.push('一步一步地往上爬。')
this.push(null);
}
_write(data, enc, next) {
console.log(data.toString());
setTimeout(() => {
next();
}, 1000);
}
}
const duplexStream = new DuplexStream();
duplexStream.on('data', data => {
console.log(data.toString())
});
duplexStream.on('end', data => {
console.log('read done')
});
duplexStream.write('阿门阿前一棵葡萄树,');
duplexStream.write('阿东阿东绿的刚发芽,');
duplexStream.write('阿东背着那重重的的壳呀,');
duplexStream.write('一步一步地往上爬。');
duplexStream.end();
duplexStream.on('finish', data => {
console.log('write done')
});
注意:两个流程互不干扰。
这个 duplex 现在同时在跑两套独立逻辑:
-
读流程(自动输出)
_read → push 歌词 → on ('data')直接打印,这是流自己 “生产数据往外吐”; -
写流程(你手动输入)
你 .write () → 进 _write → 延迟 1s 打印,这是流 “接收你塞进去的数据”;
✅ 两边数据不互通、互不影响,各跑各的。
创建 src/socket-server.mjs
import net from 'node:net';
const server = net.createServer(function(clientSocket){
console.log('新的客户端 socket 连接');
clientSocket.on('data', function(data){
console.log(data.toString());
clientSocket.write('hello');
});
clientSocket.on('end', function(){
console.log('连接中断');
});
});
server.listen(6666, 'localhost', function(){
const address = server.address();
console.log('被监听的地址为:%j', address);
});
我们创建了一个 tcp 的服务。然后再创建个 tcp 的客户端;
import net from 'node:net';
const socket = net.createConnection({
host: 'localhost',
port: 6666
}, () => {
console.log('连接到了服务端!');
socket.write('world!\n');
setTimeout(()=> {
socket.end();
}, 2000);
});
socket.on('data', (data) => {
console.log(data.toString());
});
socket.on('end', () => {
console.log('断开连接');
});
连上 tcp 服务端,发送条消息,然后 2s 后断开连接。
这种双向通信就是基于 Duplex 做的。
看下这些方法:write 是可写流的, data、end 事件是可读流的。
Socket 就是 Duplex 的实现。
Transform
Transform 也是 Duplex 双工流,只不过它会对写入的内容做一些转换之后提供给消费者来读。
Transform 流要实现 _transform 的 api。
import { Transform } from 'node:stream';
class ReverseStream extends Transform {
_transform(buf, enc, next) {
const res = buf.toString().split('').reverse().join('');
this.push(res);
next()
}
}
var transformStream = new ReverseStream();
transformStream.on('data', data => console.log(data.toString()))
transformStream.on('end', data => console.log('read done'));
transformStream.write('阿门阿前一棵葡萄树');
transformStream.write('阿东阿东绿的刚发芽');
transformStream.write('阿东背着那重重的的壳呀');
transformStream.write('一步一步地往上爬');
transformStream.end()
transformStream.on('finish', data => console.log('write done'));
在 _transform 方法里用 push 来产生可读流数据,然后 next 是消费下一个写入的数据。
push 和 next 方法在前面的 Readable、Writable 里都用过。
这就是转换流,它也是一种双工流。
那在 node.js 的内置模块里,有哪些 api 是转换流呢?
zlib
import {
createReadStream,
createWriteStream,
} from 'node:fs';
import { createGzip } from 'node:zlib';
const gzip = createGzip();
const source = createReadStream(import.meta.dirname + '/data.txt');
const destination = createWriteStream('data.txt.gz');
source.pipe(gzip).pipe(destination);
从文件的 ReadStream,pipe 到 Gzip 转换流,然后 pipe 到文件的 WriteStream。
这感觉是不是和我们写 shell 命令时一样?
这里的多次 pipe 也可以用 stream 的 pipeline 的 api 简化:
这条 pipeline 的特点是 Readable 产生数据,然后经过任意多个 Duplex(包括 Transform),最后传入 Writable。
总结
stream 是 Node.js 的非常常用 API,也是面试必问的点。
我们每天敲的 shell 命令,就是基于流的概念,上个进程的输出可以做为下个进程的输入。
写 Node.js 代码的时候,文件读写、网络通信等都是基于流。
虽然各种流有很多,但底层的 stream 只有 4 种:
- Readable:实现
_read方法,通过push传入内容 - Writable:实现
_write方法,通过next消费内容 - Duplex:实现
_read、_write,可读可写 - Transform:实现
_transform,对写入的内容做转换再传出去,继承自 Duplex
面试问的话,除了说出这 4 种 stream 外,最好举一个具体的 api 来说明。
比如 fs.createReadStream、http 的 request 是 Readable 的实现,fs.createWriteStream、http 的 response 是 Writable 的实现,net 的 Socket 是 Duplex 的实现,zlib.createGzip 是 Transform 的实现。
理解这 4 种 stream,能自己实现,也能知道哪些 api 是哪种流,就算掌握的差不多了。