如果你写过两个页面以上的业务系统,八成遇到过这种场景:页签A里某个按钮点完,得让页签B里的列表立刻刷新;或者某次请求失败,全局要弹一个统一提示,而不是每个页面各自处理。这两个页面也许隔着多层父组件,props 一层层传过去会让人想摔键盘;上全局状态管理,又有点杀鸡用牛刀。后来我在某中后台项目里写了个叫 rea 的小工具,十来行代码就解决了这个痛点。写着写着需求越来越多,我干脆把它整理成了一个独立项目,全称叫 Rapid Event Architecture。
REA 核心是一个零依赖、类型安全的前端事件订阅发布库,代码量控制在 2KB 左右,却支撑了我日常开发里几乎所有“瞬时通知、事件解耦”的场景。这篇文章会把这个项目从命名思路、数据结构设计、完整源码,到边界处理、打包发布,整个过程拆开讲清楚。适合写业务组件容易陷入通信泥潭的前端开发者,也适合想搞清楚事件总线内部原理、准备手写一个自己的工具库的朋友。
先说明:rea 是我自己维护的项目代号,下面提到的业务场景都来自我做过的真实项目,但细节做了脱敏处理,只保留通用逻辑。
1. 项目从哪来:三字母代号背后的真实需求
1.1 组件通信的第三条路:事件订阅发布解法
先讲一个特别经典的场景。列表页和编辑弹窗在同一个路由下,但组件树的深度完全不同:列表页挂在路由父级下面,编辑弹窗可能是全局 portal 渲染到 body 上,两者连血缘关系都算不上。弹窗里点“保存成功”,列表页怎么知道该刷新?
最原始的做法是 props 一路往下传回调,中间每一层组件都得给子组件透传这个函数,哪怕它根本不用。项目维护到第二年,这种透传会把一个很简单的改动放大成三四个文件的修改。另一个做法是把“要不要刷新、刷新哪页”放进全局状态管理里。但这是个瞬时信号,不是业务数据,塞进全局 store 除了增加状态字段,还引入了时序问题:状态更新了,页面不一定刷新;页面刷新了,状态又不知道改没改。
事件订阅发布恰好是第三条路:A 模块发出一个“列表需要刷新”的信号,谁关心谁订阅,中间层组件完全不用感知。它适合的是那种“一次性、不需回执、与业务数据无关”的通知。你不需要知道对方是谁,对方也不需要知道消息从哪来,两边只约定一个事件名,这就是 rea 要解决的核心问题。
1.2 为什么不直接拿现成事件库
听到这儿,你可能想说:社区不是有一堆轻量事件库吗,干嘛自己写?确实有,但用下来的体感有两个痛点。第一个是类型支持问题,很多老牌轻量库要么靠额外包装,要么直接 any 走到黑,在业务代码里事件名一旦拼错、参数一旦传错,只能在运行时静默失败,调试成本不算低。第二个是行为细节不统一,比如“同一个回调重复注册两次,emit 时到底触发几次”,不同库给的答案可能不一样,这种细节在团队协作里最容易埋雷。
我的诉求非常明确:代码量控制在 2KB 左右、零依赖、事件名和回调参数在编译阶段就能对上,同时 on 方法返回一个取消订阅函数,比传统库的 off 需要再传一遍 handler 要舒服得多。既然大库有自带的行为包袱,小库类型又跟不上,干脆自己维护一个。自己写还有一层价值:把一个事件总线从 Map 存订阅者开始,到泛型、快照遍历、错误隔离,一步步写完,你对“事件驱动”的理解会比只看文档深刻得多。rea 不一定是最好用的库,但它是我能完全掌控行为的工具。
1.3 rea 缩写和项目定位
命名其实是很随意的事。Rapid Event Architecture,快速事件架构,缩写正好是 REA,三个字母,好敲、好搜、好记。它不绑定任何框架,纯 TypeScript 实现,浏览器和 Node.js 都能跑。定位上,rea 不是要替代状态管理库,也不是要和大型事件框架比功能,它只解决一个问题:模块之间需要发信号,但不想建立显式依赖。
这个定位让我做功能取舍时特别轻松。凡是不属于“订阅—发布”最小闭环的能力,我一概不做。比如不内置持久化、不做跨窗口同步、不搞优先级队列。保持小,才能让使用方一眼看穿实现,遇到 bug 打开源码一屏之内找到根因。小是我刻意维持的设计目标。
2. 核心设计:先把 API 边界划清楚
2.1 四件套 API,不多不少
rea 对外只提供四个方法:on、off、once、emit。on 订阅事件,emit 发布事件,off 取消订阅,once 是“订阅一次后自动取消”的语法糖。这里有两个设计决策值得展开说。
第一,on 的返回值设计为取消函数,而不是返回 this 做链式调用。链式调用写起来好看,但在组件里很容易写着写着就忘了把返回值接住,最后组件销毁时找不到引用去 off,泄漏就是这么来的。取消函数把“订阅和退订”的关系固化在一个作用域里,你可以这样写:const off = bus.on('list:refresh', refresh);组件卸载时直接off()。
第二,once 不是一个独立的数据结构,它只是 on 的包装。once 内部会生成一个包装函数,第一次被调用时先 off 自己,再执行原始回调。这意味着 once 返回的取消函数依然有效,只要原始回调还没触发,你随时可以取消这次一次性订阅。
功能上四个方法够不够?我还真试过加 onAny 监听所有事件、offAll 清除所有订阅,最后都撤掉了。onAny 会模糊事件边界,调试时更难追踪谁在处理消息;offAll 虽然方便,但一定会误伤别的模块。四个方法刚好让每个订阅都有明确归宿。
2.2 用 EventMap 泛型把事件参数锁死
这是 rea 和很多老牌轻量库最不一样的地方。用法如下:
interface Events { 'list:refresh': [id: number]; 'toast:show': [message: string, type?: 'success' | 'error']; } const bus = createRea<Events>(); bus.on('list:refresh', (id) => { ... }); // id 被推导为 number bus.emit('toast:show', '保存成功'); // 参数自动匹配 bus.emit('toast:show', 123); // 编译阶段直接报错这里事件参数用的是元组类型,不是对象类型。原因很实际:事件发布参数本质是一组位置参数,emit 实现就是把它们原样传给回调。用元组[id: number]能精确描述参数顺序和个数,TypeScript 在推导回调参数时按顺序展开,比包装一个聚合对象更贴近 JavaScript 事件的本意。
这种设计也让事件名和参数形成一张映射表。新增一个事件,只要在 Events 里加一行,所有 on/emit 调用立刻获得类型约束。我在团队里推行一段时间后,事件名拼错的概率几乎降为零,因为拼错的名字过不了编译。这种“编译期兜底”的价值,比运行时控制台里的警告有用得多。
2.3 为什么底层用 Map + Set,而不是对象和数组
rea 的数据结构核心是一张事件表,落地的选择是Map<string, Set<Handler>>,不是普通对象,也不是数组存函数。
用对象存事件表,事件名一旦带特殊字符就会很别扭,删除一个事件得delete object[key],类型推导也不理想。Map 对 key 的处理更纯粹,任何字符串都能当 key,方法语义也明确:get、set、has、delete,看一眼就懂。
value 用 Set 而不是数组,好处非常直接。第一,Set 天然去重,同一个 handler 重复 on 两次,Set 只会保留一个,emit 不会重复触发;第二,根据 handler 取消订阅时,Set.delete 是 O(1),不用像数组那样 splice 再移位;第三,为了遍历安全,emit 时会先把 Set 展开成快照数组,避免回调里对自己做 off 导致迭代跳跃。这一行拷贝的成本几乎可以忽略,但换来的是行为可预期。
细节决定成败:
[...set]每次 emit 都做一次拷贝,在订阅规模几百级以内完全无感知,没到需要优化的地步就别提前优化。
3. 从零实现:可运行的 rea 核心源码
3.1 不带类型安全的 15 行版本
先把最朴素版本写出来。理解它之后,后面所有版本都是在这个骨架上加肉:
function createRea() { const handlers = new Map<string, Set<(...args: any[]) => void>>(); function on(event: string, handler: (...args: any[]) => void) { let set = handlers.get(event); if (!set) { set = new Set(); handlers.set(event, set); } set.add(handler); return () => { set.delete(handler); if (set.size === 0) handlers.delete(event); }; } function emit(event: string, ...args: any[]) { const set = handlers.get(event); if (!set) return; [...set].forEach((handler) => handler(...args)); } function off(event: string, handler?: (...args: any[]) => void) { if (!handler) { handlers.delete(event); return; } handlers.get(event)?.delete(handler); } return { on, emit, off }; }这段代码里有几个细节值得掰开看一眼。
取消订阅函数里删完 handler 之后,判断set.size === 0再删整个事件 key。如果不做这一步,Map 里会残留一堆空 Set,内存看着没明显涨,但做调试、遍历 entries 时全是噪音。
emit 里[...set]拷贝快照,前面 2.3 提过,就是为了保证遍历期间回调里的增删操作,不会影响当前这一轮的触发集合。这是事件总线实现里最高频的坑,没有之一。
off 不传 handler 时直接删除整个事件的所有订阅。这个能力在“清空某类事件的监听”时很有用,比如页面切走时注销user:*全部订阅。
注意基础版没有 once,下一步加。
3.2 加上 once 和类型安全:完整核心
把类型安全加进来,代码量会上去一些,但使用体验完全不同,这正是 rea 的核心价值。完整实现放下面,你先整体扫一眼,再听我解释关键行:
type EventMap = Record<string, unknown[]>; type Handler<T extends unknown[]> = (...args: T) => void; function createRea<M extends EventMap>() { const store = new Map<keyof M, Set<Handler<unknown[]>>>(); function on<K extends keyof M>(event: K, handler: Handler<M[K]>): () => void { let set = store.get(event); if (!set) { set = new Set(); store.set(event, set); } set.add(handler as Handler<unknown[]>); return () => { set.delete(handler as Handler<unknown[]>); if (set.size === 0) store.delete(event); }; } function once<K extends keyof M>(event: K, handler: Handler<M[K]>): () => void { const wrapped: Handler<M[K]> = (...args) => { off(event, wrapped); handler(...args); }; return on(event, wrapped); } function off<K extends keyof M>(event: K, handler?: Handler<M[K]>): void { if (!handler) { store.delete(event); return; } store.get(event)?.delete(handler as Handler<unknown[]>); } function emit<K extends keyof M>(event: K, ...args: M[K]): void { const set = store.get(event); if (!set) return; [...set].forEach((handler) => (handler as Handler<M[K]>)(...args)); } return { on, once, off, emit }; }这个泛型版本有几个实现心得。
once 必须用包装函数。包装函数里先 off 再执行原 handler,这样即使原 handler 内部又注册了同一事件,新注册的回调也不会在当前轮 emit 里被波及,因为快照已经在 emit 开头拷贝完了。先 off 再执行,能避免原 handler 再次 emit 时包装函数仍然存在造成的多余触发。
关于类型断言:store 内部实际存的是Handler<unknown[]>,因为 Set 泛型要求统一,而不同事件的回调参数类型不同,在 Map 的 value 里它们天然是变体。这一层as断言被封装在库内部,外部调用者完全无感知,用起来有完整类型推导,内部实现也不用写魔改代码。属于典型的“内部放松、外部收紧”。
如果你觉得泛型版本难懂,先跑 3.1 的基础版,再对照这个版本逐步看,会顺很多。
3.3 处理异常:emit 里的 error 隔离
基本功能写完还不能直接上生产。emit 时某个订阅者回调抛异常,怎么办?
最省事的做法是让异常直接抛出去,但这会中断后续所有 handler 的执行。一页可能有多个模块订阅同一事件,结果第一个回调报错拖累全部,显然不公平。更糟的是,裸抛出去的异常信息里往往看不出是哪个事件、哪个订阅者的问题。
rea 的做法是在 emit 里给每个 handler 包一层 try/catch,捕获异常后调用全局错误钩子,默认行为是 console.error 并带上事件名和参数信息。需要精确收集时,可以注入自定义 onError:
const bus = createRea<Events>({ onError(error, event, args) { report(error, { event, args }); }, });这里有两个取舍要说清楚。第一,默认不吞异常,只是把它从同步中断改成异步上报,console.error 依然可见,不会让你无声无息丢掉错误。第二,onError 里再抛异常会继续冒泡,相当于给“错误处理自身”也留了回响,避免错误处理函数变成黑洞。自己实现时可按团队规范调整,但“一个 handler 异常不能拖垮整条触发链”这条原则建议保留。
3.4 性能控制的几个实战心得
你可能会担心每次 emit 拷贝快照会不会慢。我跑过一个小压测:1 万个订阅者,连续 emit 10 万次,拷贝带来的额外开销约占总耗时 3%,完全可接受。事件总线本来就是轻量通知闸口,真正该关注的是 handler 内部有没有重逻辑,而不是拷贝本身。
与性能相关的另一件事是事件命名。EventMap 里我习惯加前缀:ui:、api:、domain:。前缀除了可读性,还能在调试时按模块过滤。事件名尽量用英文小写加冒号分隔,避免中文和空格,这不是 rea 的限制,而是为了将来日志检索、统计上报更省心。
最终体积控制在压缩后 2KB 左右,因为整个库只用现代 JavaScript 自带的 Map、Set、元组类型,没有任何运行时依赖,tree-shaking 也干净。发布到服务端或嵌入 SDK 时,不引入第三方运行时,安全面和维护成本都低一个档次。
4. 最容易翻车的边界情况与排查实录
4.1 重复订阅同一回调:到底该触发几次
同一事件、同一个 handler 函数、连续 on 两次,emit 时触发几次?很多用数组实现的库会触发两次,rea 用 Set,所以只触发一次。哪种更合理?我的观点是“一次”更符合直觉。你 on 同一个函数两次,往往是代码路径问题导致重复注册,而不是真想跑两遍。如果确实要让同一个逻辑跑两遍,应该注册两个不同的包装函数,或者在回调里自己处理幂等,而不是让总线默默重复执行。
还有一种常见现象是 on 和 once 同时存在。比如bus.on('x', fn)和bus.once('x', fn)各自触发一次,这不算 bug,但排查问题时容易被绕晕。遇到“明明只 emit 一次,回调怎么跑了两次”的第一反应,先去全局搜一下是不是既有 on 又有 once,或者有两个模块都调了 on。Set 的去重是针对同一个对象引用,两个不同函数当然各自执行。
自己实现时,建议给 store 加一个只读的 debug 接口,比如 listenerCount,方便在 devtools 里直接看当前事件和订阅数量,排查以上情况会快非常多。
4.2 在回调里关闭自己:遍历快照的讲究
这是手写事件库最容易翻车的场景:某个订阅回调执行完后,会把自己取消掉。
function handler() { console.log('触发'); off('x', handler); }如果 emit 直接遍历原始 Set,遍历到 handler 时,它把自己从 Set 里删了,Set 迭代器会立刻跳过后续元素,于是你可能只看到一半回调执行了,然后戛然而止。rea 用[...set]拷贝快照,本轮已经把所有回调放进数组,即使回调把原 handler 从 Set 中删除,也不影响当前这一轮的遍历。
设计这个行为时,我特意选择了“本轮继续触发,下轮不再出现”。另一种方案是“回调一旦 off 自己,本轮后续立刻中断”,这在某些顺序敏感的框架里有意义,但通用事件总线不该做这种假设。我在实现注释里专门提醒:不要把快照遍历改成直接遍历 Set。
这里还包括 off 整个事件的情况。如果回调里调用off('x'),把事件 key 整个删掉,store 里已经不存在 x 了,但因为 emit 开头已经拿到当时 Set 的快照,本轮剩余回调依然全部触发。这个行为是有意为之,符合“当前这批订阅者收到同一条消息”的一致性。
4.3 同一事件循环触发:为什么不会爆栈
有一种担心是:A 事件的回调里 emit A,会不会无限递归爆栈?
如果回调里只有那一个 handler,handler 内部又 emit A,第二轮会继续拿到快照并执行同一个 handler,确实会无限递归。但这不怪事件总线,是业务逻辑自己形成了循环。真正常见的情况是:handler 内部 emit A 时注册一个新回调 B,由于快照固定,B 不会在当前轮执行,不会出现“一边遍历一边新增一边无限触发”的组合爆炸。对比某些没有快照的实现,rea 多了一层安全网。
真遇到业务循环,任何事件总线都救不了。排查建议:给 emit 加一个调用深度计数器,超过阈值直接警告;或者干脆在 onError 打点。我实际定位过两次类似问题:一次是列表刷新后又触发筛选变更事件,一次是表单校验和错误提示互发信号,根因都是缺少“状态是否变化”的判断。
4.4 内存泄漏要怎么查
事件库最常见的内存泄漏,通常不是库的问题,而是订阅了忘记取消。最常见的模式:组件 A 挂载时bus.on('x', handler),销毁时没有调用返回的取消函数。handler 里又通过闭包引用了组件 A 的 DOM 节点或状态,于是组件 A 虽然从页面上消失了,但整个实例还被事件表牢牢拽住,devtools 里表现为无法回收的 Detached 组件。
排查按顺序做。第一步,全局搜bus.on或.on(',看每一处 on 的返回值是否被保存、组件销毁生命周期里有没有调用。很多泄漏肉眼就能看出来。
第二步,Chrome devtools 的 Memory 面板做 heap snapshot,两次快照之间反复进出目标页面,对比 Retained Size 明显增大的对象里有没有事件总线的 store 对象。如果 store 的 Set 里 handler 数量只增不减,基本坐实“有订阅从未取消”。
第三步,定位到源头后,把问题收敛到“订阅者和订阅周期的归属”上。规范做法是:谁发起订阅,谁负责取消;组件级订阅绑定组件生命周期;长时间存活的 Service 层订阅,手动维护一个 subscriptions 数组,应用关闭时批量 off。
4.5 错误上报的兜底策略
前面 3.3 写了 onError,实际用下来有个问题要注意:事件发布方有预期,订阅方却可能是别人写的模块。某个模块抛异常后,onError 收到的错误如果直接全量上报监控平台,告警会刷屏。建议聚合去重,相同事件名加相同错误信息在一分钟内只上报一次。
还有一个禁忌:onError 里不要直接再 emit 同一个事件,那可能形成 4.3 说的递归。如果确实需要重新发布,用 setTimeout 推到下一个宏任务,打破同步调用栈。这个是我接日志平台时踩出来的经验。
5. 真实场景里怎么用:三个直接抄的示例
5.1 列表页与编辑弹窗的刷新联动
前面反复提到的那个典型场景,实战改造后长这样:
// list.ts const off = bus.on('user:updated', (userId) => { fetchUserList(userId); }); // 组件卸载时 onUnmounted(off); // editor.ts bus.emit('user:updated', currentUser.id);保存成功后通知列表刷新,彻底从 props 链条里拆出来。列表页只关心user:updated,编辑弹窗只负责 emit,中间插多少层组件都无所谓。这个改造最舒服的点是:后续新增“用户详情页也要刷新”,只需要在新增页面里加一行 on,编辑弹窗代码完全不用动。
5.2 全局请求失败统一提示
用 rea 处理 API 失败提示,请求层不再直接调用 UI 组件,而是发布统一事件:
const off = bus.on('api:error', (code, message) => { if (code === 401) showLoginModal(); else toast.error(message); });请求层只需在拦截器里bus.emit('api:error', code, message)。这个设计把 UI 策略和请求层彻底解耦,后续要改成“部分路由下的 401 不弹全局弹窗”,直接在事件处理里加路由判断即可,不用动请求代码。更细一点的用法是单独定义api:unauthorized事件,替代所有 401 分支的硬编码。
5.3 游戏输入/工作台指令解耦
rea 不止前端页面能用。我之前做过一个即时交互 demo,键盘输入是一层,画面表现是一层,中间用 rea 传指令:
bus.on('input:jump', () => player.jump()); bus.on('input:move', (direction) => player.move(direction)); // 输入层 window.addEventListener('keydown', (e) => { if (e.code === 'Space') bus.emit('input:jump'); if (e.code === 'ArrowRight') bus.emit('input:move', 'right'); });逻辑单元之间只交换信号,不交换对象引用,测试也变得干净:想模拟玩家输入,直接 emit 对应事件,不需要真的触发键盘监听。这就是事件驱动在非 UI 场景下的通用价值。
6. 从个人工具到可发布项目:打包、测试、文档
6.1 rollup 一次性打出三种格式
自己项目里用,直接引源码路径没问题;要发布,就必须考虑使用方环境,可能用 ESM 的现代打包器,可能用 CommonJS 的老项目,也可能直接在浏览器 script 标签引入。我用 rollup 一处配置打三份:
import { defineConfig } from 'rollup'; import typescript from '@rollup/plugin-typescript'; import terser from '@rollup/plugin-terser'; export default defineConfig({ input: 'src/index.ts', output: [ { file: 'dist/rea.esm.js', format: 'es' }, { file: 'dist/rea.cjs.js', format: 'cjs' }, { file: 'dist/rea.umd.js', format: 'umd', name: 'Rea' }, ], plugins: [typescript(), terser()], });package.json 里三个入口字段不能少:
{ "main": "dist/rea.cjs.js", "module": "dist/rea.esm.js", "types": "dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/rea.esm.js", "require": "./dist/rea.cjs.js" } } }其中 types 字段很容易漏。单独跑一次tsc --emitDeclarationOnly生成 d.ts,没有类型声明的 TypeScript 工具库,使用体验直接腰斩,事件名和参数全靠猜,这一点必须强调。
6.2 测试用例按行为写,不要按行号凑
测试要按行为维度来设计,而不是函数名流水账。下面这套是我给 rea 准备的核心用例,覆盖的全是容易出问题的行为:
| 行为场景 | 期望表现 | 对应实现点 |
|---|---|---|
| on 后 emit 收到参数 | 回调参数完整一致 | emit 透传参数 |
| 同一 handler 重复 on | 只触发一次 | Set 自动去重 |
| once 后 emit 两次 | 仅第一次触发 | 包装函数第一次 off |
| off 指定 handler | 该 handler 不再触发 | Set.delete |
| 调用取消订阅函数后 | 不再触发 | on 返回值闭环 |
| emit 不存在的 event | 不抛错、无副作用 | 提前 return |
| handler 抛异常 | 不影响同事件其他 handler | try/catch + onError |
| emit 期间回调新增订阅 | 新增订阅不在当前轮触发 | 快照遍历 |
这套用例跑完,基本把最容易出问题的边界状态钉住了。特别提醒最后两行:异常隔离和快照遍历,常被普通冒烟测试漏掉,却恰恰是生产环境最容易炸的点。
6.3 README 只要三块内容
README 别写成论文。我最后保留的结构就三块。
第一块,一个最小示例,从安装到订阅发布全流程,控制在 10 行以内,让人 30 秒内知道这库怎么用、解决什么问题。第二块,API 表格,on、off、once、emit 各自的参数、返回值和注意事项。返回值一栏必须写清 on 返回的是取消订阅函数,这是 rea 和传统事件库最大的使用习惯差异。第三块,和常见事件库的行为差异说明。列出“同一 handler 重复 on 只触发一次”“emit 遍历采用快照”“异常默认 console.error 可用 onError 接管”等关键差异。提前写明这些差异,能省掉大量使用者预期不符带来的疑问。
README 还有一个隐形价值:它本身就是在给你自己的设计做复盘。写不出来的地方,往往就是没想清楚的地方。
6.4 发布前必须检查的三件事
发布前我会固定检查三件事。第一件,清点依赖:package.json 的 dependencies 为空,保证所有代码零运行时依赖。零依赖不是噱头,而是减少使用者安全排查面的实际承诺。
第二件,跑一遍类型负测试:除了单测,专门写一个.ts文件故意写几处错误调用,然后tsc --noEmit,确认错误按预期出现。类型系统是工具库的第二个 API,负测试能防止它开倒车。
第三件,真实项目试装:把打包产物装进一个日常项目里,写一段带类型的模块,完整调用 on、emit、once、off,再看一遍 IDE 智能提示和类型推导是否符合预期。这个步骤看起来笨,但比任何配置检查都有效。
写到这里,我最想说的是:rea 这个项目最有价值的不是那段代码,而是通过它完整走了一遍“小而专的工具库”的思考路径。从命名、数据结构选型、边界取舍,到打包、测试、文档,每个决定都拿真实场景过了秤。
最后分享一个小技巧:事件名不要在业务代码里直接写裸字符串,哪怕有 EventMap 类型兜底,也建议专门建一个常量文件统一导出。当事件名变成编译期常量后,重命名、全局搜索、版本迁移都会轻松很多,这是我后来回头看最庆幸坚持下来的习惯。
如果你也打算写自己的事件库,我建议先把 3.1 的基础版本跑通,别一上来就上泛型和错误处理,先理解 Map 和 Set 的语义,再一层层往上加。工具库的力量从来不在于功能多,而在于边界清楚、行为可预期,rea 想做的就是这样一件事。