场景:现有一个事件模型 EventEmitter,负责生产数据。通过http请求来消费生产的数据。
下面定义了DataEmitter(继承EventEmitter),通过定时器来产生数据。emitter每秒产生1个时间戳,产生5个后结束。
data-emitter.ts
ts
import { EventEmitter } from 'node:events';
export class DataEmitter extends EventEmitter {
private timer: NodeJS.Timeout | null = null;
private count = 0;
constructor(private maxCount: number, private intervalMs: number) {
super();
}
start() {
if (this.timer) return;
this.timer = setInterval(() => {
this.count += 1;
const chunk = `${Date.now()}
`;
this.emit('data', chunk);
if (this.count >= this.maxCount) {
this.stop();
this.emit('end');
}
}, this.intervalMs);
}
stop() {
console.log('stop...');
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}
export const emitter = new DataEmitter(5, 1000);
1.on方法来订阅消费
使用node原生的http模块,创建一个web服务。然后eventEmitter.on(eventName, data)方法来消费,响应请求。
server-demo.ts
ts
import http from 'node:http';
import { emitter } from './data-emitter';
const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
if (req.url === '/test') {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
// res.setHeader('Transfer-Encoding', 'chunked'); //可省略
const onData = (chunk: string) => {
res.write(chunk);
};
const onEnd = () => {
res.end();
cleanup();
};
function cleanup() {
emitter.off('data', onData);
emitter.off('end', onEnd);
}
emitter.on('data', onData);
emitter.on('end', onEnd);
emitter.start();
// 清理计时器和事件监听器
req.on('close', () => {
cleanup();
});
} else {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('Not Found');
}
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server is running at http://localhost:${PORT}`);
});
当我curl http://localhost:3000/test 请求时,每隔1s会打印一行:
1766984830578
1766984831578
1766984832580
1766984833580
1766984834582
2.async-await消费
有些特殊场景,需要await来消费,即需要将eventEmitter转换为一个线性的逻辑。 比如在hono.js中,提供stream/streamText都需在一个async方法中完成响应。
hono-demo.ts
ts
import { Hono } from 'hono'
import { streamText } from 'hono/streaming'
import { serve } from '@hono/node-server'
import { emitter } from './data-emitter';
const app = new Hono()
app.get('/test', (c) => {
return streamText(c, async (s) => {
const onData = (chunk: string) => {
s.write(chunk);
};
const onEnd = () => {
s.close();
cleanup();
};
function cleanup() {
emitter.off('data', onData);
emitter.off('close', onEnd);
}
emitter.start();
// 相当于node的req.on('close', () => {}),清理计时器和事件监听器
s.onAbort(() => {
cleanup();
})
})
})
// export default app
serve(app, (info) => {
console.log(`Server is running at http://localhost:${info.port}`)
})
当你这样写,不会生效。因为streamText的第二个参数函数一旦结束,响应就结束了(相当于res.end()),因此必须在async/await的生命周期内消费数据,一旦结束这个周期便无法消费数据了。
那么,此时有两种方式来完成EventEmitter模型到async-await模型的转换。
方式一:异步迭代器
官方在 events 模块中提供了一个方法 on(),它能将某个EventEmitter转换为一个「异步迭代器」。
ts
import {on} from 'node:events';
const asyncIterator = on(emitter,'data')
for await (const [chunk] of asyncIterator) {
s.write(chunk);
}
「异步迭代器」能被for await... of语法遍历,是因为它实现了Symbol.asyncIterator 协议。此外,须知Stream也实现了Symbol.asyncIterator,所以也能这样被遍历。
将 「回调」 转换为 「异步迭代器」,从此不再是回调的方式来消费,而是线性("同步")方式消费。
问题:如何停止/结束?
1.通过error事件,这样循环「异步迭代器」时会抛出错误。
ts
this.emit('error', new Error('max count reached'));
arduino
Error: max count reached
at Timeout.<anonymous>
2.通过事件的值
ts
for await (const [data] of on(ee, 'data')) {
console.log(data);
if (data === 'quit') {
break; // 此时循环结束,底层监听器被移除
}
}
EventEmitter的emit方法,可以传递多个参数作为数据,所以,我们可以增加一个参数作为是否迭代结束的判断 在data-emitter.ts中增加:
ts
this.emit('data', '', true); // 第三个参数shouldStop
hono-demo.ts中修改为:
ts
// 只要 emitter 一直发 data 且没有触发 error,循环就不会结束 ,相当于一个"无限流"。
for await (const [chunk, shouldStop] of asyncIterator) {
if(shouldStop) break;
s.write(chunk);
}
3.通过AbortController 信号量终止 (官方推荐)
在data-emitter.ts中增加:
ts
export const ac = new AbortController();
...
//当你要终止
ac.abort()
hono-demo.ts中修改为:
ts
import { on } from 'node:events';
import { ac } from './data-emitter';
app.get('/test', (c) => {
return streamText(c, async (s) => {
const asyncIterator = on(emitter,'data', { signal: ac.signal })
emitter.start();
// 只要 emitter 一直发 data 且没有触发 error,循环就不会结束 ,相当于一个"无限流"。
// for await (const [chunk, shouldStop] of asyncIterator) {
// if(shouldStop) break;
// s.write(chunk);
// }
try {
for await (const [chunk] of asyncIterator) {
s.write(chunk);
}
} catch (err:any) {
if (err.name === 'AbortError') {
console.log('监听已停止');
} else {
throw err;
}
}
// 连接断开,事件监听器
s.onAbort(() => {
console.log('abort...');
// cleanup();
})
})
})
方式二:转换成流
1. Readable
直接一开始使用 Readable 而非 EventEmitter。
ts
import { Readable } from 'node:stream'
export function createDataStream(maxCount: number, intervalMs: number) {
let count = 0
let timer: NodeJS.Timeout | null = null
const stream = new Readable({
encoding: 'utf8',
read() {
if (timer) return
this.push('history1')
this.push('history2')
timer = setInterval(() => {
count += 1
const chunk = `${Date.now()}\n`
const canContinue = this.push(chunk)
if (!canContinue || count >= maxCount) {
if (timer) {
clearInterval(timer)
timer = null
}
this.push(null) //流结束
}
}, intervalMs)
}
})
return stream
}
ts
app.get('/test', (c) => {
return streamText(c, async (s) => {
const stream = createDataStream(5, 1000)
s.onAbort(() => {
stream.destroy()
})
for await (const chunk of stream) {
s.write(chunk.toString())
}
})
})
这种方式有一个局限性,就是stream无法「外部写」,而是又内部生成数据。当然也可以强行暴露一个write方法
ts
stream.write = function(chunk: any) {
return this.push(chunk)
}
但这样不太优雅,看起来更像一个Readable+Writable双工流。 那不如直接使用双工流。
2. PassThrough
Node.js 中的 PassThrough 流是 流(Stream)模块 提供的一种特殊类型的 双工流(Duplex Stream) ,其核心特点是:它不会对流经的数据做任何修改,仅起到"透明中转"的作用 。换句话说,写入 PassThrough 的数据会原封不动地从可读端流出,因此常被用作中间层或"观察点"来串联、分发、监控数据流。
双工流PassThrough示例:
ts
const stream = new PassThrough()
// 任何地方都可以写
stream.write('xxx')
// 其他地方可以消费
for await (const chunk of stream){
console.log(chunk)
}
现在写一个方法将最初的EventEmitter转为Stream. data-emitter中, 在原来的class DataEmitter 中增加一个方法createStream创建一个PassThrough:
ts
createStream() {
const stream = new PassThrough();
const onData = (chunk: string) => {
stream.write(chunk);
};
const onEnd = () => {
stream.end();
};
const cleanup = () => {
this.off('data', onData);
this.off('end', onEnd);
stream.removeListener('close', cleanup);
};
this.on('data', onData);
this.once('end', onEnd);
stream.on('close', cleanup);
return stream;
}
hono-demo.ts中修改为:
ts
// 使用流
const stream = emitter.createStream();
for await (const chunk of stream) {
s.write(chunk);
}
// 连接断开,事件监听器
s.onAbort(() => {
console.log('abort...');
// cleanup();
})
思考题:
为什么AsyncIterator 无法像 Stream 那样会自动结束?
- Stream(流):设计上就有"边界"。文件读完了、网络请求传完了,就会发 end 事件,异步迭代器看到 end 就会标记为 done: true。
- EventEmitter:设计上是"消息中心"。比如一个 process 对象的 SIGINT 信号监听,或者一个聊天室的 message 事件。只要程序没死,这些事件就可能一直发生,底层没有一个通用的"我以后再也不会发消息了"的协议。
3.消费历史数据
到目前为止,存在一个问题:eventEmitter产生的历史数据如何消费?
data-emitter中提前生成了两条数据history1和history2
ts
export class DataEmitter extends EventEmitter {...}
export const emitter = new DataEmitter(5, 1000);
// ++++++++++ 增加 START ++++++++++++
emitter.emit('data', 'history1\n');
emitter.emit('data', 'history2\n');
// ++++++++++ 增加 END ++++++++++++
使用纯EventEmitter、或者结合异步迭代器的方式,都无法消费历史数据,因为本质是消费同一个源,源生成过的历史数据是过去式,现在无法消费了。
而结合Stream的方式创建一个流,则是产生了一个新源,我们是从头到尾消费这个源,故能消费到历史数据。 唯一需要处理的就是在PassThrough创建时写入历史数据:
- 对于
DataEmitter需要记录历史数据(到数组)。 - 对于新建的
PassThrough,需要写入历史数据。
最终data-emitter.ts长这样:
ts
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
export class DataEmitter extends EventEmitter {
private timer: NodeJS.Timeout | null = null;
private count = 0;
private history: string[] = [];
constructor(private maxCount: number, private intervalMs: number) {
super();
this.on('data', (chunk) => {
this.history.push(chunk); //记录历史数据
})
}
start() {
if (this.timer) return;
this.timer = setInterval(() => {
this.count += 1;
const chunk = `${Date.now()}
`;
this.emit('data', chunk);
if (this.count >= this.maxCount) {
this.stop();
this.emit('end');
}
}, this.intervalMs);
}
stop() {
console.log('stop...');
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
createStream() {
const stream = new PassThrough();
this.history.forEach(chunk => stream.write(chunk)); // 历史数据写入流
const onData = (chunk: string) => {
stream.write(chunk);
};
const onEnd = () => {
stream.end();
};
const cleanup = () => {
this.off('data', onData);
this.off('end', onEnd);
stream.removeListener('close', cleanup);
};
this.on('data', onData);
this.once('end', onEnd);
stream.on('close', cleanup);
return stream;
}
}
export const emitter = new DataEmitter(5, 1000);
emitter.emit('data', 'history1\n');
emitter.emit('data', 'history2\n');
打印结果
history1
history2
1766993518537
1766993519538
1766993520538
1766993521539
1766993522541
使用EventEmitter作为生产者,结合Stream,给每一个消费者提供一个Stream。这种方案还带来另外两个好处:
- 针对每个HTTP实例(消费者),都单独提供一个Stream供消费,互不干扰,并且能消费到历史数据。
- 如果历史数据很多,例如:history1...historyn。你可以结合流的特性,方便处理「背压」。
4.PassThrough 双工流使用场景
1) 日志多路分发(Multiplexing)
当需要将同一份日志同时写入文件和控制台时,可以使用 PassThrough 作为统一入口:
javascript
import fs from 'node:fs'
import { PassThrough } from 'node:stream'
const logStream = new PassThrough();
const fileStream = fs.createWriteStream('app.log');
logStream.pipe(fileStream); // 写入文件
logStream.on('data', (chunk) => {
console.log('Log:', chunk.toString()); // 输出到控制台
});
logStream.write('This is a log message.\n');
logStream.end();
这种方式避免了重复写入逻辑,且保持数据一致性
2) 合并多个输出流(如 stdout + stderr)
在执行子进程(如 shell 脚本)时,常需同时捕获 stdout 和 stderr 并合并为一个输出流返回给 HTTP 客户端。此时 PassThrough 可作为内存中的"汇聚点":
javascript
import spawn from 'node:child_process'
import { PassThrough } from 'node:stream'
const child = spawn('sh', ['script.sh']);
const memoryStream = new PassThrough();
child.stdout.pipe(memoryStream, { end: false });
child.stderr.pipe(memoryStream, { end: false });
child.on('close', () => {
memoryStream.end(); // 所有输出完成后关闭流
});
// 在 Egg.js 或 Express 中可直接设为 ctx.body 或 res
ctx.body = memoryStream;
这里 PassThrough 充当了"内存缓冲流",将两个独立流合并为一个可读流
注意事项
- 背压(Backpressure)自动处理 :
PassThrough遵循标准流的背压机制。当下游消费变慢时,上游会自动暂停,防止内存溢出 - 不要同时使用
pipe()和手动data监听 :一旦注册data事件,流会进入"流动模式",此时不能再安全地使用pipe(),反之亦然 - 错误传播 :通过
pipe()连接的流链中,任一环节抛出error事件,通常需手动监听并处理,否则可能导致进程崩溃