事件循环与 EventEmitter
专栏:TypeScript 与 Node 地基 · 第 5 / 13 篇:::info 学习目标 完成本篇后你能够:解释”单线程为什么不卡”;区分微任务与宏任务的执行顺序;用 EventEmitter 实现发布-订阅;理解 typed events 的类型安全写法。 前置:第 3 篇完成。预计时长:60 分钟。 :::
Node 用一个线程服务成千上万的并发请求,靠的是事件循环:所有耗时操作都委托给系统(网络/磁盘)或定时器,完成后回调被排进队列,由事件循环依次执行。你的代码永远只是”处理一个事件”,处理完立刻让出线程。
执行顺序:同步 → 微任务 → 宏任务
console.log('1 同步')
setTimeout(() => console.log('4 宏任务:setTimeout'), 0)
Promise.resolve().then(() => console.log('3 微任务:then'))
console.log('2 同步')
// 输出:1 2 3 4
规则:同步代码先跑完 → 清空全部微任务(Promise.then、queueMicrotask)→ 执行一个宏任务(setTimeout、IO 回调)→ 再清空微任务 → 循环。await 的续体也是微任务——这解释了上一篇所有关于执行顺序的直觉。
EventEmitter:发布-订阅模式
Node 的 EventEmitter 是事件系统的基础组件:一个”频道”(事件名),多个”听众”(回调),发射时按注册顺序通知:
import { EventEmitter } from 'node:events'
const bus = new EventEmitter()
bus.on('message', (msg) => console.log('收到:', msg)) // 订阅
bus.emit('message', '你好') // 发布
Agent 场景天然适合这个模式:会话里发生的每件事(用户消息、工具调用、轮次结束)都是事件,UI、日志、计费都是订阅者——它们互不认识,却都能响应同一份事件流。
带类型的 EventEmitter:Cordis 事件系统的雏形
裸 EventEmitter 的事件名和参数都没有类型检查。dsh 的做法(也是 LangGraph、Node 生态的常见做法):用声明合并给事件名和参数建立类型映射(下一篇详讲声明合并,先看用法):
interface Events {
'user/message'(content: string): void
'tool/call'(name: string, args: unknown): void
}
// 强类型总线:事件名拼错、参数个数不对都编译报错
bus.on('tool/call', (name, args) => { ... }) // ✓
bus.on('tool/cal', () => {}) // ✗ 编译错误:拼错的事件名
dsh 的 Cordis 在这之上加了三种分发模式(第 4 篇 dsh 专栏详述):emit(广播)、waterfall(洋葱式层层加工)、serial(依次裁决)——本篇的 EventEmitter 就是它们的原点。
一次事件的处理流程
与其他主题的联系
- 第 3 篇的 await 在底层就是微任务——两个异步知识其实是一体的;
- dsh 的 session/event、tools/result 都是本篇模式的工程化版本(多了类型、作用域过滤与分发模式);
- 第 7 篇的插件宿主会亲手实现一个迷你事件总线。
常见踩坑
- 同步代码阻塞事件循环——一个
while(true)或超大 JSON.parse 会让所有请求卡死;重计算放 worker; - 忘掉 emit 是同步的——
emit会立即调用所有监听器(不等事件循环),监听器里的异常会传播到发射点; - 监听器抛错没有兜底——给 EventEmitter 加
error事件或 try/catch,否则一个坏监听器拖垮全局; - 监听器泄漏——
on了不off,长驻进程内存缓慢上涨。once或保存 disposer。
随堂练习(带验收标准)
- 实现一个
TypedEmitter<Events>泛型类:on/emit的事件名与参数由泛型约束。验收:拼错事件名编译报错; - 写三段代码验证执行顺序(同步/微任务/宏任务)。验收:不看运行结果就能预测输出;
- 读
packages/core/session/src/index.ts里session/event的发射处,对照本篇理解”谁在 emit、谁在 on”。