前端里最容易失控的东西之一,就是“事件”。
刚开始,事件机制往往显得特别灵活:
- 这里
trigger一下; - 那里
addEventListener一下; - 页面里几个组件很快就能连起来。
但系统一大,副作用马上出现:
- 你不知道事件是谁发的;
- 也不知道谁还在监听;
- 页面切走了监听还活着;
- 同名事件在不同上下文里互相污染;
- 业务 bug 变成“偶发、难复现、像鬼打墙”。
Odoo 在这件事上的思路很成熟:
事件通信不是不能用,而是必须同时讲清三件事:事件挂在哪根 bus 上、影响范围多大、组件销毁时谁来收尾。
这正是 useBus 和 EventBus 搭配使用时最值得学的地方。
一、useBus 最重要的不是“监听”,而是“自动解绑”
addons/web/static/src/core/utils/hooks.js 里的 useBus 很短:
- 绑定
bus.addEventListener(eventName, listener); - 在
useEffect的清理阶段自动removeEventListener。
别小看这几行。
它真正解决的是经典监听泄漏问题:
- 组件已经销毁;
- 但监听器还挂在 bus 上;
- 下次事件触发时,旧逻辑偷偷再跑一遍。
在复杂单页应用里,这类 bug 极其常见,因为“离开页面”不等于浏览器刷新。组件可能已经被 Owl 销毁,但你之前手绑的监听器还活着。
所以 useBus 的价值不是少写几行代码,而是把:
监听与组件生命周期重新绑回一起。
二、Odoo 常用的不是“一个宇宙级总线”,而是很多局部 bus
制造总览 mrp_mo_overview.js 很有代表性:
useSubEnv({ overviewBus: new EventBus() })useBus(this.env.overviewBus, "update-folded", ...)this.env.overviewBus.trigger("unfold-all")
这说明官方并不迷恋“一根全局 EventBus 管全站”。
相反,它更喜欢:
- 某块功能区自己持有一根 bus;
- 这根 bus 服务于同一棵子树;
- 事件语义围绕当前局部功能设计。
这样做的好处是,事件天然更可读:
reloadunfold-allupdate-folded
这些名字在“制造总览局部世界”里足够清楚,但如果扔进全局总线,就会立刻变得暧昧。
三、useBus 的正确问题不是“能不能通信”,而是“该挂哪根 bus”
很多团队一上来问的是:
- 这个组件能不能通过事件通知另一个组件?
Odoo 更成熟的问法应该是:
- 这件事属于哪块局部世界?
- 这根 bus 的宿主是谁?
- 它应该和哪个组件树一起出生、一起消失?
比如网站商品对比底栏 product_comparison_bottom_bar.js:
useBus(this.props.bus, comparisonUtils.COMPARISON_EVENT, ...)useSubEnv({ bus: this.props.bus })
官方没有把商品比较强行并入神秘全局广播,而是显式接收一根 props.bus,再把它注入局部子树。
这意味着:
- bus 的来源是已知的;
- bus 的消费范围是局部的;
- 功能区拆掉时,这套通信网络也一起消失。
四、EventBus 真正厉害的地方,是让“局部协作”变便宜
很多人对事件总线反感,是因为他们见过太多失控的全局 pub/sub。
但 Odoo 的用法提醒我们:
EventBus 不一定是全局广播台,它也可以是某个小功能区内部的对讲机。
当一组兄弟组件需要协作时,局部 EventBus 往往比“父组件逐层传回调 + 中间层不断透传”更轻。
例如:
- 某个块展开/折叠;
- 某个面板要求整体重载;
- 某个局部工具条发出统一动作;
- 某棵树内多个层级要响应同一信号。
只要边界清晰,局部总线并不是反模式,反而是对复杂组件树的一次减压。
五、为什么官方坚持 Hook 而不是手写 add/remove
你当然可以手动写:
bus.addEventListener(...)onWillUnmount(() => bus.removeEventListener(...))
但 Odoo 还是提供了 useBus,原因不只是偷懒。
一旦把这件事做成 Hook,团队就能默认获得几件事:
- 监听和组件生命周期保持同一入口;
- 代码风格统一,排查时路径更稳定;
- 不容易忘记清理;
- 后续如果要增强行为,也能集中升级。
这就是框架级 API 的意义:
不是因为开发者不会写,而是因为重复手写的东西最容易写得不一致。
六、事件通信最容易踩的坑,不是“发不出去”,而是“范围失控”
很多 bug 都不是因为事件没发到,而是发得太远、听的人太多。
常见表现有:
- 一个对话框操作,意外影响页面别处;
- 当前页的监听器对上一个页面残留的事件有反应;
- 调试时看到事件名,却完全不知道挂在哪根 bus 上;
- 组件复用后,同名事件开始互相串线。
Odoo 通过 useSubEnv + EventBus + useBus 的组合,实际上在限制这类失控:
- 先把 bus 局部化;
- 再让子树默认拿到同一根 bus;
- 再通过 Hook 保证监听和销毁绑定。
这套组合拳的重点不是“通信更强”,而是“通信更守规矩”。
七、什么时候该用事件,什么时候不该用
从 Odoo 源码的气质来看,事件更适合:
- 广播式的局部动作;
- 一对多通知;
- 同一功能区多个组件对同一信号响应;
- 不想把回调 props 一层层传下去的局部协作。
但如果是:
- 明确的一对一调用;
- 需要返回值;
- 强顺序、强依赖的业务流程;
- 跨功能域的核心状态更新;
那通常不该优先想事件。
因为事件擅长“通知”,不擅长“严格交易”。
八、二开最实用的经验:给事件名加上上下文脑补
Odoo 源码里很多事件名看起来很普通,比如:
reloadupdate-foldeddropdown-opened
如果你只盯名字,会觉得太泛。
但一旦你先问“这根 bus 属于谁”,名字就清楚了。
所以调试事件系统时,第一步不是搜事件名,而是先定位:
- 当前组件拿的是哪根 bus;
- 这根 bus 从哪里注入;
- 它是全局的、页面级的,还是子树级的。
九、总结:可靠的事件系统,靠的不是“多发”,而是“边界 + 生命周期”
useBus 这类 Hook 看起来不华丽,却恰好落在最关键的位置:
- 监听器和组件生命周期一致;
- 事件挂在明确的 bus 宿主上;
- bus 又可以被限制在某块局部子树里。
所以 Odoo 给我们的真正提醒是:
组件通信的重点,从来不是“哪里 emit 一下就通了”,而是“这条信号属于谁、会活多久、会影响谁”。
当这三个问题都说清楚,事件机制就是高效协作;说不清,它就会变成以后最难拆的隐形耦合。
DISCUSSION
评论区