很多项目里的弹窗系统,做久了都会遇到同一类问题:
- 第二层弹窗打开后,第一层还在抢焦点;
- 关闭顺序一乱,页面滚动状态就坏掉;
- 组件销毁了,但关闭回调没跑完;
- 一个功能弹窗写得很顺,另一个功能又各自造了一套。
Odoo 在 addons/web/static/src/core/dialog/dialog_service.js 和 addons/web/static/src/core/overlay/overlay_service.js 里的做法,很适合拿来回答一个问题:
为什么成熟前端里的弹窗,最好不要只是“临时挂个组件”。
官方把弹窗明确做成了:
- 一个
dialogservice; - 底层依赖
overlayservice; - 由主组件里的
OverlayContainer统一承载; - 再通过 stack、subEnv、onClose 契约去管理生命周期。
这说明 Odoo 眼里的弹窗,不是某个页面的小特效,而是 Web Client 的基础运行时能力。
一、dialog 依赖 overlay,说明“弹窗”其实是 overlay 的一种语义化封装
dialogService 的定义很直接:
dependencies: ["overlay"]
这行代码的意义非常大。
它说明官方没有把 dialog 做成“独立的 DOM 管理器”,而是承认:
- 真正负责把东西挂到页面浮层里的,是 overlay;
- 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 状态对象,至少包括:
idcloseisActive
很多人看到这会问: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。它更想表达的是:
- 这些数据属于“弹窗运行环境”;
- 不是单纯业务参数;
- 子组件树里需要稳定可取。
所以像:
closeisActiveid
这类信息,放进 subEnv 比塞进层层 props 更符合它的性质。
这也让弹窗内容组件不必自己关心 overlay 细节,只需要理解:
- 我运行在一个 dialog 环境里;
- 我可以调用 close;
- 我可以知道自己是不是当前活跃层。
五、关闭不是“销毁完就结束”,而是一个带回调契约的异步过程
overlay.add() 收到的 options 里,会有 onRemove。而 dialog service 又在它上面封装了一层:
await options.onClose?.(closeParams)
这说明关闭不是一个同步瞬间,而是一条小生命周期:
- 用户或代码触发
close(params); - overlay 准备移除;
- dialog service 执行
onClose; - 栈里删掉当前 dialog;
- 重新激活上一层或移除
modal-open。
这非常像服务契约,而不是纯组件行为。
它允许业务层在关闭时做很多事:
- 返回结果;
- 清理状态;
- 触发父层刷新;
- 做一些异步收尾动作。
所以 Odoo 的 dialog 不是“把 UI 消失了”,而是“把一次模态交互完整结算掉”。
六、为什么要记录 scrollOrigin
每次 add dialog 时,源码都会记录:
window.scrollYwindow.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
评论区