如果你在一线写前端,大概率遇到过这个场景:一个页面上同时放着几组互相依赖的模块,表格改一行要联动刷新列表,列表刷新又要更新角标,角标再通知详情页……以前我处理这种事就是到处挂回调,后来回调越挂越多,终于在一次线上事故里翻车。当时我被迫重新梳理方案,最后花了三周时间写了一个叫 rea 的小框架,全称是 Reactive Event Architecture。
rea 是一套基于事件总线和响应式原子的轻量级前端运行时,核心依赖只有三块:event bus、atom store、custom element。它不绑定任何 UI 框架,可以嵌在 Vue、React 里当状态层用,也可以独立运行整块页面。这个方案在我手上的几个中后台工程里实践了大半年,稳定性和可维护性都明显好过原来的事件堆砌。下面把设计思路、核心实现和踩过的坑全部展开。
1. 从零开始设计 rea:为什么是事件驱动加响应式
1.1 先统一词汇:原子、总线与副作用
在说明架构之前,先定义三个概念。原子(atom)是一个保存单一值、可订阅变化的最小状态单元;事件总线(bus)是模块之间互相同步消息的通道;副作用(side effect)则是状态变化后引发的外部操作,比如发请求、写本地缓存、更新 DOM。三者配合后,数据永远单向流动:业务逻辑修改 atom,atom 通知订阅者,订阅者执行副作用或渲染视图。对比 MVC 里 controller 一把抓,这套写法把每个职责拆到只能做一件事。
选事件驱动而不是简单用全局状态,是因为中后台页面天然具备大量异步通知场景:账号权限变更、登录态过期、轮询返回、WebSocket 推送。全局 store 如果只负责数据,主模块仍然要硬编码通知关系。加上总线之后,模块之间完全不认识对方,发布方只喊一声事件名,其他关心这件事的模块自己监听。减少耦合的同时,也把测试边界切得很干净。
当时我做选型对比时也考虑过纯 observable 方案,但 observable 偏底层,业务方用起来容易把逻辑散落在 subscribe 回调里。rea 刻意把总线放在状态层旁边,让"事件名"成为可查的契约,而不是随手散落的字符串。这个决定在后来跨团队协作时帮了大忙:每个人拿到事件清单,就知道哪些模块在协作。
1.2 为什么不用大仓库当状态层
我之前的架构方案依赖一个全局状态库,页面一多,store 定义就开始互相引用。模块 A 要在模块 B 的 store 里塞字段,模块 B 又要监听模块 A 的事件,最后 store 变成一个大杂烩,谁也说不清某个字段是谁写入的,改一行代码要全局搜索大半天。
rea 的设计不同:每个业务模块自带若干 atom,跨模块协作一律通过事件总线完成。这样模块之间最多只依赖事件名,不依赖对方的内部结构。比如一个订单列表清空后,列表模块内部自己维护清空状态,同时向总线发一条order/cleared;支付模块监听到事件后重置本模块的勾选状态。调试时只追一条事件链路,非常直观。
这里要注意一个原则:事件命名用动作而不是状态。order/cleared是动作,order.cleared容易被当成字段名,时间久了会产生歧义。我们团队后来定了一套命名规约:领域/动作,全部小写,斜杠分隔。例如cart/added、filter/changed、user/logout。这个规约直接写进了 MR 检查清单。
1.3 原生 Custom Element 承载视图的原因
视图层我没有用现成框架,而是选原生 Custom Element。主要原因有三:不受框架版本绑架、天然可挂载到任意父级、Shadow DOM 可以提供样式隔离。在一个存在 Vue2、React16、原生 JS 的混合项目里,一套 Custom Element 组件可以被任何技术栈复用,互不干扰。
实际使用中,我保留了轻量的 template 解析能力,但不是再造一个完整虚拟 DOM。中后台场景里表格、弹窗、表单这类交互,状态变化频率没那么离谱,直接在元素里绑定订阅回调就足够。再快的响应式框架在这里也展现不出明显性能差异,反而引入虚拟 DOM 会带来额外体积和心智负担。
有人质疑过 Custom Element 兼容性。我实践下来,只要不依赖太新的特性,主流浏览器完全没问题。需要兼容更老环境时,做一层 polyfill 也很简单。相比为了框架版本升级而焦虑,原生的东西反而最稳定。
2. 核心实现:事件总线、原子状态与绑定层
2.1 event bus 的实现与容错
rea 的 bus 代码只有几十行,但关键点在于容错。先看核心实现:
// core/bus.js export function createBus() { const topics = new Map(); function on(topic, handler) { if (!topics.has(topic)) topics.set(topic, new Set()); topics.get(topic).add(handler); return () => off(topic, handler); } function once(topic, handler) { const wrapper = (payload) => { off(topic, wrapper); handler(payload); }; return on(topic, wrapper); } function off(topic, handler) { const set = topics.get(topic); if (!set) return false; return set.delete(handler); } function emit(topic, payload) { const set = topics.get(topic); if (!set) return false; for (const handler of [...set]) { try { handler(payload); } catch (err) { console.error(`[rea:event-error]`, topic, err); } } return true; } return { on, once, off, emit }; }emit内部对每个 handler 用 try/catch 包裹,这个设计救过我很多次。很多团队自定义事件都是裸发,一个监听函数抛异常,后面所有监听全部断掉。我曾经在一个报表项目里遇到过这种情况,某个模块的空指针异常直接吞掉了后续几个模块的通知,页面表现是一半功能失效但没有任何报错,排查了整整一个下午。
加上 catch 之后,总线上出问题只是单点失败,不会拖垮整条事件链,日志也能抓到主题名和堆栈。complete 的 emit 仍然会继续派发给后面的监听,这是刻意行为,因为监听之间不应该被异常相互影响。
off的设计也一样重要。每次on都返回取消函数,业务方最好在组件销毁时调用。但要注意once的 wrapper 必须先从 Set 里移除再执行 handler,不然 handler 内部又触发同一事件,会造成连环执行。我在一次重构中遇到过这个坑,当时事件回调里再次 emit 同一个主题,导致递归爆栈。
2.2 atom 状态怎么做到响应式
atom 的核心不是响应式魔法,而是把赋值和通知捆绑。我最初版本是这样的:
// core/atom.js export function atom(initialValue) { let value = initialValue; const listeners = new Set(); return { get: () => value, set(next) { const update = typeof next === 'function' ? next(value) : next; if (Object.is(update, value)) return; const prev = value; value = update; listeners.forEach((fn) => fn(update, prev)); }, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }, }; }这里有几个容易被忽略的细节。set里先判断值是否真的变化,用Object.is而不是===,可以正确识别 NaN。这种细节平时没人提,但确实能避免一些隐蔽 Bug。每个 atom 只保存一个值,不要让 atom 内部变成嵌套多层的对象;复杂结构建议拆成多个原子,或者用不可变更新维护。
在 rea 里我建议把 atom 当成组件的内部状态,而不是全局状态。全局只放登录态、当前用户、权限这类真正的共享数据。业务数据都在页面闭包或组件实例内创建,页面销毁时订阅也会被统一清掉,不会泄漏。后面做内存优化就是因为这个设计省了我很多时间。
如果你需要派生状态,可以在订阅里再组合,也可以封装一个 computed。我的做法很简单:computed 本质是依赖一组 atom,当任意依赖变化时重新计算值并触发自己的订阅。它不缓存,也没有复杂的依赖收集,适合数据量不大的场景。
2.3 视图绑定与渲染调度
ReaElement 是视图层的基类,代码也很薄:
// core/rea-element.js import { createBus } from './bus.js'; export const bus = createBus(); export class ReaElement extends HTMLElement { constructor() { super(); this._disposers = []; } connectedCallback() { this.init?.(); this.render?.(); const results = this.effects?.() ?? []; for (const fn of results) { if (typeof fn === 'function') this._disposers.push(fn); } } disconnectedCallback() { this._disposers.forEach((fn) => fn()); this._disposers = []; this.innerHTML = ''; } effects() { return []; } }connectedCallback 里调用 init、render 和 effects,disconnectedCallback 统一回收订阅并清空 DOM。最关键的约束是 effects 必须返回取消函数数组。每个订阅、每个事件监听都放进数组,组件销毁时一次性清理,从机制上消灭了绝大部分泄漏。
渲染调度我采用了朴素策略:状态变化时,effect 里拿新值去改 DOM。不用虚拟 DOM,也就不需要 diff 算法。但这也带来一个瓶颈:同一事件可能触发多个 atom,导致一个组件被多次渲染。我当时加了一个微任务调度器:任一 atom 变化后先缓存渲染请求,等当前同步代码执行完再统一 flush。实测把重复渲染次数减少了大概 70%。
调度器的实现不算复杂,核心是把每次渲染函数推入 Set,然后在 Promise.resolve().then 里统一执行。这样无论同步代码里触发多少次状态更新,最终只渲染一次。代价是渲染延迟到微任务执行,肉眼感知不到差异,收益却非常明显。
3. 用 rea 搭一个真实业务模块
3.1 搜索联想框:原子状态与总线配合
看一个实用的例子:搜索联想框。组件只负责收集输入和展示结果,请求逻辑完全放到组件外面。
<search-box placeholder="输入商品关键词">import { atom } from './core/atom.js'; import { bus, ReaElement } from './core/rea-element.js'; class SearchBox extends ReaElement { constructor() { super(); this.keyword = atom(''); this.suggestions = atom([]); this.status = atom('idle'); } onInput(e) { const keyword = e.target.value.trim(); this.keyword.set(keyword); bus.emit('searchbox/input', { source: this.getAttribute('data-source'), keyword, }); } effects() { return [ this.keyword.subscribe(() => this.updateInput()), this.suggestions.subscribe((list) => this.renderList(list)), bus.on('searchbox/result', (payload) => { this.status.set('done'); this.suggestions.set(payload.list); }), ]; } } customElements.define('search-box', SearchBox);这个组件展示的是 rea 的基本循环:输入关键字 -> 更新 keyword atom -> emit 到总线。外部结果模块监听事件,经过防抖和请求后,把结果通过searchbox/result事件写回,组件内订阅者收到新数组再渲染下拉列表。组件只负责提交输入事件和渲染结果,发请求和状态判断属于外部模块。
外部模块长这样:
// modules/search-controller.js import { bus } from '../core/rea-element.js'; bus.on('searchbox/input', async (payload) => { const res = await fetch(payload.source, { method: 'POST', body: JSON.stringify({ keyword: payload.keyword }), }); const list = await res.json(); bus.emit('searchbox/result', { keyword: payload.keyword, list }); });你会发现组件和请求模块完全解耦。以后想换接口、加缓存、做防抖,直接改 search-controller 就行,组件代码动都不动。这才是 rea 想解决的问题:让每个模块只关注自己真正擅长的事。
3.2 跨组件联动:订单列表、筛选栏和角标
假设一个订单管理页有三个模块:筛选栏、订单表格、右上角统计角标。传统写法里筛选栏要么通过回调传给表格,要么在全局 store 里读写。rea 的做法是:筛选栏只发筛选条件事件;表格监听事件后重置分页拉数据;角标监听事件后刷新统计接口。三方互不认识,只认filter/changed事件。
bus.on('filter/changed', (payload) => { store.page.set(1); loadOrders(payload); }); bus.on('filter/changed', () => { refreshBadgeCount(); });这两个监听函数在代码里就是一前一后注册,但执行顺序完全不保证,所以业务上不要依赖监听顺序。如果你一定要先清空表格再刷新角标,就把两步放进同一个 handler。实际项目里我见过监听顺序事故:先刷新角标、后重置分页,导致角标查了一次空分页的数据,报表数量短暂错误。
auide 后续加新模块的情况下,只需在同一个事件上新增监听,完全不需要改动现有模块。有一次客户要求增加一个"已读事项"提醒,我就在原事件后面多挂了一个 handler,前端一行旧的逻辑都没有碰。这就是事件架构给业务扩展带来的实际好处。
3.3 桥接 Vue/React:要不要再包一层
rea 不是非要替换掉主框架,它更适合跟主框架配合。某后台项目里 React 负责页面骨架,老 jQuery 模块负责表格渲染。我用一个全局 rea store 保存页面参数,React 组件往里写,jQuery 监听读取,两边数据终于对齐。
// react-side.js import { store } from './global-store.js'; function SearchPanel() { const [kw, setKw] = useState(store.kw.get()); useEffect(() => store.kw.subscribe(setKw), []); return ( <input value={kw} onChange={(e) => store.kw.set(e.target.value)} /> ); }React 侧用 useState 加 useEffect 去同步 rea 的 atom,本质是给 rea 套一层 Adapter。Vue 侧可以直接用 watchEffect。这套桥接层不复杂,胜在能平稳过渡:先把数据逻辑迁到 rea,视图层依然用老框架,再逐步替换视图组件。迁移项目的时候安全感高很多。
桥接层的核心问题是要避免重复渲染。我见不少人在 React 组件里直接把 atom 变化映射成 setState,然后又在 useEffect 里 set,造成一个事件触发两次 render。正确做法是只在订阅回调里 setState 一次,不要额外在事件流里再触发一次更新。
4. 调试、测试与性能实录
4.1 三个内存泄漏案例
案例 1:忘记取消订阅。早期 SearchBox 组件的 effects 里写了结果订阅,但返回的取消函数没放进数组。组件销毁后订阅还在,每次搜索结果返回都会更新一个已经不在页面上的 DOM。定位方式是在 devtools 里给 component 加了标记,反复刷新后发现 DOM node 数量一直上涨。修复方式是基类强制收集所有订阅返回。
案例 2:匿名函数 off 失效。有同事封装事件时用匿名函数做 handler,off 传的是另一个匿名函数,引用不一致,删除失败。监听越积越多,页面越用越卡。后来我要求所有 bus 的 on 调用必须接收返回值做卸载,不再允许手动传 handler 去 off。从使用层面彻底杜绝这类问题。
案例 3:闭包持有大对象。事件回调里引用了大量业务对象,即使订阅被清掉,某些闭包仍然被外部引用。这个排查成本最高。后来我们在关键回调里做了引用计数日志,发现某些 list 对象始终无法回收。根治方法是入口只传 id 和必要字段,不要传全量数据,避免闭包无意间握住一大块数据。
这三个案例说明一个问题:event bus 让模块解耦,同时也让引用关系不透明。所以 rea 强制使用统一清理机制,就是为了把人为失误降到最低。
4.2 自动化测试怎么切分
对 rea 这类薄封装,测试核心集中在三块:atom 的行为、bus 的容错、组件的 effects。atom 测试关注值更新和通知次数。bus 测试关注 emit 异常隔离和 off 正确释放。组件测试则用 happy-dom 挂载一个自定义元素,断言 DOM 有没有按状态变化更新。维护成本不高。
import { describe, it, expect, vi } from 'vitest'; import { atom } from './atom.js'; it('should not notify when value unchanged', () => { const value = atom(1); const spy = vi.fn(); value.subscribe(spy); value.set(1); expect(spy).not.toHaveBeenCalled(); });bus 异常隔离的测试脚本里,我专门注册一个会抛出异常的处理函数,再注册另一个正常处理,验证正常处理还能执行。这个用例看起来简单,但在没做容错之前,一个错误会把整条链打断;有了 catch 后测试通过,也保证了线上不出现连锁崩溃。
组件测试最需要注意的是异步渲染。因为 rea 的渲染走了微任务调度,断言 DOM 前必须 await 一次 Promise。我一开始没注意,测试时经常断言失败,后来统一封装了一个flush()工具方法,把所有等待微任务的逻辑都收进去,测试代码干净很多。
4.3 性能优化:批处理与大列表
微任务批处理调度器在实际业务里的收益非常直接。某个列表页有一组筛选条件,原来每次条件变化都触发查询和重绘,连续触发五次就发五次请求。加上批处理之后,同一事件循环里的多次 set 只产生一次渲染和一次查询。用户从体验上就是操作一停,页面才刷新,不再持续抖动。
大列表渲染我建议用原生的 DocumentFragment 配合虚拟列表。rea 不管虚拟列表里每个元素的内部状态,只负责把 loading 状态和列表数据传给组件。表格渲染 3000 行数据,在无虚拟化时实测首次白屏要数秒;换成简单分页后毫秒级。复杂的响应式计算从状态层去掉,交给专用列表组件去做。
还有一个小优化:在订阅回调里避免创建新的临时对象。某些 effect 每次渲染都生成一个新的对象字面量传给 DOM API,会造成额外的内存波动。我在代码 review 时要求订阅回调的返回值保持稳定,能复用的对象就提到模块顶层,收益很稳定。
5. 迁移与上线:真实踩坑清单
5.1 从旧代码平滑迁移的步骤
不要一次性推翻重来。先把全局数据抽到 atom 里去,再逐步替换事件通信。步骤大致是:第一,建一个 global-store 文件,统一放登录 token、用户信息、当前项目 id;第二,把跨模块通知改成 bus.emit;第三,把页面的关键渲染区域改用 ReaElement。每一步都能独立回归测试,不会出现推倒重来的大迭代。
我在某项目里用了三周做迁移,前两周都在抽状态和事件,真正替换 UI 组件只用了一周。如果一开始就重写界面,复杂度会多出很多,还会丢失旧逻辑细节。强烈建议把旧代码里的事件命名先用表格列一遍,和团队对好统一前缀,再开始动代码。
迁移时的原则是一次只替换一个模块。旧模块保留原有输出,新模块通过 bus 接收同一份数据,两边并行跑一段时间,确认数据一致后再删除旧代码。这个方法被团队称为"双写验证",我们的支付页面就是这么安全切换的。
5.2 上线前必须检查的 7 个细节
| 检查项 | 怎么检查 | 为什么重要 |
|---|---|---|
| 全局订阅是否已销毁 | 逐个 component 的 disconnected 打日志 | 泄漏最隐蔽,且难以复现 |
| 事件命名是否动作化 | 扫描所有 emit,确认是domain/action | 动作化事件能减少语义歧义 |
| state 是否多层嵌套 | 看 atom 初始值类型,不允许对象嵌套过深 | 嵌套对象难以追踪变更意图 |
| disconnected 是否清空 DOM | 查看组件销毁后元素是否还挂在页面 | 不及时清空会拖累浏览器渲染 |
| 错误处理是否走统一日志 | 在总线 catch 中打点 | 保证单点异常不拖垮整条链 |
| 渲染是否触发多次请求 | 打开 network 面板,连续操作观察请求数 | 多次请求是批处理没生效的表现 |
| 桥接层是否会重复渲染 | 给渲染函数加计数器 | 避免一套数据更新引发两轮渲染 |
这 7 个细节基本覆盖了我线上踩过的坑。列成表格后让团队照着逐项打勾,效率高很多。特别是第一项,因为很多内存泄漏在开发环境根本不明显,只有长期运行才爆发。
5.3 实际迁移效果与数据对比
我们把一个订单后台从旧的事件堆砌迁移到 rea 后,统计了三个阶段:迁移前、迁移中、迁移后。前端核心模块的总 JS 体积从 1.8MB 降到 0.9MB,因为去掉了大量手写事件桥接代码和一个全局状态库。页面从六个互相缠绕的模块解耦成四个独立模块,接口请求数从平均六次降到两次。这些数据只代表我的场景,但方向是有共性的。
稳定性提升更明显。迁移前,线上每月大概出现三到五次因为事件错乱导致的白屏或数据不对;迁移后大半年只出现一次,原因还是某个组件销毁时没有正确清空 DOM,后来被基类强制回收补上。整体来看,rea 带来的不只是代码量减少,更是心智负担的下降。
最后分享一个实际体会:rea 的心智模型很简单,但简单意味着约束必须严格。事件不能乱命名,atom 不能乱放,订阅不能乱注册。只要守住这几条约定,这个模式就能一直稳定运行。如果你正准备重构一个充满回调的页面,我建议先试着把数据流和事件流理清楚,再用 rea 这样的轻量运行时去落地,你会发现中后台前端的复杂度其实没有想象中那么高。