ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

rea事件驱动架构:解耦前端组件通信的轻量状态管理方案

rea事件驱动架构:解耦前端组件通信的轻量状态管理方案

我一直觉得,前端项目做到一定规模之后,最麻烦的不是组件写不完,而是组件之间“怎么说话”太乱了。props 层层透传、回调函数满天飞、再加上好几个全局状态对象,往往改一处要牵出一堆联调。最近在折腾一个内部中后台系统的时候,我把一个叫 rea 的轻量事件驱动架构重新梳理了一遍,感觉这套思路特别适合拿来处理这类问题。rea 的全称是 Reactive Event Architecture,核心就干三件事:定义事件、订阅事件、触发事件。听起来很简单,但把这三件事做扎实,可以替代一大部分状态管理库的日常工作,而且代码量极小,心智负担也低。

这篇内容主要面向两类人:一类是已经写过一段时间业务组件,被跨组件通信折磨过,想找一个轻量替代方案的前端开发者;另一类是还没接触过事件驱动架构,想了解它到底解决什么问题、踩过哪些坑的初学者。我会把 rea 的模块设计、核心实现、在 React 里的真实接入方式,以及我实际用下来的几个常见问题都展开聊一聊。你不需要有太多基础,跟着代码走一遍,基本就能在自己项目里照猫画虎。

1. rea 的整体设计思路:事件驱动不是银弹,但对大部分业务够用

1.1 状态管理的痛点与 rea 的定位

传统的前端组件通信,最常用的是这么几种方式:

  • 父组件通过 props 把数据和回调传给子组件。
  • 子组件通过回调函数把数据再抛给父组件。
  • 多个不相干组件需要共享数据时,就把状态提升到公共父级。

这套模式在小项目里非常好用,但项目一旦进入“多模块并行开发”的阶段,问题就暴露出来了。某个页面有十几层组件嵌套,顶部的一个配置项要传到最底层的表格里,中间每一个层级都得写一遍透传代码。新增一个字段的时候,你得从参数定义开始一路改到最终使用的地方,改漏一个地方,运行时才会报错。

rea 的定位不是要把所有状态方案都替换掉,而是专注于“解耦”这件事。它把组件之间的直接依赖关系,转成“事件发布”和“事件订阅”的关系。发布方不需要知道谁在听,订阅方也不需要知道事件是谁发出来的,两边的代码只依赖事件名和载荷结构。这样一来,新增一个跨模块通信,不需要在组件树里层层开洞,只需要在一个地方定义事件,在另一个地方订阅事件就够了。

我把这种模型总结成一句话:状态是数据流,事件是信号流。状态管理库解决的是“数据在哪里、怎么更新、谁依赖于它”,事件驱动解决的是“谁关心这件事、什么时候通知”。rea 同时做了这两件事,但刻意把事件驱动放在更核心的位置。

1.2 rea 的目录结构与模块划分

rea 不是一个很重的框架,我更愿意叫它“可移植的架构核心”。它的整体目录结构大概是这样的:

模块文件职责
核心状态容器core/container.ts管理 state、on、emit、setState,提供基础订阅能力
派生更新core/effects.ts维护 effect 任务,在状态变化后统一触发依赖更新
调度器utils/scheduler.ts批量调度刷新,避免每次 setState 都同步执行
React 适配adapters/react.ts提供 useRea hook,把 rea 状态桥接到 React 渲染
类型工具utils/types.ts定义事件映射类型、选择器类型

模块划分的原则有两个:核心层不依赖 React,适配层才依赖 React。这样做的好处是,如果你在一个非 React 项目里,比如原生小程序或者某个轻量视图层框架里,也能把 core 和 scheduler 直接拖过去用。

另外一个很关键的设计是“事件名即类型”。我特别不推荐直接用字符串随便定义事件,因为字符串写错不会报错,只在运行时期默默掉线。rea 会在类型层做一个事件映射,让每个事件名和它的 payload 类型绑定起来。后面你会看到,这种类型约束在实操中能省掉大量排查时间。

2. rea 核心模块实现:从事件注册到状态更新,代码其实很少

2.1 状态容器的基础骨架

我先给一个最原始的 rea 核心实现,以便看清楚它到底做了什么。这个版本我刻意去掉了批量调度和类型体操,只保留主干逻辑:

type EventCallback<T = unknown> = (payload: T) => void; interface ReaOptions<T> { initialState: T; } export function createReaStore<T extends Record<string, unknown>>( options: ReaOptions<T> ) { let state = { ...options.initialState }; const listeners = new Map<keyof T, Set<EventCallback>>(); const allListeners = new Set<EventCallback>(); function getState() { return state; } function setState(updater: (prev: T) => T, eventName?: keyof T) { const next = updater(state); const changed = Object.keys(next).some( (key) => next[key] !== state[key] ); if (!changed) { return; } state = { ...next }; if (eventName) { listeners.get(eventName)?.forEach((cb) => cb(state)); } allListeners.forEach((cb) => cb(state)); } function on(eventName: keyof T, callback: EventCallback): () => void { if (!listeners.has(eventName)) { listeners.set(eventName, new Set()); } listeners.get(eventName)!.add(callback); return () => { listeners.get(eventName)?.delete(callback); }; } function off(eventName: keyof T, callback: EventCallback) { listeners.get(eventName)?.delete(callback); } return { getState, setState, on, off, }; }

这段代码里最核心的部分是Set的用法。为什么用Set而不是数组保存监听器?因为Set天然支持 O(1) 的删除操作,而且同一个回调函数不会被重复添加。如果你用数组,删除一个监听器就要splice,还得小心索引错位,实在没必要。

on方法返回一个取消订阅函数,这一点非常重要。在事件驱动架构里,订阅和取消订阅必须成对出现。如果只提供on不提供取消手段,组件卸载之后回调还残留在事件总线上,就会出现“组件都没了,事件来了还在跑”的诡异问题。

setState里我先通过浅比较做了一次变更判断。这个判断看起来不起眼,但能避免很多无效通知。尤其是在批量调度场景下,如果某个事件触发了 10 次setState,其中有 8 次数据没变化,那么这 8 次通知都可以直接跳过。

我用一个生活化的类比来说明这个模型:列表里的发布者不会挨个问“你想不想看”,他只管张贴公告。订阅者如果不想看了,自己把名字从公告栏上划掉。rea 也是这么工作的,发布和订阅完全解耦,双方不互相引用对象。

2.2 自动派生状态:effect 与依赖收集

在很多业务场景里,一个状态变化后,往往需要同步更新好几个派生状态。比如购物车数量变了,合计金额要变,优惠信息也要变。你当然可以在组件里一个个用useMemo手动计算,但事件驱动架构更喜欢用“effect + 自动刷新”的方式。

rea 的 effect 模块维护一个任务集合,状态每次发生变更后,调度器会遍历这个集合执行回调。代码大概是这样:

const effectTasks = new Set<() => void>(); function effect(callback: () => void) { effectTasks.add(callback); callback(); return () => { effectTasks.delete(callback); }; } function refreshEffects() { effectTasks.forEach((callback) => callback()); }

这里要注意一个细节:effect第一次执行时,构造观察者的代码要能正确读取到当前状态。所以我在注册的同时先主动调用了一次 callback,目的是让 effect 内部的初始化逻辑立即生效。比如统计订单金额时,第一次注册就把当前已有金额算出来,而不是等下一次状态变化才计算。

等到setState完成后,只需要在合适的位置调用refreshEffects,所有依赖该状态的派生逻辑就会自动重跑。这个模型的优点是显式、直观,不需要像 Vue 那样做深层依赖收集。但代价是:如果一个 effect 内部读取了多个状态,它可能在任意一个状态变化时被重复执行。所以 rea 的 effect 更适合“幂等”的计算逻辑,也就是说,无论执行多少次,结果都不会出错。

2.3 批量调度:为什么不要每次 setState 都立刻同步执行

如果你直接把emit实现成同步执行所有监听器,会有一个很头疼的问题:一个用户操作里连续触发了三次setState,那么 effect 就会被同步执行三次,渲染层也要跟着重绘三次。在很多业务页面里,这种重复渲染会让性能迅速劣化。

rea 的解决办法是在状态更新和监听器执行之间加一个调度层。我把这个调度器设计成“微任务批量合并”模式:

let dirty = false; function scheduleRefresh() { if (dirty) { return; } dirty = true; queueMicrotask(() => { dirty = false; refreshEffects(); notifyAllListeners(); }); }

当一个事件循环里连续调用多次setState时,只有第一次会真正进入微任务队列,后续的scheduleRefresh都会被dirty标记拦下来。这样,同一个任务周期里的多次状态变更会被合并成一次通知。

这个设计和 React 的批处理思路很像,但脱离 React 也能独立工作。我在内部项目里测过,如果不做批处理,一个包含 50 个订阅组件的表单页面,在联动刷新时会出现明显的闪烁和中间态;加上批处理后,整个页面只重新计算一次,视觉上要平顺很多。

注意:queueMicrotask并不是唯一选择。如果项目兼容性要求高,可以用Promise.resolve().then()代替。但不要用setTimeout,因为setTimeout会引入至少 4 毫秒的延迟,对高频事件来说太慢了。

3. 在 React 和业务场景里的接入:useRea 到底怎么写

3.1 一个可直接复用的 useRea

如果 rea 只是做事件订阅,那它还停留在工具库层面。真正让它变成架构的是 React 适配层。React 18 之后,useSyncExternalStore成了接入外部数据源的标准方式,我建议直接在 rea 的 React 适配层里用这个 API:

import { useSyncExternalStore } from "react"; export function useRea(store, selector) { return useSyncExternalStore( (callback) => store.subscribe(callback), () => selector(store.getState()) ); }

subscribe必须是稳定引用,而且需要返回取消订阅函数。selector最好是稳定的,可以使用函数组件的useCallback包一层,避免每次渲染都产生新的选择器引用。

这里有个容易被忽略的点:useSyncExternalStore要求订阅函数在 store 数据变化时触发 React 重新渲染,但它不会帮你做浅比较。如果你 selector 返回的是一个新数组、新对象,比如state.items.filter(...),那么 React 每次都会认为值变了,从而造成重复渲染。

更好的写法是让 selector 返回基本类型,或者使用 memoized selector:

function selectTotalPrice(state) { return state.cart.reduce((sum, item) => sum + item.price, 0); }

数字不出问题,对象和数组才会出问题。我通常建议团队在 rea 的选择器里“能返回数字就返回数字,能返回单个字段就返回单个字段”。这个习惯能帮你规避掉一半以上的性能陷阱。

3.2 跨组件触发事件:一个购物车的案例

现在用一个最常见的购物车场景看看 rea 怎么落地。购物车数据放在 rea store 里,头部导航要显示数量,商品列表要能添加商品,结算页要读总价。组件之间没有直接依赖,全部通过事件和状态关联。

const cartStore = createReaStore({ initialState: { items: [], }, }); function addToCart(item) { cartStore.setState((prev) => ({ items: [...prev.items, item], }), "addItem"); }

在商品列表组件里,点击“加入购物车”只需调用addToCart,它完全不关心谁在监听addItem事件。头部导航组件里,直接订阅数量变化:

function CartBadge() { const count = useRea(cartStore, (state) => state.items.length); return <span>{count}</span>; }

用 React 组件渲染的时候,渲染层只关心“数量这个数字变了没有”,不关心购物车里具体加了什么。这样事件驱动和响应式渲染就分得很清楚:事件负责触发数据变化,响应式订阅负责把变化同步到视图。

3.3 异步事件与竞态处理:一个请求场景的真实案例

事件驱动架构在异步场景里最大的坑,不是事件本身,而是“旧请求的结果覆盖新请求的状态”。比如用户快速切换筛选条件,第一次请求还没回来,第二次请求已经发出去了。如果第一次请求后发先至,页面就会显示错误数据。

rea 对这种场景的处理方式不复杂,但非常有效:给每次异步操作生成一个序号,只有最新一次请求的结果才有资格写入状态。

let requestId = 0; async function loadUsers() { const current = ++requestId; const data = await fetchUsers(); if (current !== requestId) { return; // 已经有更新的请求发出,直接丢弃本次结果 } userStore.setState({ users: data }, "loadUsers"); }

这个模式在 rea 里应作为标准写法,而不是特殊优化。因为事件订阅往往不会自动清理旧的 pending 请求,只有显式地通过序号判断,才能保证最终写入状态的一定是最新数据。

如果你有条件使用浏览器的AbortController,也可以配合使用。在刚刚的订阅取消回调里调用abort(),能从网络层面直接中断旧请求,效果比单纯丢数据更好。

4. 实操中遇到的 5 个典型问题与排查实录

再好的架构,用起来都会遇到问题。我把 rea 在实际业务里最容易踩的坑整理成了一份速查表,下面逐个展开说。

现象可能原因解决方式
组件卸载后回调仍在执行没有调用取消订阅函数在 effect 或 hook 中返回清理函数
页面出现重复更新setState 在监听器里触发了下一个 setState增加变更检测和批量调度标记
事件丢失订阅时机晚于触发时机状态初始化时先快照,再订阅
异步回调读到旧状态闭包捕获了旧 state 引用使用 getState 获取最新状态,或使用 functional update
大量监听器导致卡顿一个事件被太多组件无差别订阅拆分事件粒度、减少 selector 返回大对象

4.1 内存泄漏:独立组件卸载后,事件回调还在执行

这是我见过最多的问题。很多同学在组件里写了store.on("change", handler),却忘了在useEffect的 cleanup 里调用取消订阅。组件被条件渲染卸载了,handler 还挂在 store 的监听器集合里,事件一来就继续执行。

React 适配层基本能帮你规避这个问题,因为useSyncExternalStore会自己做清理。但如果你在useEffect手动订阅,或者在普通函数里调用store.on,就必须把返回值保存起来,在合适的时机调用。

实操技巧:我给团队定了一个规矩——所有的on调用返回值必须赋值给一个变量。如果两行代码之后这个变量没有被使用,代码审查就不通过。这是一种低成本但非常有效的强制约束。

4.2 重复触发与无限循环:事件回调里修改同一个状态

re a 的状态更新如果触发了某个监听器,监听器内部又调用了新的setState,而新状态又会被同一批监听器监听,就可能出现“事件猴子捞月”一样的无限循环。

最简单的解决方法是浅比较。我在setState里已经加了一层变更判定,如果旧状态和新状态引用相同,直接跳过通知。但引用相同不一定意味着内容不变,所以更推荐在 reducer 里保证不可变性:每次更新都返回一个新对象,只在真正变化时才触发后续逻辑。

还有一种更隐蔽的循环:两个 store 互相订阅。A store 更新后通知 B,B 更新后反过来触发 A。这种循环很难一次性看出来,所以在设计事件流时,尽量保持事件流单向。如果一个事件流的拓扑里出现了环,大概率是业务逻辑设计有问题,而不是 rea 实现有问题。

4.3 事件丢失:订阅时机晚于触发时机

JavaScript 是单线程语言,如果事件在订阅之前就已经被触发,订阅者自然是收不到的。这个问题在某些初始化阶段特别常见,比如某个模块在页面加载时就开始请求数据,而另一个模块的数据订阅却要等到 React 组件挂载后才建立。

这种问题不能靠“把初始化代码往后挪”来解决,因为顺序问题永远存在。比较稳妥的做法是:在创建 rea store 时,把初始快照准备好。订阅者先通过getState()读一次当前状态,再把后续变更事件接上。这样,即使事件已经发生过,订阅者也能拿到最终结果。

rea 的 hook 已经做了“先读快照再订阅”的顺序,所以用useRea的组件不太会遇到这个问题。手动写订阅逻辑时需要格外注意。

4.4 闭包过期:异步回调读到旧状态

你可能会在异步请求回调里写state.value,但这里的state来自闭包,可能是事件触发那一刻捕获的旧引用。如果回调执行时状态已经更新多次,读到旧值就非常正常。

更稳妥的方式是在回调内部读最新状态,而不是依赖闭包里的变量。rea 的getState方法永远返回当前最新引用,所以遇到异步场景,应时刻使用store.getState()或函数式更新,而不是闭包里的state变量。

4.5 大量监听器导致页面卡顿

当一个事件被大量组件同时订阅,并且每个组件的更新范围很大时,页面会出现明显卡顿。这不是事件机制的问题,而是订阅粒度太粗导致的。比如一个state.user变化要让所有组件都刷新一遍,显然不合理。

解决办法有两个。第一,把大 store 拆成多个小 store,让不同业务模块各自维护自己的状态。第二,在 selector 里只选择当前组件关心的最小字段,从而减少 React 的更新范围。

5. rea 的性能边界与影响范围:搞清楚它能做什么,不能做什么

5.1 性能核算和调度策略

很多同事第一次看到事件驱动架构,脑子里都会冒出一个疑问:用事件广播的方式分发状态更新,性能会不会比单一 store 差很多?

其实大多数业务场景的根本瓶颈并不在“事件分发的次数”,而在“分发之后触发了多少无用的渲染”。rea 在这里的优化思路很直接:监听器集合用Set,判断是否变化用浅比较,更新合并用批量调度,渲染层只触发真正有变化的组件。

我用一个不太严谨但很有参考价值的估算来说明:假设一个页面有 100 个组件订阅了同一个状态,每个订阅的 selector 都返回数字类型,那么一次状态变更最多触发 100 次 React 渲染,这也是useSyncExternalStore的保底策略。实际场景中,多数 selector 会根据数据变化自动保持引用相等,所以触发渲染的次数会远小于组件总数。

如果事件数量非常大,比如每秒上千次,那就要考虑“节流 + 合并”的双层策略。在scheduleRefresh之前再加一个时间窗口,把同一窗口内的多次刷新合并成一次。这个操作不复杂,但对高频实时数据特别友好。

5.2 影响范围:它能省掉哪些中间层

rea 对业务的影响,最直接的体现是减少了组件之间的中间层。这里说的中间层,包括:

  • 为了传数据而存在的容器组件。
  • 为了回调而存在的转发函数。
  • 为了共享状态而存在的 context provider 嵌套。
  • 为了跨模块通信而写的各种工具函数。

在实际改造中,我确实见过一个模拟项目把原本三层 context provider 嵌套,换成了三个 rea store,每个 store 只负责自己领域的状态。改完之后,组件树变得扁平,渲染结构也更好理解。

但这不是说所有 context 都该被替代。如果你的组件只是单纯地从父级接收配置,并没有多个不确定来源的事件触发,那 context 依然很合适。rea 真正擅长的是“多个来源、多种时机、多个接收方”的复杂通知场景。

5.3 不适合用 rea 的场景

事件驱动不是银弹,有几类场景我建议谨慎使用,甚至直接避免。

第一,服务端状态缓存。如果你要缓存接口返回的数据,并且需要考虑缓存失效、重试、标签失效等问题,直接使用成熟的请求缓存库会更省心。rea 没有内置缓存策略,硬加的话,代码会很别扭。

第二,强依赖时间旅行调试的场景。如果你需要把所有状态变更记录成一条不可变时间线,可以随时回退到任意历史状态,那事件驱动的广播模型就不如单一 reducer 模型方便。

第三,深层依赖图。当一个状态变化需要同时驱动十几个状态联动的复杂业务,事件之间的调用链会变得不容易追踪。这时候更适合用显式计算函数,而不是靠事件广播来触发。

第一和第三类场景我用实际体验来解释一下:rea 的核心价值是“广播”,而“精确计算”是另一回事。广播适合通知,不适合做复杂派生关系。如果一个状态必须由多个数据源共同决定,并且依赖顺序敏感,我宁可写一个独立的计算函数,让修改点集中在函数内部,而不是拆成多个互相追着跑的事件。

6. 收尾:给想上手 rea 的同学一点个人建议

6.1 从哪里开始改造比较稳

如果你准备在项目里引入 rea,我建议不要一开始就把整个项目的状态全部迁过来,那会是一场灾难。先找一个“跨组件通信最频繁、但逻辑最独立”的模块,比如购物车、筛选器、轻量级审批流。把这一块的事件和状态理清,跑通几个真实业务路径,再决定是否推广。

我自己的经验是:先拿一个状态结构比较简单、但组件分散得比较厉害的页面来试水。你会很快体验到“不用再往下层传回调”的感觉。等这个模块稳定运行两周,再考虑把它扩展到其他业务域。

6.2 需要避开的几个坑

在正式落地时,有几个坑值得反复提醒自己。

第一,事件名的规范性。如果团队里好几个人一起写,事件名全靠自觉,最后一定会出现addItem和add_item并存的情况。建议在类型层把事件名定义成枚举,每个事件只允许出现在一个明确维护的列表里。

第二,订阅清理的纪律性。任何手动on都要和取消订阅配对,这个规则必须写进团队约定。

第三,不要把所有状态都丢进全局事件。有些状态是某个组件独有的,把它提升到全局事件模型里,只会增加无谓的广播成本。局部状态就留在局部,只有跨模块共享时再交给 rea。

第四,不要把 rea 当成万能的全套框架。它不打算替代所有状态管理,也不会替你管理路由、缓存和表单校验。它只是一个很强的小工具,帮助你构建更清晰的事件流。

最后再分享一个小经验:使用事件驱动架构时,要习惯“不关心谁在听”这件事。很多人上手后总想看一眼事件到底被谁订阅了,这很正常,但不要依赖这种直觉去推导全局逻辑。正确的方式是,把事件名、载荷结构、触发时机这三样东西当成对外接口来维护。接口稳定了,事件的发布方和订阅方就能各自独立演化。这也正是 rea 这个名字里 Reactive 和 Architecture 两个词给我的最大启发:响应式解决性能问题,架构解决协作问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进