好,这篇文章写给那些跟我一样,被前端异步事件流折磨过的人。
先解释一下标题。rea 不是某个开源库的名字,而是我们组里内部项目代号——React Event Adapter 的缩写。它是一套零依赖的事件编排方案,用不到 600 行 TypeScript 实现了事件的统一订阅、级联触发、超时取消和调度优先级。如果你正在处理复杂异步交互,比如多个接口联动、长列表轮询、音视频状态同步,或者只是想让项目里散落一地的 addEventListener 和 onXxx 回调变得可控,那这篇分享应该能给你一些直接能用的思路。
文章会按我真实的推进顺序来写:先讲为什么自研而不是引库,再讲核心架构和关键代码,接着是一套完整的接入示例,最后是踩过的问题排查记录。没有虚的。下面直接进入主题。
1. 项目概述与需求拆解
1.1 rea 的定位:它到底是什么
很多人第一眼看到 rea 会以为是“又一个状态管理库”。不是的。状态管理解决的是“数据放在哪里、怎么变化”,rea 解决的是“这些变化之间如何触发、如何取消、如何编排”。它在两者之间填了一个非常容易被忽视的空隙——事件关系的编排层。
举个例子。一个跨平台收银终端项目里,扫码枪扫码成功之后,需要同时触发商品查询、金额计算、日志上报,并且其中商品查询成功后才能开始渲染推荐商品;如果用户在这期间按了取消键,所有进行中的任务都要中止,日志也要标记为“用户取消”。如果直接用 Promise 链去写,取消逻辑会散落到各个回调里;如果用第三方事件总线,又会遇到“订阅了但忘了取消导致内存泄漏”的老问题。
rea 把这些统一抽象成一个调度网络:每一个事件进来,先经过适配器转换成统一格式,再由调度器决定分发到哪个订阅器、按什么优先级执行、是否可以被取消。调用方只需要声明“我想在什么时候关注什么事件”,剩下的生命周期管理由运行时接管。
1.2 目标是解决哪一类问题
我把需求拆成了五个具体问题,这也是 rea 的验收标准。
第一个问题是“累计订阅”。页面切来切去,组件挂载卸载,事件订阅数量如果不收敛,几十分钟后浏览器就会卡顿。rea 要求所有订阅都返回一个取消函数,并且在宿主对象被回收时能自动解绑。
第二个问题是“触发顺序”。业务上经常有“先 A 后 B 再 C”的强时序要求,但事件系统天然是广播式的,现实又比 Redux 的 reducer 复杂得多。rea 希望支持同步任务和异步任务混合编排,同时允许声明式指定依赖关系。
第三个问题是“失败恢复”。接口报错、网络超时、数据格式不合法,都可能导致一条事件链中断。rea 里每个任务节点都可以声明超时时间和失败策略,失败策略包含“跳过、重试、中断整条链”。
第四个问题是“性能开销”。很多现成方案引入几十 KB 依赖不说,还会在每次派发时构建很多中间对象。rea 的约束很明确:单次派发不新增堆分配,节点调度必须在毫秒级完成。
第五个问题是“可调试性”。复杂异步最怕“不知道事件走没走、走到哪一步”。rea 内置一个采样模式的日志中间件,打开后能把每个事件包的流转路径打印到控制台,不需要额外接调试工具。
如果你手头的项目也有这五类问题中的两三样,那这套方案对你有参考价值。
2. 整体设计思路与方案取舍
2.1 三层架构:事件源、适配器、运行时
先看整体分层。rea 的最上层是事件源,也就是业务代码发出事件的入口;中间是适配器层,负责把各种不同形态的输入统一成标准事件包;最底层是运行时,包含事件总线、调度器和任务执行器。
事件源很简单,就是一个带泛型参数的emit函数。调用方不需要关心事件是怎么被路由的,只需要把一个事件名和载荷丢进来。适配器层解决的是输入形态差异:有的是原生 DOM Event,有的是定时器回调,有的是 WebSocket 消息,有的是 Promise resolve 值。统一协议的意义不在于格式好看,而是让下游订阅者只依赖一种结构,不用为每种输入源写分支判断。
运行时是核心。事件总线维护“事件名到订阅节点”的多级索引,调度器维护任务依赖图和优先级队列,执行器负责任务的同步/异步切换。三层之间通过一个轻量上下文对象串联起来,每个事件包在进入总线时会附带requestId,就像快递包裹上的运单号,整个生命周期的日志、取消、重放都靠它。
我把这套架构戏称为“三个抽屉”:业务代码只需要打开最上层抽屉把东西扔进去,其他两层自己消化。
2.2 为什么坚持零依赖
第一次设计方案时,团队里有人提议直接引入一个成熟的事件流库。表面上能省不少事,但实测下来有三个痛点。
第一是体积。一个跨平台终端里,前端包体积直接影响加载速度,而那类库的核心体积不小,加上类型定义和运行时辅助函数,压缩后也有一大坨。
第二是类型侵入。成熟库往往带一套强类型标记体系,类型参数在十几个文件间穿透,我们的业务代码不少是纯 JavaScript 的老模块,强行上类型标记会弄得鸡飞狗跳。
第三是取消语义不完全匹配。我们需要的“取消”是业务级别的:用户按了取消键,整条商品查询链要停止,发出去的网络请求不一定能中止,但后续渲染和上报不能继续。这要求任务执行器协作式响应取消令牌,而不是简单销毁订阅。当时找了一圈,没有完全符合我们语义的方案。
所以我决定写一个极简版本。零依赖还有一个额外好处:代码可以原样放进项目的任何模块体系里,哪怕是旧的 script 标签引入方式也不冲突。实际运行时体积压缩后大约 2 到 4 KB,具体取决于配置选项是否裁剪。
2.3 与主流方案的横纵对比
用一张表说明 rea 与几种常见方案的区别更直观。
| 方案 | 事件订阅 | 级联异步 | 可取消 | 调度优先级 | 体积 | 上手成本 |
|---|---|---|---|---|---|---|
| 原生 addEventListener | 支持 | 不支持 | 部分 | 无 | 0 | 低 |
| 通用状态管理库 | 支持(store 订阅) | 需要中间件 | 困难 | 无 | 中等 | 中 |
| 某响应式扩展库 | 支持 | 强力支持 | 支持 | 有 | 较大 | 高 |
| rea | 支持 | 支持 | 全面支持 | 有 | 小 | 低 |
对比之后能看出来,rea 的定位不是要替代谁,而是把项目里“不同来源事件互相咬合”的那一层责任单独抽出来。状态管理照常用,原生监听也可以保留,rea 只作为它们之间的粘合层。
我还专门确认过一个细节:rea 不劫持事件,也不改变事件冒泡机制。它只是在宿主环境之上做编排,所以不会破坏原有代码的兼容性。
3. 核心实现细节与实操步骤
3.1 第一步:搭出统一事件包协议
统一协议是整套系统的地基。事件包我设计成了这样:
interface ReaEvent { id: string; type: string; payload: unknown; meta: { requestId: string; timestamp: number; source: string; retryCount: number; }; }不要小看这个结构。id是事件的唯一标识,由创建时生成;requestId是一条业务链路里的公共标识,比如“一次扫码操作”会生成一个requestId,这条链路里产生的所有事件包都携带它。retryCount在失败重试时递增,避免同一个事件包被重复执行无限次。
适配器的任务就是把不同输入转成这个协议。我实现了一个fromDOMEvent适配器,用它包装原生事件:
function fromDOMEvent(target: EventTarget, type: string) { return { subscribe(handler: (e: ReaEvent) => void) { const listener = (e: Event) => { handler({ id: genId(), type, payload: { originalEvent: e, data: extractData(e) }, meta: { requestId: getCurrentRequestId(), timestamp: Date.now(), source: `dom:${type}`, retryCount: 0, }, }); }; target.addEventListener(type, listener, { passive: true }); return () => target.removeEventListener(type, listener); }, }; }这个适配器有个容易被忽略的坑:extractData必须尽早把 e 里的数据拷贝出来,不能直接保存原生事件对象引用。因为原生事件对象在事件循环结束后会被回收,类型标注又不严格,保存引用很容易踩到不确定行为。我在项目里就因为这个返工过一次。
适配器统一了输入,下游代码就不会因为“今天是 DOM 事件、明天是 WebSocket 消息”而改动逻辑。
3.2 第二步:实现事件总线与订阅生命周期
事件总线负责把ReaEvent按type分发到对应订阅列表。核心实现用了一个Map<string, Set<Subscription>>,订阅节点长这样:
interface Subscription<T = unknown> { handler: (event: ReaEvent<T>) => void; filter?: (event: ReaEvent<T>) => boolean; priority: number; once: boolean; }这里我用Set而不是普通数组,原因是订阅节点的增删是高频操作,Set的删除复杂度是 O(1),数组的splice是 O(n)。当订阅量上千时,数组删除会明显拖慢派发。
优先级排序我用了一个非常朴素但好用的方法:每次派发时构建一个临时数组并排序。因为实际场景里每个事件类型的订阅数量不会超过几十个,临时数组排序的时间成本远低于维护一个永远有序的双向链表。
订阅生命周期最重要的设计是“三保险”卸载。第一重,订阅时返回取消函数,调用即移除;第二重,订阅接收一个可选的 AbortSignal,信号触发时自动移除;第三重,内部用 WeakRef 监听订阅者对象,如果宿主对象已经不可达,下次事件派发时会顺手清理这个订阅节点。
我印象最深的一次 bug 就是因为只做了第一重保险:某模块里有个全局单例对象订阅了一个事件,单例本身永不销毁,但是它的内部回调引用了已卸载组件的 DOM 节点,导致内存泄漏。加了 WeakRef 清理后,这个问题才被彻底解决。
3.3 第三步:调度器与任务编排
调度器是 rea 里最有价值的部分。业务上要支持“A 完成之后 B 才执行,B 和 C 可以并行,D 最多重试两次”这样的编排。
我的做法是把每个订阅抽象为“任务节点”,节点之间用依赖表关联。调度器每次收到事件包时,先看依赖表里有没有未完成的父节点;如果有,就把当前任务放入等待队列。父节点完成时,调度器会检查依赖它的子节点是否全部就绪,再决定是否派发。
等待队列的实现是 16ms 时间片的批处理循环。每一帧内只处理一批任务,避免单个事件拥挤时阻塞 UI 渲染。这里的时间片不是硬性实时约束,而是用宏任务切割,让渲染有机会插队。生产环境下如果发现某条链路对延迟特别敏感,也可以把时间片调小到 4ms。
异步任务节点还需要能感知任务完成。我给每个异步任务注入一个taskHandle,带有done(err)和cancel()方法。业务代码在执行真正的异步请求时只需要调用done来通知调度器。这样的话,一个异步请求超时后,调度器可以走失败策略而不是干等着。
任务取消的实现是协作式的。取消令牌设置标志位,每个await之后检查一下标志位,如果已取消就提前返回。这不是一种霸道的线程中断,好处是不会留下“取消之后资源没释放”的脏状态,坏处是业务代码必须自觉在异步操作的关键节点响应取消。
我建议所有使用 rea 的项目都约定一条规矩:凡涉及await的地方,至少要在 await 前后各检查一次取消标志。这条规矩救了我好几次。
3.4 第四步:性能参数选择
有些参数不是拍脑袋定的,我给出几个关键参数和背后的计算依据。
第一是“最大未完成任务数”。调度器默认每个事件类型同时未完成任务不超过 200。这是根据浏览器内存估算的:每个任务节点连同闭包引用大约占 4KB 内存,200 个任务约 800KB,对移动端来说还在安全范围。超过上限时,调度器会丢弃最低优先级任务,并发出告警事件。
第二是“默认任务超时”。我统一设为 3000ms,依据是我们项目里最慢的接口 P99 是 2.2 秒,加上 800ms 缓冲足够。如果你的接口更慢,建议设为 P99 加 30%,而不是拍脑袋定个 10 秒。
第三是“派发队列批处理窗口”。16ms 是浏览器一帧的时间,这样设置可以把积压的事件的处理分摊到多帧,避免长任务卡住动画。桌面端性能好的机器可以改成 8ms,但收益不明显,我实测后还是保留 16ms。
第四是“日志采样率”。线上环境默认是 0.1%,也就是每 1000 条事件只记录 1 条,避免日志系统成为性能瓶颈。开发环境直接 100%。
这些参数都做成了可配置项,但我不想让使用者面对一堆旋钮。所以 rea 提供了一个preset参数,只分 “web”、“node”、“lowPower” 三种预设。多数项目用默认值就够了,个别参数再覆盖。
4. 完整接入示例与参数配置
4.1 一个真实场景的接入代码
用扫码购物的例子串一遍。先创建一个事件总线:
import { createRea } from 'rea'; const app = createRea({ preset: 'web', overrides: { taskLimit: 100, defaultTimeout: 5000, }, });注册需要长期存在的监听器。比如推荐商品渲染,只关心goods:queried事件,并且要求优先级高于普通任务:
const offRender = app.on('goods:queried', (event) => { renderRecommendation(event.payload.goods); }, { priority: 10 });再把扫码枪的 DOM 事件接进来:
const scanSub = adapter.fromDOMEvent(scanInput, 'scan') .subscribe(e => { app.emit('scan:detected', parseCode(e.payload.data), { requestId: genRequestId(), }); });业务链路用调度器编排:
app.sequence('checkoutFlow', { nodes: [ { id: 'scan', eventType: 'scan:detected' }, { id: 'query', eventType: 'goods:query:start', dependsOn: ['scan'] }, { id: 'compute', eventType: 'price:compute', dependsOn: ['query'] }, { id: 'log', eventType: 'log:record', dependsOn: ['compute'] }, ], });这样写的好处是编排和业务执行逻辑分离。checkoutFlow是一个声明式配置,调度器看到它就把依赖图建好。取消整个流程只需要:
app.cancelFlow('checkoutFlow');取消之后,query、compute、log节点会收到取消信号,而scan本身还是挂着的,因为扫下一次码依然要触发新链路。这个细节是我反复斟酌过的:链路取消不能把源头事件也灭了,否则整个功能就废了。
4.2 参数配置表与推荐值
我把常用参数的推荐值整理成一张表,你可以直接抄作业。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| eventBusCacheLimit | 200 | 每事件类型最大缓存任务数 |
| timeout | 3000ms | 单任务超时时间,按 P99 接口上浮 30% |
| timeSlice | 16ms | 批处理时间片,低功耗设备可调整至 32ms |
| maxRetry | 2 | 失败重试上限,超过后进入失败回调 |
| dedupeWindow | 100ms | 相同 payload 的去重窗口 |
| logSampleRate | 0.001 | 生产环境采样率 |
| enableWeakRefCleanup | true | 启用弱引用自动清理订阅 |
dedupeWindow值得单独解释。连续快速触发同一个事件,比如用户连点扫码按钮,同一个码会被解析三遍。rea 的去重不是简单地忽略后两次,而是重置超时计时器:只要 100ms 内再来同样的载荷,之前发起的任务就可能被合并或跳过。这样既保留最后操作的结果,又不重复执行昂贵请求。
我记得第一次没做去重时,测试人员连续快速扫码三张同样的商品条码,结果库存扣了三次。加上去重后,只执行了一次,业务才通过验收。
4.3 模块拆分建议
实际项目里不要把所有东西都塞到一个入口。我的经验是拆成三层文件:
第一层是基础设施文件,比如事件总线、调度器、适配器,每个文件只导出一个类或函数。第二层是业务适配文件,把 DOM 事件、后端推送、定时器等输入源封装成统一的适配器实例。第三层是编排文件,里面只写app.sequence这样的声明式配置,不写任何具体逻辑。
这样拆分之后,日常开发几乎只需要改第三层。产品说“先计算优惠再查询库存”,就调整编排节点顺序,业务方法左右换一下;完全不需要去翻调度器的实现。
这个拆分方式对团队也很友好:新人不用理解整个运行时,只需要理解“事件的输入是什么、输出是什么、顺序由谁决定”三个问题,就能开始改代码。
5. 常见问题排查与技巧实录
5.1 重复订阅导致的 N 倍事件触发
这是问得最多的问题。症状是某个事件每触发一次,业务逻辑执行了两遍甚至三遍。
根源几乎都是同一个:组件的订阅函数在热更新或者路由切换时被调用了多次,但旧的取消函数没有执行。rea 的on返回的取消函数必须在组件卸载时调用,这一点和原生事件的 removeEventListener 一样。
我排查这个问题时用过一个很实用的技巧:给每个订阅节点生成一个随机订阅 ID,并在事件打印日志里带上它。这样日志里出现“某个事件后面跟了三个相同 handler”时,能立刻断定重复订阅,而不必怀疑事件本身被重复派发。
另外一个容易漏的地方是定时器里的订阅。如果你在 setInterval 里调用了rea.on,又没在 setTimeout 里清理,那定时器每次触发都会新增一个永久订阅。我建议项目里统一封装一个useRea钩子(或者等价的生命周期函数),把所有订阅都收口进去。
5.2 高并发下事件顺序错乱
症状是任务本身没问题,但执行顺序不稳定。比如一次scan:detected触发了query和compute,有时候compute先完成,有时候query先完成,导致渲染结果不一致。
我先说结论:rea 默认不保证不同任务节点之间的完成顺序,只保证“依赖关系满足后才会启动子任务”。如果业务依赖query先于compute启动,就必须显式声明dependsOn,不能在代码里默认“先定义先执行”。
实际查这个问题的时候,我用时间片日志发现等待队列里的任务确实是按顺序分批执行的,但异步任务里的微任务穿插会导致最终落点不同。所以只要任务里有任何异步操作,顺序就必须依赖声明,而不是依靠派发顺序。
如果你想让某两个任务强行排队,可以给它们加一个虚拟依赖节点,类似信号量:
nodes: [ { id: 'semaphore', eventType: 'internal:semaphore' }, { id: 'query', eventType: 'goods:query:start', dependsOn: ['semaphore'] }, { id: 'compute', eventType: 'price:compute', dependsOn: ['semaphore'] }, ]这样query和compute都会等semaphore完成后再各自启动。虽然它们还是会并行,但如果你把semaphore设计成一个真正异步完成后才 resolve 的任务,就能精确控制启动时机。
5.3 内存泄漏:WeakRef 的坑
前面提到 rea 用了 WeakRef 清理订阅,但这引出了一个新的坑:如果宿主对象一直被不可见的变量引用,WeakRef 永远不会触发清理。
排查的时候,我发现一个页面上有几百个只执行一次的once订阅。这些订阅执行完本来该被移除,但是因为闭包链里保存了一个外层大对象,导致订阅节点的handler里引用的数据一直可达。WeakRef 只作用于宿主的弱引用,不会帮你解开闭包。
我的建议是:所有once订阅的回调函数里,不要引用比订阅本身生命周期更长的对象,尤其是组件实例、全局配置对象。如果确实需要读这些数据,在订阅回调里通过getSnapshot或者getState获取,而不是直接闭包捕获。
排查内存泄漏的老办法还是好用的:控制台performance.memory快照对比、Chrome 的 Allocation Timeline、连续多次进入页面再退出,观察节点数量是否回升。rea 增加了一个debugLeak开关,打开后会在订阅创建时保留一个精简的调用栈,泄漏发生时能定位到是哪个模块创建了订阅。
5.4 死锁和无限循环
还有一种场景很吓人:A 事件触发了 B,B 的回调里又触发了 A,导致两个事件在调度器里互相打转,微任务队列完全卡死。
rea 的循环保护措施是给每个requestId一个深度计数器,默认最多 32 层。任何事件链如果达到 32 层还没结束,调度器会主动截断并抛出报警事件。这个设计有点像是防递归栈溢出,但作用在事件流层面。
有一次线上事故就是这个循环保护启的作用。当时问题源头是某个配置对象的解析逻辑在异常路径下返回了一个默认值,这个默认值又触发了一个订阅,那个订阅再次触发了解析逻辑。最终循环被截断,但我们还是花了不少时间定位,因为日志里 32 层全部是同一对事件。
排查循环的实用方法是在日志中间件中打印完整调用链。rea 的日志中间件支持traceId展开,只要开一次完整追踪,就能看到事件是从哪条路径进入循环的。别依赖人肉推演,循环路径通常很绕。
6. 在真实项目中的实测结果与心得
6.1 压测数据与表现
说一个压测实例。某跨平台收银终端里,原方案是一堆散落的 Promise 回调加上两个全局事件对象,每次扫码高峰时段,事件回调总数平均在 47 个左右。接入 rea 后,同类场景的事件回调经过编排和去重,平均落到 12 个,峰值耗时从原来的 210ms 降到 43ms。
内存方面也有可观改善。之前每 10 分钟进行一次页面会话切换,内存增量大约 23MB;rea 全面接管订阅生命周期后,增量降到 6MB,其中还有一部分来自第三方图表库。
当然也有例外。在一次读卡器高频推送事件场景里,rea 的去重窗口反而引入了额外内存:大量快照数据被保存在去重表中等待窗口过期。当时我没有算好窗口大小,500 条推送全部被缓存,显得很笨重。最后把dedupeWindow调到 30ms,并关闭大部分事件的去重,情况立刻好转。
这说明一个道理:任何框架的默认参数都必须结合具体场景校准,不要盲目用官方的推荐值。官方推荐值是基于普遍情况,不是基于你的业务。
6.2 我踩过的三个深坑
第一个坑是“适配器包了一层又一层”。最初为了让事件格式统一,我在多个层级建了不同的包装对象,结果每次派发都要深拷贝 payload,性能直接翻车。后来改成不可变引用共享,payload 只有在明确需要修改时才浅拷贝。
第二个坑是“过度依赖调度器”。曾有同事想让调度器包揽所有业务分支判断,把复杂的 if/else 写进节点配置,结果配置本身变得比业务代码还难读。现在团队的约定是:编排只关心依赖和执行,具体的业务判断仍然留在普通函数里。
第三个坑是“取消信号传得不彻底”。最初实现取消时只给节点本身发了信号,但节点内部的子异步调用没有共享同一个取消信号,导致用户取消后,子任务还在后台跑,造成状态污染。后来的修复是统一使用requestId作为取消上下文,任何子任务都可以通过requestId查询是否已取消。
6.3 后续扩展方向
rea 目前还有一个比较明显的短板:跨标签页或者跨 Web Worker 的事件同步。目前的实现范围是单个宿主环境内,跨环境的场景要么通过 postMessage 转发,要么得把事件包序列化后重新注入。后续我想做的是一个可插拔的 transport 层,让 Worker 环境下的事件也能走同一套调度器。
再有就是运行时快照。配合请求链路追踪,我希望支持“把当前所有挂起任务的快照序列化到一个 JSON 文件”,这样用户反馈问题时,直接丢快照过来就能定位到是哪条链路卡住了。这个功能还在原型阶段,目前只在内部 demo 上跑通。
如果读者对 rea 这套编排思路感兴趣,我建议不要直接照抄代码,而是先画一张你项目里最复杂的事件依赖图。把图画出来,你会发现真正的问题经常不在某个库的 API 上,而在事件关系本身。rea 的意义就是把这个“关系”变成代码里看得见、追得到、取消得掉的东西。按这个思路去改你自己的项目,哪怕不引入任何库,也能收获不少。