资讯动态

REA:2KB零依赖TypeScript事件订阅发布库,前端组件通信解耦利器

发布时间:2026/10/14 9:19:34 来源:尧图企业网站定制
如果你写过两个页面以上的业务系统八成遇到过这种场景页签A里某个按钮点完得让页签B里的列表立刻刷新或者某次请求失败全局要弹一个统一提示而不是每个页面各自处理。这两个页面也许隔着多层父组件props 一层层传过去会让人想摔键盘上全局状态管理又有点杀鸡用牛刀。后来我在某中后台项目里写了个叫 rea 的小工具十来行代码就解决了这个痛点。写着写着需求越来越多我干脆把它整理成了一个独立项目全称叫 Rapid Event Architecture。REA 核心是一个零依赖、类型安全的前端事件订阅发布库代码量控制在 2KB 左右却支撑了我日常开发里几乎所有“瞬时通知、事件解耦”的场景。这篇文章会把这个项目从命名思路、数据结构设计、完整源码到边界处理、打包发布整个过程拆开讲清楚。适合写业务组件容易陷入通信泥潭的前端开发者也适合想搞清楚事件总线内部原理、准备手写一个自己的工具库的朋友。先说明rea 是我自己维护的项目代号下面提到的业务场景都来自我做过的真实项目但细节做了脱敏处理只保留通用逻辑。1. 项目从哪来三字母代号背后的真实需求1.1 组件通信的第三条路事件订阅发布解法先讲一个特别经典的场景。列表页和编辑弹窗在同一个路由下但组件树的深度完全不同列表页挂在路由父级下面编辑弹窗可能是全局 portal 渲染到 body 上两者连血缘关系都算不上。弹窗里点“保存成功”列表页怎么知道该刷新最原始的做法是 props 一路往下传回调中间每一层组件都得给子组件透传这个函数哪怕它根本不用。项目维护到第二年这种透传会把一个很简单的改动放大成三四个文件的修改。另一个做法是把“要不要刷新、刷新哪页”放进全局状态管理里。但这是个瞬时信号不是业务数据塞进全局 store 除了增加状态字段还引入了时序问题状态更新了页面不一定刷新页面刷新了状态又不知道改没改。事件订阅发布恰好是第三条路A 模块发出一个“列表需要刷新”的信号谁关心谁订阅中间层组件完全不用感知。它适合的是那种“一次性、不需回执、与业务数据无关”的通知。你不需要知道对方是谁对方也不需要知道消息从哪来两边只约定一个事件名这就是 rea 要解决的核心问题。1.2 为什么不直接拿现成事件库听到这儿你可能想说社区不是有一堆轻量事件库吗干嘛自己写确实有但用下来的体感有两个痛点。第一个是类型支持问题很多老牌轻量库要么靠额外包装要么直接 any 走到黑在业务代码里事件名一旦拼错、参数一旦传错只能在运行时静默失败调试成本不算低。第二个是行为细节不统一比如“同一个回调重复注册两次emit 时到底触发几次”不同库给的答案可能不一样这种细节在团队协作里最容易埋雷。我的诉求非常明确代码量控制在 2KB 左右、零依赖、事件名和回调参数在编译阶段就能对上同时 on 方法返回一个取消订阅函数比传统库的 off 需要再传一遍 handler 要舒服得多。既然大库有自带的行为包袱小库类型又跟不上干脆自己维护一个。自己写还有一层价值把一个事件总线从 Map 存订阅者开始到泛型、快照遍历、错误隔离一步步写完你对“事件驱动”的理解会比只看文档深刻得多。rea 不一定是最好用的库但它是我能完全掌控行为的工具。1.3 rea 缩写和项目定位命名其实是很随意的事。Rapid Event Architecture快速事件架构缩写正好是 REA三个字母好敲、好搜、好记。它不绑定任何框架纯 TypeScript 实现浏览器和 Node.js 都能跑。定位上rea 不是要替代状态管理库也不是要和大型事件框架比功能它只解决一个问题模块之间需要发信号但不想建立显式依赖。这个定位让我做功能取舍时特别轻松。凡是不属于“订阅—发布”最小闭环的能力我一概不做。比如不内置持久化、不做跨窗口同步、不搞优先级队列。保持小才能让使用方一眼看穿实现遇到 bug 打开源码一屏之内找到根因。小是我刻意维持的设计目标。2. 核心设计先把 API 边界划清楚2.1 四件套 API不多不少rea 对外只提供四个方法on、off、once、emit。on 订阅事件emit 发布事件off 取消订阅once 是“订阅一次后自动取消”的语法糖。这里有两个设计决策值得展开说。第一on 的返回值设计为取消函数而不是返回 this 做链式调用。链式调用写起来好看但在组件里很容易写着写着就忘了把返回值接住最后组件销毁时找不到引用去 off泄漏就是这么来的。取消函数把“订阅和退订”的关系固化在一个作用域里你可以这样写const off bus.on(list:refresh, refresh);组件卸载时直接off()。第二once 不是一个独立的数据结构它只是 on 的包装。once 内部会生成一个包装函数第一次被调用时先 off 自己再执行原始回调。这意味着 once 返回的取消函数依然有效只要原始回调还没触发你随时可以取消这次一次性订阅。功能上四个方法够不够我还真试过加 onAny 监听所有事件、offAll 清除所有订阅最后都撤掉了。onAny 会模糊事件边界调试时更难追踪谁在处理消息offAll 虽然方便但一定会误伤别的模块。四个方法刚好让每个订阅都有明确归宿。2.2 用 EventMap 泛型把事件参数锁死这是 rea 和很多老牌轻量库最不一样的地方。用法如下interface Events { list:refresh: [id: number]; toast:show: [message: string, type?: success | error]; } const bus createReaEvents(); bus.on(list:refresh, (id) { ... }); // id 被推导为 number bus.emit(toast:show, 保存成功); // 参数自动匹配 bus.emit(toast:show, 123); // 编译阶段直接报错这里事件参数用的是元组类型不是对象类型。原因很实际事件发布参数本质是一组位置参数emit 实现就是把它们原样传给回调。用元组[id: number]能精确描述参数顺序和个数TypeScript 在推导回调参数时按顺序展开比包装一个聚合对象更贴近 JavaScript 事件的本意。这种设计也让事件名和参数形成一张映射表。新增一个事件只要在 Events 里加一行所有 on/emit 调用立刻获得类型约束。我在团队里推行一段时间后事件名拼错的概率几乎降为零因为拼错的名字过不了编译。这种“编译期兜底”的价值比运行时控制台里的警告有用得多。2.3 为什么底层用 Map Set而不是对象和数组rea 的数据结构核心是一张事件表落地的选择是Mapstring, SetHandler不是普通对象也不是数组存函数。用对象存事件表事件名一旦带特殊字符就会很别扭删除一个事件得delete object[key]类型推导也不理想。Map 对 key 的处理更纯粹任何字符串都能当 key方法语义也明确get、set、has、delete看一眼就懂。value 用 Set 而不是数组好处非常直接。第一Set 天然去重同一个 handler 重复 on 两次Set 只会保留一个emit 不会重复触发第二根据 handler 取消订阅时Set.delete 是 O(1)不用像数组那样 splice 再移位第三为了遍历安全emit 时会先把 Set 展开成快照数组避免回调里对自己做 off 导致迭代跳跃。这一行拷贝的成本几乎可以忽略但换来的是行为可预期。细节决定成败[...set]每次 emit 都做一次拷贝在订阅规模几百级以内完全无感知没到需要优化的地步就别提前优化。3. 从零实现可运行的 rea 核心源码3.1 不带类型安全的 15 行版本先把最朴素版本写出来。理解它之后后面所有版本都是在这个骨架上加肉function createRea() { const handlers new Mapstring, Set(...args: any[]) void(); function on(event: string, handler: (...args: any[]) void) { let set handlers.get(event); if (!set) { set new Set(); handlers.set(event, set); } set.add(handler); return () { set.delete(handler); if (set.size 0) handlers.delete(event); }; } function emit(event: string, ...args: any[]) { const set handlers.get(event); if (!set) return; [...set].forEach((handler) handler(...args)); } function off(event: string, handler?: (...args: any[]) void) { if (!handler) { handlers.delete(event); return; } handlers.get(event)?.delete(handler); } return { on, emit, off }; }这段代码里有几个细节值得掰开看一眼。取消订阅函数里删完 handler 之后判断set.size 0再删整个事件 key。如果不做这一步Map 里会残留一堆空 Set内存看着没明显涨但做调试、遍历 entries 时全是噪音。emit 里[...set]拷贝快照前面 2.3 提过就是为了保证遍历期间回调里的增删操作不会影响当前这一轮的触发集合。这是事件总线实现里最高频的坑没有之一。off 不传 handler 时直接删除整个事件的所有订阅。这个能力在“清空某类事件的监听”时很有用比如页面切走时注销user:*全部订阅。注意基础版没有 once下一步加。3.2 加上 once 和类型安全完整核心把类型安全加进来代码量会上去一些但使用体验完全不同这正是 rea 的核心价值。完整实现放下面你先整体扫一眼再听我解释关键行type EventMap Recordstring, unknown[]; type HandlerT extends unknown[] (...args: T) void; function createReaM extends EventMap() { const store new Mapkeyof M, SetHandlerunknown[](); function onK extends keyof M(event: K, handler: HandlerM[K]): () void { let set store.get(event); if (!set) { set new Set(); store.set(event, set); } set.add(handler as Handlerunknown[]); return () { set.delete(handler as Handlerunknown[]); if (set.size 0) store.delete(event); }; } function onceK extends keyof M(event: K, handler: HandlerM[K]): () void { const wrapped: HandlerM[K] (...args) { off(event, wrapped); handler(...args); }; return on(event, wrapped); } function offK extends keyof M(event: K, handler?: HandlerM[K]): void { if (!handler) { store.delete(event); return; } store.get(event)?.delete(handler as Handlerunknown[]); } function emitK extends keyof M(event: K, ...args: M[K]): void { const set store.get(event); if (!set) return; [...set].forEach((handler) (handler as HandlerM[K])(...args)); } return { on, once, off, emit }; }这个泛型版本有几个实现心得。once 必须用包装函数。包装函数里先 off 再执行原 handler这样即使原 handler 内部又注册了同一事件新注册的回调也不会在当前轮 emit 里被波及因为快照已经在 emit 开头拷贝完了。先 off 再执行能避免原 handler 再次 emit 时包装函数仍然存在造成的多余触发。关于类型断言store 内部实际存的是Handlerunknown[]因为 Set 泛型要求统一而不同事件的回调参数类型不同在 Map 的 value 里它们天然是变体。这一层as断言被封装在库内部外部调用者完全无感知用起来有完整类型推导内部实现也不用写魔改代码。属于典型的“内部放松、外部收紧”。如果你觉得泛型版本难懂先跑 3.1 的基础版再对照这个版本逐步看会顺很多。3.3 处理异常emit 里的 error 隔离基本功能写完还不能直接上生产。emit 时某个订阅者回调抛异常怎么办最省事的做法是让异常直接抛出去但这会中断后续所有 handler 的执行。一页可能有多个模块订阅同一事件结果第一个回调报错拖累全部显然不公平。更糟的是裸抛出去的异常信息里往往看不出是哪个事件、哪个订阅者的问题。rea 的做法是在 emit 里给每个 handler 包一层 try/catch捕获异常后调用全局错误钩子默认行为是 console.error 并带上事件名和参数信息。需要精确收集时可以注入自定义 onErrorconst bus createReaEvents({ onError(error, event, args) { report(error, { event, args }); }, });这里有两个取舍要说清楚。第一默认不吞异常只是把它从同步中断改成异步上报console.error 依然可见不会让你无声无息丢掉错误。第二onError 里再抛异常会继续冒泡相当于给“错误处理自身”也留了回响避免错误处理函数变成黑洞。自己实现时可按团队规范调整但“一个 handler 异常不能拖垮整条触发链”这条原则建议保留。3.4 性能控制的几个实战心得你可能会担心每次 emit 拷贝快照会不会慢。我跑过一个小压测1 万个订阅者连续 emit 10 万次拷贝带来的额外开销约占总耗时 3%完全可接受。事件总线本来就是轻量通知闸口真正该关注的是 handler 内部有没有重逻辑而不是拷贝本身。与性能相关的另一件事是事件命名。EventMap 里我习惯加前缀ui:、api:、domain:。前缀除了可读性还能在调试时按模块过滤。事件名尽量用英文小写加冒号分隔避免中文和空格这不是 rea 的限制而是为了将来日志检索、统计上报更省心。最终体积控制在压缩后 2KB 左右因为整个库只用现代 JavaScript 自带的 Map、Set、元组类型没有任何运行时依赖tree-shaking 也干净。发布到服务端或嵌入 SDK 时不引入第三方运行时安全面和维护成本都低一个档次。4. 最容易翻车的边界情况与排查实录4.1 重复订阅同一回调到底该触发几次同一事件、同一个 handler 函数、连续 on 两次emit 时触发几次很多用数组实现的库会触发两次rea 用 Set所以只触发一次。哪种更合理我的观点是“一次”更符合直觉。你 on 同一个函数两次往往是代码路径问题导致重复注册而不是真想跑两遍。如果确实要让同一个逻辑跑两遍应该注册两个不同的包装函数或者在回调里自己处理幂等而不是让总线默默重复执行。还有一种常见现象是 on 和 once 同时存在。比如bus.on(x, fn)和bus.once(x, fn)各自触发一次这不算 bug但排查问题时容易被绕晕。遇到“明明只 emit 一次回调怎么跑了两次”的第一反应先去全局搜一下是不是既有 on 又有 once或者有两个模块都调了 on。Set 的去重是针对同一个对象引用两个不同函数当然各自执行。自己实现时建议给 store 加一个只读的 debug 接口比如 listenerCount方便在 devtools 里直接看当前事件和订阅数量排查以上情况会快非常多。4.2 在回调里关闭自己遍历快照的讲究这是手写事件库最容易翻车的场景某个订阅回调执行完后会把自己取消掉。function handler() { console.log(触发); off(x, handler); }如果 emit 直接遍历原始 Set遍历到 handler 时它把自己从 Set 里删了Set 迭代器会立刻跳过后续元素于是你可能只看到一半回调执行了然后戛然而止。rea 用[...set]拷贝快照本轮已经把所有回调放进数组即使回调把原 handler 从 Set 中删除也不影响当前这一轮的遍历。设计这个行为时我特意选择了“本轮继续触发下轮不再出现”。另一种方案是“回调一旦 off 自己本轮后续立刻中断”这在某些顺序敏感的框架里有意义但通用事件总线不该做这种假设。我在实现注释里专门提醒不要把快照遍历改成直接遍历 Set。这里还包括 off 整个事件的情况。如果回调里调用off(x)把事件 key 整个删掉store 里已经不存在 x 了但因为 emit 开头已经拿到当时 Set 的快照本轮剩余回调依然全部触发。这个行为是有意为之符合“当前这批订阅者收到同一条消息”的一致性。4.3 同一事件循环触发为什么不会爆栈有一种担心是A 事件的回调里 emit A会不会无限递归爆栈如果回调里只有那一个 handlerhandler 内部又 emit A第二轮会继续拿到快照并执行同一个 handler确实会无限递归。但这不怪事件总线是业务逻辑自己形成了循环。真正常见的情况是handler 内部 emit A 时注册一个新回调 B由于快照固定B 不会在当前轮执行不会出现“一边遍历一边新增一边无限触发”的组合爆炸。对比某些没有快照的实现rea 多了一层安全网。真遇到业务循环任何事件总线都救不了。排查建议给 emit 加一个调用深度计数器超过阈值直接警告或者干脆在 onError 打点。我实际定位过两次类似问题一次是列表刷新后又触发筛选变更事件一次是表单校验和错误提示互发信号根因都是缺少“状态是否变化”的判断。4.4 内存泄漏要怎么查事件库最常见的内存泄漏通常不是库的问题而是订阅了忘记取消。最常见的模式组件 A 挂载时bus.on(x, handler)销毁时没有调用返回的取消函数。handler 里又通过闭包引用了组件 A 的 DOM 节点或状态于是组件 A 虽然从页面上消失了但整个实例还被事件表牢牢拽住devtools 里表现为无法回收的 Detached 组件。排查按顺序做。第一步全局搜bus.on或.on(看每一处 on 的返回值是否被保存、组件销毁生命周期里有没有调用。很多泄漏肉眼就能看出来。第二步Chrome devtools 的 Memory 面板做 heap snapshot两次快照之间反复进出目标页面对比 Retained Size 明显增大的对象里有没有事件总线的 store 对象。如果 store 的 Set 里 handler 数量只增不减基本坐实“有订阅从未取消”。第三步定位到源头后把问题收敛到“订阅者和订阅周期的归属”上。规范做法是谁发起订阅谁负责取消组件级订阅绑定组件生命周期长时间存活的 Service 层订阅手动维护一个 subscriptions 数组应用关闭时批量 off。4.5 错误上报的兜底策略前面 3.3 写了 onError实际用下来有个问题要注意事件发布方有预期订阅方却可能是别人写的模块。某个模块抛异常后onError 收到的错误如果直接全量上报监控平台告警会刷屏。建议聚合去重相同事件名加相同错误信息在一分钟内只上报一次。还有一个禁忌onError 里不要直接再 emit 同一个事件那可能形成 4.3 说的递归。如果确实需要重新发布用 setTimeout 推到下一个宏任务打破同步调用栈。这个是我接日志平台时踩出来的经验。5. 真实场景里怎么用三个直接抄的示例5.1 列表页与编辑弹窗的刷新联动前面反复提到的那个典型场景实战改造后长这样// list.ts const off bus.on(user:updated, (userId) { fetchUserList(userId); }); // 组件卸载时 onUnmounted(off); // editor.ts bus.emit(user:updated, currentUser.id);保存成功后通知列表刷新彻底从 props 链条里拆出来。列表页只关心user:updated编辑弹窗只负责 emit中间插多少层组件都无所谓。这个改造最舒服的点是后续新增“用户详情页也要刷新”只需要在新增页面里加一行 on编辑弹窗代码完全不用动。5.2 全局请求失败统一提示用 rea 处理 API 失败提示请求层不再直接调用 UI 组件而是发布统一事件const off bus.on(api:error, (code, message) { if (code 401) showLoginModal(); else toast.error(message); });请求层只需在拦截器里bus.emit(api:error, code, message)。这个设计把 UI 策略和请求层彻底解耦后续要改成“部分路由下的 401 不弹全局弹窗”直接在事件处理里加路由判断即可不用动请求代码。更细一点的用法是单独定义api:unauthorized事件替代所有 401 分支的硬编码。5.3 游戏输入/工作台指令解耦rea 不止前端页面能用。我之前做过一个即时交互 demo键盘输入是一层画面表现是一层中间用 rea 传指令bus.on(input:jump, () player.jump()); bus.on(input:move, (direction) player.move(direction)); // 输入层 window.addEventListener(keydown, (e) { if (e.code Space) bus.emit(input:jump); if (e.code ArrowRight) bus.emit(input:move, right); });逻辑单元之间只交换信号不交换对象引用测试也变得干净想模拟玩家输入直接 emit 对应事件不需要真的触发键盘监听。这就是事件驱动在非 UI 场景下的通用价值。6. 从个人工具到可发布项目打包、测试、文档6.1 rollup 一次性打出三种格式自己项目里用直接引源码路径没问题要发布就必须考虑使用方环境可能用 ESM 的现代打包器可能用 CommonJS 的老项目也可能直接在浏览器 script 标签引入。我用 rollup 一处配置打三份import { defineConfig } from rollup; import typescript from rollup/plugin-typescript; import terser from rollup/plugin-terser; export default defineConfig({ input: src/index.ts, output: [ { file: dist/rea.esm.js, format: es }, { file: dist/rea.cjs.js, format: cjs }, { file: dist/rea.umd.js, format: umd, name: Rea }, ], plugins: [typescript(), terser()], });package.json 里三个入口字段不能少{ main: dist/rea.cjs.js, module: dist/rea.esm.js, types: dist/index.d.ts, exports: { .: { types: ./dist/index.d.ts, import: ./dist/rea.esm.js, require: ./dist/rea.cjs.js } } }其中 types 字段很容易漏。单独跑一次tsc --emitDeclarationOnly生成 d.ts没有类型声明的 TypeScript 工具库使用体验直接腰斩事件名和参数全靠猜这一点必须强调。6.2 测试用例按行为写不要按行号凑测试要按行为维度来设计而不是函数名流水账。下面这套是我给 rea 准备的核心用例覆盖的全是容易出问题的行为行为场景期望表现对应实现点on 后 emit 收到参数回调参数完整一致emit 透传参数同一 handler 重复 on只触发一次Set 自动去重once 后 emit 两次仅第一次触发包装函数第一次 offoff 指定 handler该 handler 不再触发Set.delete调用取消订阅函数后不再触发on 返回值闭环emit 不存在的 event不抛错、无副作用提前 returnhandler 抛异常不影响同事件其他 handlertry/catch onErroremit 期间回调新增订阅新增订阅不在当前轮触发快照遍历这套用例跑完基本把最容易出问题的边界状态钉住了。特别提醒最后两行异常隔离和快照遍历常被普通冒烟测试漏掉却恰恰是生产环境最容易炸的点。6.3 README 只要三块内容README 别写成论文。我最后保留的结构就三块。第一块一个最小示例从安装到订阅发布全流程控制在 10 行以内让人 30 秒内知道这库怎么用、解决什么问题。第二块API 表格on、off、once、emit 各自的参数、返回值和注意事项。返回值一栏必须写清 on 返回的是取消订阅函数这是 rea 和传统事件库最大的使用习惯差异。第三块和常见事件库的行为差异说明。列出“同一 handler 重复 on 只触发一次”“emit 遍历采用快照”“异常默认 console.error 可用 onError 接管”等关键差异。提前写明这些差异能省掉大量使用者预期不符带来的疑问。README 还有一个隐形价值它本身就是在给你自己的设计做复盘。写不出来的地方往往就是没想清楚的地方。6.4 发布前必须检查的三件事发布前我会固定检查三件事。第一件清点依赖package.json 的 dependencies 为空保证所有代码零运行时依赖。零依赖不是噱头而是减少使用者安全排查面的实际承诺。第二件跑一遍类型负测试除了单测专门写一个.ts文件故意写几处错误调用然后tsc --noEmit确认错误按预期出现。类型系统是工具库的第二个 API负测试能防止它开倒车。第三件真实项目试装把打包产物装进一个日常项目里写一段带类型的模块完整调用 on、emit、once、off再看一遍 IDE 智能提示和类型推导是否符合预期。这个步骤看起来笨但比任何配置检查都有效。写到这里我最想说的是rea 这个项目最有价值的不是那段代码而是通过它完整走了一遍“小而专的工具库”的思考路径。从命名、数据结构选型、边界取舍到打包、测试、文档每个决定都拿真实场景过了秤。最后分享一个小技巧事件名不要在业务代码里直接写裸字符串哪怕有 EventMap 类型兜底也建议专门建一个常量文件统一导出。当事件名变成编译期常量后重命名、全局搜索、版本迁移都会轻松很多这是我后来回头看最庆幸坚持下来的习惯。如果你也打算写自己的事件库我建议先把 3.1 的基础版本跑通别一上来就上泛型和错误处理先理解 Map 和 Set 的语义再一层层往上加。工具库的力量从来不在于功能多而在于边界清楚、行为可预期rea 想做的就是这样一件事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑