如果你最近正在一个组件数量破百、模块联动频繁的页面里调试共享状态,你大概也会和我一样,需要一个像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 -> Set<callback> const eventListeners = new Map(); // eventName -> Set<callback> 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里连续set,get返回的是最新值,因为底层对象已经改了,只是还没通知订阅者;而订阅回调里通过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" 掩盖问题。这两个习惯陪着我排查了大量日常问题,算是这个项目里最值得保留的遗产。