
如果你在一线写前端大概率遇到过这个场景一个页面上同时放着几组互相依赖的模块表格改一行要联动刷新列表列表刷新又要更新角标角标再通知详情页……以前我处理这种事就是到处挂回调后来回调越挂越多终于在一次线上事故里翻车。当时我被迫重新梳理方案最后花了三周时间写了一个叫 rea 的小框架全称是 Reactive Event Architecture。rea 是一套基于事件总线和响应式原子的轻量级前端运行时核心依赖只有三块event bus、atom store、custom element。它不绑定任何 UI 框架可以嵌在 Vue、React 里当状态层用也可以独立运行整块页面。这个方案在我手上的几个中后台工程里实践了大半年稳定性和可维护性都明显好过原来的事件堆砌。下面把设计思路、核心实现和踩过的坑全部展开。1. 从零开始设计 rea为什么是事件驱动加响应式1.1 先统一词汇原子、总线与副作用在说明架构之前先定义三个概念。原子atom是一个保存单一值、可订阅变化的最小状态单元事件总线bus是模块之间互相同步消息的通道副作用side effect则是状态变化后引发的外部操作比如发请求、写本地缓存、更新 DOM。三者配合后数据永远单向流动业务逻辑修改 atomatom 通知订阅者订阅者执行副作用或渲染视图。对比 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 和 effectsdisconnectedCallback 统一回收订阅并清空 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 失效。有同事封装事件时用匿名函数做 handleroff 传的是另一个匿名函数引用不一致删除失败。监听越积越多页面越用越卡。后来我要求所有 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 这样的轻量运行时去落地你会发现中后台前端的复杂度其实没有想象中那么高。