前端

Odoo 组件通信为什么不该“到处 emit 一下”:useBus、EventBus 与生命周期解绑讲透

很多人一听到组件通信,就想上全局事件中心,结果事件来源越来越乱、页面切换后残留监听越来越多。Odoo 的 `useBus`、局部 `EventBus` 和组件销毁时自动解绑的设计说明,真正可靠的前端通信不是“谁都能发谁都能听”,而是事件范围、宿主对象与生命周期三件事必须同时说清。

前端
进阶 开发者 1 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

前端里最容易失控的东西之一,就是“事件”。

刚开始,事件机制往往显得特别灵活:

  • 这里 trigger 一下;
  • 那里 addEventListener 一下;
  • 页面里几个组件很快就能连起来。

但系统一大,副作用马上出现:

  • 你不知道事件是谁发的;
  • 也不知道谁还在监听;
  • 页面切走了监听还活着;
  • 同名事件在不同上下文里互相污染;
  • 业务 bug 变成“偶发、难复现、像鬼打墙”。

Odoo 在这件事上的思路很成熟:

事件通信不是不能用,而是必须同时讲清三件事:事件挂在哪根 bus 上、影响范围多大、组件销毁时谁来收尾。

这正是 useBusEventBus 搭配使用时最值得学的地方。

一、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 服务于同一棵子树;
  • 事件语义围绕当前局部功能设计。

这样做的好处是,事件天然更可读:

  • reload
  • unfold-all
  • update-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,团队就能默认获得几件事:

  1. 监听和组件生命周期保持同一入口;
  2. 代码风格统一,排查时路径更稳定;
  3. 不容易忘记清理;
  4. 后续如果要增强行为,也能集中升级。

这就是框架级 API 的意义:

不是因为开发者不会写,而是因为重复手写的东西最容易写得不一致。

六、事件通信最容易踩的坑,不是“发不出去”,而是“范围失控”

很多 bug 都不是因为事件没发到,而是发得太远、听的人太多。

常见表现有:

  • 一个对话框操作,意外影响页面别处;
  • 当前页的监听器对上一个页面残留的事件有反应;
  • 调试时看到事件名,却完全不知道挂在哪根 bus 上;
  • 组件复用后,同名事件开始互相串线。

Odoo 通过 useSubEnv + EventBus + useBus 的组合,实际上在限制这类失控:

  • 先把 bus 局部化;
  • 再让子树默认拿到同一根 bus;
  • 再通过 Hook 保证监听和销毁绑定。

这套组合拳的重点不是“通信更强”,而是“通信更守规矩”。

七、什么时候该用事件,什么时候不该用

从 Odoo 源码的气质来看,事件更适合:

  • 广播式的局部动作;
  • 一对多通知;
  • 同一功能区多个组件对同一信号响应;
  • 不想把回调 props 一层层传下去的局部协作。

但如果是:

  • 明确的一对一调用;
  • 需要返回值;
  • 强顺序、强依赖的业务流程;
  • 跨功能域的核心状态更新;

那通常不该优先想事件。

因为事件擅长“通知”,不擅长“严格交易”。

八、二开最实用的经验:给事件名加上上下文脑补

Odoo 源码里很多事件名看起来很普通,比如:

  • reload
  • update-folded
  • dropdown-opened

如果你只盯名字,会觉得太泛。

但一旦你先问“这根 bus 属于谁”,名字就清楚了。

所以调试事件系统时,第一步不是搜事件名,而是先定位:

  • 当前组件拿的是哪根 bus;
  • 这根 bus 从哪里注入;
  • 它是全局的、页面级的,还是子树级的。

九、总结:可靠的事件系统,靠的不是“多发”,而是“边界 + 生命周期”

useBus 这类 Hook 看起来不华丽,却恰好落在最关键的位置:

  • 监听器和组件生命周期一致;
  • 事件挂在明确的 bus 宿主上;
  • bus 又可以被限制在某块局部子树里。

所以 Odoo 给我们的真正提醒是:

组件通信的重点,从来不是“哪里 emit 一下就通了”,而是“这条信号属于谁、会活多久、会影响谁”。

当这三个问题都说清楚,事件机制就是高效协作;说不清,它就会变成以后最难拆的隐形耦合。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。