前端

Odoo 弹窗为什么不是“new 一个组件显示出来”:dialog service、overlay 栈与关闭契约讲透

很多人做前端弹窗时,思路还停留在“创建组件、挂到页面、点关闭就销毁”。但 Odoo `dialog_service.js` 与 `overlay_service.js` 显示,真正稳定的弹窗系统要处理层级栈、激活态、滚动锁定、关闭回调与子环境注入。它本质上是一个运行时服务,而不是某个组件的小技巧。

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

很多项目里的弹窗系统,做久了都会遇到同一类问题:

  • 第二层弹窗打开后,第一层还在抢焦点;
  • 关闭顺序一乱,页面滚动状态就坏掉;
  • 组件销毁了,但关闭回调没跑完;
  • 一个功能弹窗写得很顺,另一个功能又各自造了一套。

Odoo 在 addons/web/static/src/core/dialog/dialog_service.jsaddons/web/static/src/core/overlay/overlay_service.js 里的做法,很适合拿来回答一个问题:

为什么成熟前端里的弹窗,最好不要只是“临时挂个组件”。

官方把弹窗明确做成了:

  • 一个 dialog service;
  • 底层依赖 overlay service;
  • 由主组件里的 OverlayContainer 统一承载;
  • 再通过 stack、subEnv、onClose 契约去管理生命周期。

这说明 Odoo 眼里的弹窗,不是某个页面的小特效,而是 Web Client 的基础运行时能力。

一、dialog 依赖 overlay,说明“弹窗”其实是 overlay 的一种语义化封装

dialogService 的定义很直接:

  • dependencies: ["overlay"]

这行代码的意义非常大。

它说明官方没有把 dialog 做成“独立的 DOM 管理器”,而是承认:

  1. 真正负责把东西挂到页面浮层里的,是 overlay;
  2. dialog 只是建立在 overlay 之上的一种更具体的 UI 约定。

overlay_service.js 会:

  • 注册主组件 OverlayContainer
  • 维护一个 reactive 的 overlays 集合
  • 提供统一的 add(component, props, options) 与 remove 逻辑

也就是说,在底层世界里:

  • tooltip 也可以是 overlay;
  • drawer 也可以是 overlay;
  • dialog 也是 overlay。

而 dialog service 的价值,在于补上“模态弹窗”特有的行为规则。

二、为什么 dialog 要维护自己的 stack

dialog_service.js 里自己维护了一个:

  • stack = []

这里存的不是组件实例,而是每个 dialog 的 subEnv 状态对象,至少包括:

  • id
  • close
  • isActive

很多人看到这会问:overlay 不是已经有 overlays 集合了吗,为什么 dialog 还要再存一层 stack?

因为 overlay 关心的是:

  • 页面上有哪些浮层;
  • 它们的组件和 props 是什么;
  • 什么时候移除。

而 dialog 关心的是更上层的问题:

  • 哪个对话框现在是“活跃”的;
  • 新弹窗打开后,旧弹窗如何失活;
  • 全部关闭时顺序如何反向回收;
  • body 的 modal-open 类什么时候加、什么时候去。

换句话说:

overlay 负责“挂上去”,dialog 负责“按模态栈规则运行”。

三、isActive 不是装饰字段,而是多层弹窗不打架的关键

每次 add() 一个新 dialog,service 会先执行 deactivate(),把当前 stack 里所有 subEnv.isActive 设成 false

随后把新 dialog push 进去,并让它成为激活态。

关闭时,再把栈顶以下最后一层恢复成 isActive = true

这个细节非常关键。

因为多层模态框最怕的是“虽然视觉上在上面,但下面那层还活着”。这会导致:

  • 焦点冲突;
  • 键盘快捷键误触发;
  • 背景对话框还在响应某些 watcher 或行为;
  • 用户以为自己只在操作顶层,实际上底层也在动。

isActive 的存在,本质上就是在给多层 dialog 建一个明确的前台所有权

四、为什么 dialog 要通过 subEnv 注入 close / isActive,而不是全靠 props

源码里 DialogWrapper 很小,但作用很大:

  • useChildSubEnv({ dialogData: this.props.subEnv })
  • 再把真正的 dialog 组件渲染出来

这说明官方并不想把 dialog 的运行时控制信息都塞进普通 props。它更想表达的是:

  • 这些数据属于“弹窗运行环境”;
  • 不是单纯业务参数;
  • 子组件树里需要稳定可取。

所以像:

  • close
  • isActive
  • id

这类信息,放进 subEnv 比塞进层层 props 更符合它的性质。

这也让弹窗内容组件不必自己关心 overlay 细节,只需要理解:

  • 我运行在一个 dialog 环境里;
  • 我可以调用 close;
  • 我可以知道自己是不是当前活跃层。

五、关闭不是“销毁完就结束”,而是一个带回调契约的异步过程

overlay.add() 收到的 options 里,会有 onRemove。而 dialog service 又在它上面封装了一层:

  • await options.onClose?.(closeParams)

这说明关闭不是一个同步瞬间,而是一条小生命周期:

  1. 用户或代码触发 close(params)
  2. overlay 准备移除;
  3. dialog service 执行 onClose
  4. 栈里删掉当前 dialog;
  5. 重新激活上一层或移除 modal-open

这非常像服务契约,而不是纯组件行为。

它允许业务层在关闭时做很多事:

  • 返回结果;
  • 清理状态;
  • 触发父层刷新;
  • 做一些异步收尾动作。

所以 Odoo 的 dialog 不是“把 UI 消失了”,而是“把一次模态交互完整结算掉”。

六、为什么要记录 scrollOrigin

每次 add dialog 时,源码都会记录:

  • window.scrollY
  • window.scrollX

并提供 scrollToOrigin()

这说明官方非常清楚,模态对页面的影响不只有视觉遮罩,还有滚动上下文。

用户打开弹窗前可能正停在很长页面的中部。如果弹窗期间 body 被锁滚或布局变化,关闭后还能不能回到相对稳定的位置,会直接影响体验。

这类事情如果每个业务弹窗自己写,最后通常会出现几十种半一致实现。做成 service 统一处理,明显更稳。

七、body 上的 modal-open 是全局副作用,所以必须由 service 管

每开一个 dialog,service 会:

  • document.body.classList.add("modal-open")

当 stack 清空后,再:

  • document.body.classList.remove("modal-open")

这代表一个重要原则:

只要一个行为会影响整个页面,而不是某个局部组件,它就更适合放进 service 层统一管理。

如果每个弹窗组件自己去加减 body class,最常见的问题就是:

  • A 弹窗开了加 class;
  • B 弹窗再开又加一次;
  • 关掉 B 时不小心把 class 直接去掉;
  • 结果 A 还开着,但 body 已经恢复可滚。

stack + service 正是为这种全局副作用兜底的。

八、overlay container 说明弹窗最终还是要回到统一渲染入口

overlay_service.js 里把 OverlayContainer 注册到 main_components。这说明所有 overlay 最终不是随便往 DOM 某个角落 append,而是进了 Web Client 认可的统一容器。

这带来的价值有:

  • 层级顺序可控;
  • 全局样式更统一;
  • 未来做 portal、rootId 定位、特定宿主挂载时更容易扩展。

特别是 rootId 这个 option,说明官方甚至考虑了 overlay 可能不只在默认根节点上工作,而要和某些特定宿主结构配合。

九、实战里最常见的几个误区

误区 1:把 dialog 当普通组件,自行 mount / unmount

短期能跑,长期会绕开统一 stack、body class、关闭契约与活跃态管理。

误区 2:只做视觉层级,不做激活态切换

这样多层弹窗时最容易出现底层继续响应输入的问题。

误区 3:onClose 只当一个“顺手通知”

在 Odoo 这里,onClose 是模态交互的结算点。很多上层刷新或返回结果都应该从这里走,而不是随便散在组件销毁钩子里。

误区 4:把 overlay 和 dialog 混成一个概念

overlay 是更底层的浮层承载;dialog 是建立其上的模态语义。分层清楚后,系统才容易长大。

十、结论

Odoo 弹窗系统的重点,不是“怎么把一个组件弹出来”,而是怎么把模态交互做成稳定的运行时能力

它通过:

  • overlay service 统一承载浮层;
  • dialog service 管理模态栈;
  • isActive 管理当前前台所有权;
  • subEnv 注入运行时控制信息;
  • onClose 维持关闭契约;
  • modal-open 与滚动恢复统一处理全局副作用。

所以更准确的理解是:

Odoo dialog 不是一个组件模式,而是一套建立在 overlay 之上的模态运行时协议。

理解这一点后,你再看确认框、向导、选择器、awaitable dialog 这些功能,就不会再把它们当成“会弹出来的页面碎片”,而会把它们看成 Web Client 的基础交互设施。

DISCUSSION

评论区

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