很多系统一开始的事件总线都很轻:emit(name, payload),再配一个 on(name, handler) 就能跑起来。真正的问题不会立刻出现,而是随着事件增多、订阅方变多、场景变复杂,名字和 payload 会慢慢失去同步。某个地方仍然发 user.updated,另一个地方已经开始监听 user.profile.updated;某次迭代里 payload 多了一个字段,但下游还按旧形态读取;有的 handler 以为收到的是单对象,另一个地方却开始传数组。
TypeScript 在事件系统里的价值,不是把事件总线写得更花,而是把“事件名和 payload 的对应关系”变成可检查的契约。只要这一层没有被显式建模,系统后面再怎么拆模块、加队列、做回放,事件协作都很容易继续靠记忆和文档维持。
最危险的接口长这样:事件名是 string,payload 是 any
function emit(event: string, payload: any) {
// ...
}
这类写法的坏处不是“没有类型提示”这么简单,而是它让系统的两个核心约束完全脱离了关系:
- 哪些事件名是合法的。
- 某个事件对应的 payload 应该长什么样。
只要这两个约束还靠人脑维护,规模一大就一定会漂移。
event map 是最稳的起点:先把名字和 payload 绑定起来
一种非常实用的建模方式,是先定义事件映射表:
type AppEventMap = {
'user.created': { id: string; source: 'admin' | 'self-service' }
'user.deleted': { id: string; reason?: string }
'invoice.paid': { invoiceId: string; amount: number }
}
一旦这张表存在,发布和订阅接口都可以围绕它推导:
function emit<K extends keyof AppEventMap>(
event: K,
payload: AppEventMap[K]
) {}
function on<K extends keyof AppEventMap>(
event: K,
handler: (payload: AppEventMap[K]) => void
) {}
这类 API 的意义不只是补全更舒服,而是让“事件名”和“事件负载”在类型层面无法脱钩。只要事件名选错、payload 形状不对,问题会在提交代码前就暴露。
事件设计真正要防的是“同名异义”和“异名同义”
事件系统最容易出现的两类混乱是:
- 同名异义:同一个事件名在不同模块里承载不同语义。
- 异名同义:同一类业务事实被多个名字重复表达。
比如 user.updated 这个名字,看起来什么都能装。用户改昵称、改角色、改手机号、改订阅偏好都能叫 updated。短期很方便,长期却让订阅方不知道自己到底该监听什么,也让 payload 越长越杂。
更稳的做法往往是:
- 名称按业务事实切分,而不是按“发生过变化”这种宽泛概念命名。
- payload 只携带当前事件需要承诺的最小信息。
- 大量共享字段通过公共对象或 metadata 包装,而不是每个事件都随意展开。
订阅端类型安全的关键,不在泛型,而在 handler 语义是否稳定
发布端和订阅端的类型常常被讨论成“泛型能不能写出来”,其实更重要的问题是:handler 是否能基于事件名做出稳定假设。如果一个事件 payload 经常改、字段意义经常变,哪怕泛型写对了,订阅端也不会真正稳定。
所以团队在设计 event map 时,最好把这几件事一起定清:
- 这个事件代表什么业务事实。
- 订阅方最少需要拿到哪些字段。
- 哪些字段未来允许扩展,哪些字段一旦变化就算破坏式变更。
TypeScript 能帮你守住结构,但不能替你定义清业务语义。
一个常见失败案例:事件总线很“通用”,但所有变更都在悄悄破约
某团队有一套抽象得很漂亮的事件总线封装,emit 和 on 都做成了泛型,但事件名本身没有中心化建模,而是由各模块自己声明字符串字面量。结果几个月后出现了典型问题:
- 同一个名字在多个地方被重复定义。
- payload 字段逐步追加,但没有人通知所有订阅者。
- 少数 handler 开始自己断言 payload 类型,绕过总线约束。
问题不在总线 API,而在契约没有集中。只要 event map 不是系统级事实,而是多个文件分散声明,漂移迟早会发生。
事件版本演进要像 API 一样认真
很多团队对 HTTP API 很谨慎,却对事件变更很随意。实际上,事件一旦被多个消费者订阅,它就是另一种 API。比较值得固定的规则包括:
- payload 新增字段通常问题不大,但删除和重命名要视作破坏式变更。
- 需要重大语义变化时,优先引入新事件名,而不是偷偷改旧 payload。
- 公共事件和内部事件分层,不要让局部实现细节暴露给全局订阅方。
- 如果事件跨进程或跨服务传播,运行时 schema 校验也要补上。
事件不是“消息发出去就算完”,而是长期协作契约的一部分。
一份事件系统建模检查表
- 事件名和 payload 是否被一张中心化 event map 绑定。
- 事件命名是否表达业务事实,而不是宽泛动作。
- payload 是否只承诺最小必要字段,而非把整个对象一股脑透出去。
- 订阅方是否能仅凭事件名获得稳定的 payload 类型。
- 破坏式变更是否按 API 升级一样被认真对待。
总结
TypeScript 让事件系统最值得做的一件事,就是把“事件名和 payload 的关系”从口头约定变成编译期契约。只要 event map 足够集中、命名足够克制、版本演进足够明确,发布订阅就不会随着规模扩大而变成字符串驱动的隐性耦合。
本批次专题导航:
- 工程边界:TypeScript 项目引用与 tsconfig 分层、TypeScript Monorepo 依赖边界治理、TypeScript 类型检查性能优化
- 协议协作:TypeScript 公共 API 设计、TypeScript 运行时校验与静态类型协作、TypeScript 与 OpenAPI 契约协同
- 落地复用:TypeScript 设计模式实战、TypeScript 测试数据构建
- 状态建模:TypeScript 事件系统建模、TypeScript 表单与错误状态建模
本系列导航:
- 如果你还没把边界输入做成可信数据,先读 TypeScript 运行时校验与静态类型协作
- 若你要把事件流继续落到页面状态,再看 TypeScript 表单与错误状态建模
- 如果你希望对外契约和事件契约一起收口,可读 TypeScript 公共 API 设计
延伸阅读:


