恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

自研轻量级状态管理工具rea:事件总线与状态仓库的工程实践

  • 首页
  • 资讯中心
  • /
  • 自研轻量级状态管理工具rea:事件总线与状态仓库的工程实践

相关资讯

Notepad++ Markdown插件安装与预览白屏解决指南 2026/10/18 20:17:40
React 19 Fiber源码解析:位运算如何撑起高并发调度体系 2026/10/18 20:17:40
Cursor 半价购入(仅限七月):把 Base URL 改到 TaoToken 的配置与验证 2026/10/18 20:12:39

最新资讯

COMSOL钻井仿真实战:环空流场、井壁稳定与循环温度场
英德景观石厂家竞争力盘点:景石源供应链优势全解析
Tesseract-OCR中文识别安装配置全指南
.NET 6 Web API 快速骨架:SQL Server + JWT 鉴权全链路
支付宝SDK转H5支付链接:签名逻辑与调起避坑指南
POMDP下的可扩展策略优化与记忆增强PPO实战解析

今日推荐

基于GMM的语音识别系统原理与MATLAB实现详解
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

本周热门

基于GMM的语音识别系统原理与MATLAB实现详解
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

自研轻量级状态管理工具rea:事件总线与状态仓库的工程实践

发布时间:2026/10/18 20:17:40
自研轻量级状态管理工具rea:事件总线与状态仓库的工程实践 如果你最近正在一个组件数量破百、模块联动频繁的页面里调试共享状态你大概也会和我一样需要一个像rea这样的小工具。不是那个搜索框里常见的英文单词而是我自己写的一个约三百行的轻量状态与事件管理模块——全称叫 Reliable Event Assistant缩写恰好是 rea。这个项目最初只是为了解决某个管理后台里props 层层传、回调满天飞的混乱局面结果越用越顺后来逐步补上了事务更新、类型推导和持久化插件成了我日常开发里离不开的基础设施。这篇文章我直接把 rea 的设计思路、核心源码、以及在生产环境里踩过的坑拆开讲。它适合谁适合那些不想为一个全局状态管理方案引入重依赖、但又需要跨组件通信和共享状态的团队也适合想读源码、想自己手写一个状态管理工具的初级和中高级开发者。你不需要有很深的前端基础只要会 JavaScript 的基础语法就可以跟着思路走一遍。代码我会拆开讲参数怎么设计、为什么要这么设计都会说清楚。1. rea 是怎么冒出来的一次跨模块通信的痛点复盘1.1 那个让我崩溃的三层回调项目是某团队的一个模拟后台管理系统页面结构不复杂但模块边界特别碎顶部筛选区、中间表格区、右侧详情面板、底部操作条四个区域各自独立却共享着十几个状态。一开始大家用组件框架自带的属性传递从父组件传到子组件再传到孙组件中间还要层层转发事件回调。改一个字段往往要顺藤摸瓜改三个文件加上同事偶尔忘了给中间层补回调参数线上就出现按钮点了没反应的诡异 Bug。真正让我决定动手写的是一次连续排查四小时的经历。某个筛选条件从顶部传到表格再通过事件回调冒泡回父级父级再改另一个状态触发详情面板刷新。链路里有九个环节任何一个环节的名字写错整条链就断了。我那时候看着代码里层层嵌套的change和:value意识到问题不在某个具体写法而是这个通信模型本身就不适合跨模块联动。1.2 为什么没直接选完整状态容器当时团队里也在讨论是否引入社区里常见的全局状态容器。我翻过文档功能确实全但为了二十多个变量、十来个联动事件要学习概念、配模块、写严格模式、接插件明显大材小用。更重要的是这类方案通常强制整个应用的数据流都走它对老项目改造来说成本很高——你得把现有组件的内部状态全部迁移出来才能体现价值。我理想中的方案很简单只要能有一个地方集中存共享数据各模块能读能写并且能订阅变化再有一个跨模块事件总线模块之间可以发布和监听消息不需要知道对方是谁。就这两件事不需要不可变数据、不需要时间旅行、不需要额外的网络层。rea 的雏形就是基于这两个需求写出来的。先说明一下这只是一个从实际场景出发的合理设计决策不代表完整状态容器不好只是在当时的项目体量下一个两百行的自研模块显然更划算。2. rea 的核心设计事件总线与状态仓库的合体2.1 整体架构只有三个概念rea 从设计第一天起就只围绕三个抽象状态仓库、事件总线、派生订阅。状态仓库负责存数据暴露get和set事件总线负责模块间通信暴露on、off、emit派生订阅是状态仓库的扩展允许你监听某个路径的变化并执行回调。传统思路会把这三件事放进三个对象里但我在实际使用中发现把它们浓缩到一个createStore调用里调用方的心智负担最低——你只需要记住一个 store 实例所有操作都从它身上拿。我们看最简单的用法const store createStore({ keyword: , selectedRows: [], panelVisible: false }); // 读取 const kw store.get(keyword); // 写入并通知监听者 store.set(keyword, rea); // 订阅变化 store.subscribe(keyword, (newVal, oldVal) { console.log(关键词变了, oldVal, -, newVal); }); // 跨模块事件A模块发布B模块监听 store.on(refresh-list, () { reload(); }); store.emit(refresh-list);第一版用起来已经能覆盖 80% 的场景。后来我补充了一个subscribe的设计细节它支持用点分路径监听嵌套属性例如store.subscribe(user.profile.name, cb)而不只是监听顶层字段。这背后是一个很朴素的判断——团队里写业务代码的人不会愿意每改一个嵌套属性就手动触发一次set让库去解析路径、定位变更目标才是最省事的做法。2.2 为什么状态和事件要放在同一个实例上有人问过为什么事件总线不能单独拆成一个对象我的看法是能拆但没必要。把这两样放在一起最大的好处是出了问题只看一个对象不需要引入第二个全局变量。调试时可以console.log(store)一眼看到当前状态树和所有注册的监听器。而在模块销毁时你也只需要调用store.clear()就同时清空了状态和事件监听避免了很多泄漏问题。合并带来的另一个好处是状态变化 通知他人这种高频模式可以写成原子操作。例如store.set(modalVisible, true); store.emit(modal-opened, { source: list });这两行业务代码以前要分散在两个模块里现在因为set内部已经带上了变更通知事件触发只需要传给那些跟状态无关的信号场景——比如列表加载完成用户点了一次刷新。这种把数据和信号分开的语义长期维护时确实更清晰。2.3 关于“浅比较”和“引用不变”的取舍状态管理里最容易翻车的就是对象的变更检测。rea 默认采用浅比较 路径解析的组合策略set(user.name, 张三)会直接修改原对象然后根据路径通知订阅者而set(user, newObj)会对比新旧引用只有引用变了才通知。这种方法不像不可变数据那样严格但胜在直观符合大多数同事的编码习惯。缺点是如果你在回调里直接改动oldValue的子属性就会污染上一次的状态快照。我的处理方式是传给回调的oldValue做一次浅拷贝避免误改。代价是每次订阅回调会多出一点内存开销实测结果对中型项目可以忽略。关于这里的取舍后文还会细说。3. 从零手写 rea 核心源码拆解与关键取舍3.1 createStore 的骨架实现先给你看完整的核心代码然后再逐段拆。整个文件去掉注释大概三百行下面这个版本是精编之后的主干逻辑function createStore(initialState {}) { const state { ...initialState }; const listeners new Map(); // path - Setcallback const eventListeners new Map(); // eventName - Setcallback const batches new Set(); // 批量更新时的 path 集合 let batchDepth 0; function normalizePath(path) { if (Array.isArray(path)) return path.join(.); return String(path); } function get(path) { if (path undefined) return state; const key normalizePath(path); if (!key) return state; return key.split(.).reduce((obj, k) (obj null ? undefined : obj[k]), state); } function set(path, value) { const key normalizePath(path); if (!key) throw new Error(set 需要一个有效路径); const keys key.split(.); const lastKey keys.pop(); let target state; for (const k of keys) { if (target[k] null || typeof target[k] ! object) target[k] {}; target target[k]; } const oldValue target[lastKey]; target[lastKey] value; const changedPaths new Set(); collectChangedPaths(key, oldValue, value, changedPaths); if (batchDepth 0) { changedPaths.forEach((p) batches.add(p)); } else { changedPaths.forEach((p) notifyPath(p, value, oldValue)); } return value; } function subscribe(path, callback) { const key normalizePath(path); if (!listeners.has(key)) listeners.set(key, new Set()); listeners.get(key).add(callback); return () unsubscribe(key, callback); } function on(eventName, callback) { if (!eventListeners.has(eventName)) eventListeners.set(eventName, new Set()); eventListeners.get(eventName).add(callback); return () off(eventName, callback); } function emit(eventName, payload) { const set eventListeners.get(eventName); if (!set) return; set.forEach((cb) { try { cb(payload); } catch (err) { console.error([rea] 事件 ${eventName} 回调执行出错, err); } }); } function batch(fn) { batchDepth 1; try { fn(); } finally { batchDepth - 1; if (batchDepth 0) { batches.forEach((p) notifyPath(p, get(p), undefined)); batches.clear(); } } } function notifyPath(path, newValue, oldValue) { const set listeners.get(path); if (!set) return; set.forEach((cb) { try { cb(newValue, oldValue); } catch (err) { console.error([rea] 路径 ${path} 回调执行出错, err); } }); } function collectChangedPaths(path, oldVal, newVal, out) { out.add(path); if (oldVal newVal typeof oldVal object typeof newVal object) { // 递归找到第一层不同的子路径 const keys new Set([...Object.keys(oldVal), ...Object.keys(newVal)]); keys.forEach((k) { const oldChild oldVal[k]; const newChild newVal[k]; if (oldChild ! newChild) { collectChangedPaths(path . k, oldChild, newChild, out); } }); } } function clear() { listeners.clear(); eventListeners.clear(); batches.clear(); batchDepth 0; } return { get, set, subscribe, on, off: (name, cb) off(eventListeners, name, cb), emit, batch, clear, _debug: () ({ state, listeners, eventListeners }) }; }3.2 路径解析与 collectChangedPaths 的设计意图上面代码里最容易让人困惑的是collectChangedPaths。为什么set(user, newObj)之后监听user.name的回调还会触发因为我在对比新旧值的时候一旦发现引用不同就递归第一层让子路径的监听者也收到通知。这是一个很实际的需求——你拿到的newObj很可能来自后端接口数据整体替换了但界面上的字段粒度订阅在user.name如果只通知user一级界面就会漏更新。倒过来想如果每次都递归到最深层遇到大树结构会浪费性能。所以这个函数只做第一层差异定位先加上当前路径然后只对直接子属性做一次引用比较子属性不同就往子路径继续递归直到遇到原始值为止。这背后的逻辑有点像你在大楼里找人先确定在哪一层然后只在这一层按门牌号找不会把所有楼层都翻一遍。实际项目里把嵌套对象整体替换的频率不高而单层叶子字段的更新是绝大多数场景所以这套策略兼顾了正确性和性能。3.3 批量更新 batch解决一次请求触发 20 次刷新问题在做 rea 的早期版本时我无意中发现一个性能问题某个模块在初始化时要连续set十几次比如设置筛选条件、分页信息、表格排序结果订阅了多个路径的组件被重复渲染了很多次。这正是批量更新要解决的核心矛盾——一组状态变更应该只通知一次。batch的实现思路很朴素用一个计数器记录当前的批量深度在批量执行的函数里所有set都不直接触发通知而是把变更路径收集到一个 Set 里。等批量函数执行完再统一对收集到的路径去重并通知。我特意把批量做得支持嵌套调用batch内再调batch深度加一只有最外层结束才真正派发。这个细节在工作中很实用因为一个公共函数里写了batch调用它的外层可能也在batch里不会出现内层已经通知、外层再通知一次的问题。3.4 为什么emit不绑定到状态路径上前面提到 rea 把状态与事件合体但实现上事件总线和路径订阅是两套独立的 Map。语义上的差异是路径订阅处理数据变化事件处理动作发生。比如列表刷新完成是一个动作它不代表某个状态字段改变了而list数组变了是数据变化。两者混在一个 Map 里容易互相污染独立性更强清理起来也更可控。emit的实现里加了一层 try/catch单个监听回调抛错不会影响其他回调。这个设计是踩过坑后补上的——有一次上游模块的事件回调写错了导致下游一整片逻辑不执行排查了很久才意识到是某个回调直接抛异常中断了 JavaScript 的事件循环派发。4. 生产环境踩过的坑时序、泄漏与并发更新4.1 事件时序先emit后on消息就这么丢了rea 的第一个版本emit只通知当前已经注册的监听者。合作团队有个模块在渲染完成后发出了一个 ready 事件另一个模块在异步初始化完成后才on(ready)去接收结果事件先到监听后设消息凭空消失。排查链路是这样的不能只看代码逻辑还要看初始化顺序。原因是两个模块分别在两个异步函数里启动没有做顺序协调。最终的解决方案是给事件总线加了历史信号记忆允许emit之后晚来的监听者能拿到最后一次载荷。实现上不复杂在emit里用一个全局的lastPayloadByEvent缓存on的时候如果缓存里有值立刻带着缓存值调用一次回调。但要注意这个行为必须做成可选项否则所有事件都变成记忆事件反而会在不该迟到触发时产生重复执行。下面是简化版实现function emit(eventName, payload, { keepLast false } {}) { if (keepLast) lastEventCache.set(eventName, payload); const set eventListeners.get(eventName); if (!set) return; set.forEach((cb) { try { cb(payload); } catch (err) { ... } }); } function on(eventName, callback, { replayLast false } {}) { if (replayLast lastEventCache.has(eventName)) { callback(lastEventCache.get(eventName)); } addListener(eventName, callback); }这个迟到补偿是对时序问题最优雅的兜底但注意不要滥用。放在rea里我一般只给initialized这类只发生一次的信号开replayLast普通事件保持实时派发。4.2 内存泄漏订阅了但是从不取消subscribe和on的返回值都是取消函数但项目里同事经常忘记调用尤其组件销毁时只记得清理定时器不记得取消状态订阅。结果就是用户切换页面几十次以后内存里堆了大量回调每次set都触发一个已经被销毁组件的重渲染逻辑。我把这个教训写在 README 里并且做了一个更主动的辅助store.createScoped()。它返回一个新的子 store父 store 暴露一个releaseScope()一次性取消该子 store 创建的所有订阅可以在组件卸载时统一调用。实际体验下来比逼着每个开发者都手动保存取消函数要可靠得多。如果你自己想实现核心只是多维护一个scopeId前缀订阅路径和事件名都加上这个前缀。4.3 并发更新多个接口竞态导致旧数据覆盖新数据这是相对难排查的问题。页面进入时并发请求了三个接口第一个请求返回用户基础信息第二个请求返回权限列表第三个返回订单数据。网络状况不可控有可能是最后一个请求先返回然后触发store.set(user, 已过期数据)把后返回的真实用户信息覆盖掉。传统做法是在组件层加 loading 状态、请求取消等逻辑。rea 的解法是给你的set增加一个可选参数version用来做过期判断store.set(user, data, { version: currentRequestId });在set内部如果发现 save 信息中已存在同一个路径而新写入的version比旧version小就丢弃这次写入。这个方案比起在业务层手工判断更集中而且所有并发请求只需要在入口处生成单调递增的请求编号即可。如果你的接口里没有统一的请求封装也可以把version直接放进数据里每次写入时对比一下。总之要记住一点竞态问题的本质是顺序不是数据本身必须在写入前就把顺序关系规范化。4.4 批量里的get读的还是旧值需要单独处理用过其他状态管理库的人可能会有一个直觉set是同步的那set之后立刻get应该能拿到新值。但如果是batch里连续setget返回的是最新值因为底层对象已经改了只是还没通知订阅者而订阅回调里通过store.get(path)拿到的也是新值。真正要注意的是在批量内触发的事件回调里store.get看到的值可能处于中间态不是最终态。比如批量内先set(a, 1)再set(a, 2)你在中间某个事件回调里读get(a)读到的是最新当前的2但批量还没提交。这种时刻你没法第一时间拿到这一批开始时 a 的旧值。如果有这种需要给batch的回调传入一个snapshot参数会比较省心。我的实现里面batch(fn)可以接收fn(beforeSnapshot)批量开始前把需要快照的路径读一遍。虽然不是所有场景都需要快照但真碰到批量提交前要向后台记录旧状态这类需求这个能力很值钱。5. 什么场景该用 rea什么场景别硬上5.1 适合 rea 的三类场景从实践来看rea 最适合的是中等规模的应用内部状态管理尤其是这几个特征同时出现时。第一跨模块通信的路径比较绕比如表格、筛选器、详情面板之间的联动。第二共享状态的总量不大可能不超过几十个字段而且变更集中在少数几个路径上。第三团队希望降低概念负担不想为状态管理维护一堆文件和规范。在这种场景里rea 能带来很明显的减负效果组件之间不再需要层层传 props不再需要手动维护一个个回调转发只需要一个清晰的 store 入口文件把状态和事件定义好。实测下来一个十个模块的页面用 rea 维护起来比用传统方式至少减少一半样板代码。5.2 不适合 rea 的场景如果应用组件树非常庞大状态之间的依赖关系很强或者你需要精确的变更追踪、时间旅行调试那专用状态容器会更合适。另外如果你面临的是服务端渲染场景模块在服务端和客户端都要执行状态初始化需要处理注水和脱水那么 rea 这种小型方案会显得吃力——它不是为双端运行的复杂生命周期设计的。还有一个我踩过的坑不要在需要多人实时协作编辑的复杂状态模型里硬用 rea。比如类似协同文档场景需要操作序列、合并冲突、状态回滚这已经远超一个小型事件总线的能力边界。任何工具都有它的半径rea 的半径就是中小型应用里同步、快速的共享状态和事件通信。认清边界是选型最重要的部分。5.3 和经典状态容器打个直观对比我知道不少读者会关心 rea 和成熟的状态管理库到底差在哪。用一个通俗的类比成熟方案像是给整个应用装了一套带红绿灯的城市交通系统——你有明确的信号流、管理工具、状态快照但车多时系统本身也重rea 更像是小区里的几条单向道路——路况直观、吞吐够用但如果你指望高峰期在一千个路口做协同调度它就不够了。从代码量上也能看出来rea 的核心文件只有三百行主流程一眼能看完出错时能直接读源码定位。而大型状态容器通常有更丰富的插件生态、调试工具、不可变数据优化但在中小项目里这些能力多数处于备而不用的状态。选择哪个本质上不是能力比拼而是项目规模与团队习惯的匹配。6. 进阶扩展持久化、类型推导与脱离组件树使用6.1 一个好用不贵的 localStorage 持久化插件rea 的源码很小扩展起来也容易。我在项目里加了一个持久化中间件只要在createStore时传入persist配置就会在初始化时读取本地缓存并在每次set后节流写入。核心逻辑只有二十行function createStore(initialState, { persist null } {}) { let state { ...initialState }; if (persist) { const saved localStorage.getItem(persist.key); if (saved) { try { state { ...state, ...JSON.parse(saved) }; } catch (e) { /* 忽略损坏缓存 */ } } } const store createStoreWithState(state); let saveTimer null; const originalSet store.set.bind(store); store.set (path, value, options) { const result originalSet(path, value, options); if (persist !options?.silent) { clearTimeout(saveTimer); saveTimer setTimeout(() { localStorage.setItem(persist.key, JSON.stringify(store.get())); }, persist.debounce ?? 300); } return result; }; return store; }这个插件我用了很久唯一的经验是不要把整个状态树都持久化通常只需要白名单里的几个路径。否则随着状态膨胀序列化和反序列化会变成性能负担。而且 localStorage 可能被用户清掉初始化时如果遇到损坏 JSON解析要放在 try/catch 里。6.2 TypeScript 类型推导从 initialState 反推出完整类型项目上了 TypeScript 之后我给 rea 补了类型。核心思路很简单createStore用泛型推导出整个状态树的结构set的参数限制为树中存在的合法路径。这类实现无需复杂的高级类型体操只要做到顶层足够安全嵌套路径适度放松即可。写类型声明时我遵循一条原则开发体验优先于类型严谨性。如果为了每一层嵌套都做精准推导代码复杂度会大幅上升团队里普通人反而不容易看懂类型报错。所以 rea 的类型定义只保证 get/set 的顶层路径不拼错嵌套路径允许字符串形式。这个折中在实际协作中很受欢迎因为业务代码往往没有耐心去维护一套 path 字面量联合类型。6.3 脱离组件树在普通函数与工具模块里使用rea 有一个其他状态管理方案很少强调的优点它不依赖任何组件上下文。这意味着你可以在一个普通的工具函数里使用store.get判断当前状态也可以在 API 封装层里store.set更新服务端返回的数据。这解决了前端项目中一个非常实际的痛点非组件代码也需要感知状态。举个例子某个日志上报工具需要在全局状态变化时记录上下文这在传统方案里需要手动把 store 实例传进工具模块而 rea 因为 store 本身就是普通的单例对象工具模块直接 import 就能用。代码依赖者少模块就更容易被复用。如果你在一个组件框架里使用 rea可能需要写一个小适配层把store.subscribe转换成组件响应式状态。这个适配层通常只有几十行核心逻辑是组件开始挂载时订阅需要的路径卸载时取消。这比把所有共享状态都塞进组件框架的全局机制里更轻量也让团队可以渐进式地引入不必一步到位改造全项目。7. rea 不是终点由这个项目悟到的一点东西如果你让我总结做 rea 这段经历最直观的一点是很多标准方案在最开始的阶段并不适合你但不是因为它不好而是因为你的场景还不够大。为一个中小项目引入几百行自研状态管理看起来像是重复造轮子但只要这个轮子足够贴合你的实际需求维护成本会比通用方案更低。另外一点经验是写这种小工具库时一定要记得把取消订阅和清理作用域做成第一等公民而不是后续补丁。我在 rea 的早期版本里就没有作用域概念结果后面为了兼容回调和清理逻辑做了一个不算优雅的scope扩展。如果一开始就为资源释放留好接口后续会省很多事。最后再分享一个实用技巧如果你也要写类似的小型状态库建议从一开始就做两件不起眼的事——一是给所有内部数据结构提供_debug()方法方便在控制台直接看监听器数量二是每次set、emit都在未捕获异常时打上路径和事件名别用空泛的 listener error 掩盖问题。这两个习惯陪着我排查了大量日常问题算是这个项目里最值得保留的遗产。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号