“按 ESC 关闭弹窗”这件事,很多人会下意识觉得没什么可讲的:
- 监听键盘;
- 触发 close;
- 把弹窗删掉。
但 Odoo 的 dialog.js 和 dialog_service.js 恰恰说明,这种理解太粗了。
在成熟 Web Client 里,ESC 关闭链路真正要回答的是:
- 现在谁有资格响应 ESC?
- 关闭时走的是 dismiss 还是直接 close?
- onClose 回调、overlay 移除、stack 激活态恢复由谁负责?
- 多层弹窗下,为什么下面那层不能顺便一起响应?
所以 Odoo 处理的不是“ESC 能不能关”,而是ESC 属于哪一层 UI 所有权。
一、ESC 并不是 dialog service 直接监听的,而是 Dialog 组件声明热键
dialog.js 里最关键的一句是:
useHotkey("escape", () => this.onEscape())
这意味着 ESC 的入口并不是 service 层全局粗暴拦截,而是当前 Dialog 组件主动声明自己如何响应 escape。
这有两个好处:
- ESC 的行为跟组件生命周期绑定;
- 不会把“关闭弹窗”硬编码成全局唯一键盘副作用。
换句话说,dialog service 负责“把弹窗体系搭起来”,而具体一个 Dialog 组件是否接 ESC、怎么接、接到后做什么,是组件自己的契约。
二、onEscape() 不是直接 remove,而是先走 dismiss()
源码里 onEscape() 最终调用的是 dismiss(),而 dismiss() 的逻辑是:
- 如果
this.data.dismiss存在,先await this.data.dismiss(); - 然后再
return this.data.close({ dismiss: true })。
这个顺序很关键。
它说明 Odoo 明确区分:
- “用户按 ESC 取消”
- 和 “业务上确认关闭”
ESC 默认对应的是 dismiss 语义,而不是普通 close。这样调用方就可以在 dismiss 里做:
- 取消预览;
- 还原临时状态;
- 拦截尚未提交的局部变更;
- 记录一次用户主动取消。
所以 ESC 不是 DOM 删除快捷键,而是带语义的退出入口。
三、真正移除 UI 的还是 overlay 层
很多人做弹窗时,容易把 dialog 和 overlay 混成一层。
但 Odoo 的 dialog_service.js 很清楚:
- dialog service 依赖
overlayservice; add()里最后调用的是overlay.add(...);- 返回的
remove才是真正把那层浮层从 overlay 体系里移走的函数。
这意味着 ESC 关闭链路大致是:
Dialog捕获 escape;- 进入
dismiss(); - 调用
dialogData.close({ dismiss: true }); close实际绑定到 dialog service 中的remove(params);- overlay 执行移除;
- onRemove 再负责栈收尾与激活态切换。
你会发现,Odoo 整个设计非常克制:
- 组件声明关闭意图;
- service 维护 stack 与语义;
- overlay 负责真正挂载/卸载。
这三层没有硬糊在一起。
四、isActive 才是多层弹窗下 ESC 不串层的关键
dialog_service.js 每次新弹窗打开时,都会先 deactivate(),把旧 stack 里的 subEnv.isActive 全部设成 false,然后新弹窗自己变成 active。
关闭时再:
- 把当前层从 stack 里移掉;
- 所有层重新失活;
- 如果还有上一层,就把栈顶恢复成
isActive = true。
这一步的意义,不只是 CSS 变灰。
它其实是在建立一条很明确的规则:
当前只有最上层 dialog 拥有前台交互权。
没有这个规则时,多层弹窗最常见的问题就是:
- 顶层按 ESC;
- 底层也收到;
- 快捷键或 footer 行为串到下面;
- 用户以为关了一层,结果背景层也跟着状态乱掉。
Odoo 用 o_inactive_modal 和 isActive 把这个所有权写得很硬,这也是为什么它的多层弹窗更稳。
五、onClose 不是顺手回调,而是 overlay 移除后的结算点
在 dialog_service.js 里,overlay.add() 的 onRemove 会做几件事:
- 防重复关闭;
- 执行
options.onClose?.(closeParams); - 从 stack 里删掉当前 dialog;
- 恢复上一层 active;
- 没有剩余弹窗时移除
body.modal-open。
这说明 onClose 的语义也很明确:
它不是“用户触发了关闭按钮”的瞬时事件,而是“这层 overlay 正在被移除”的收尾阶段。
因此 ESC 关窗时,最终仍会经过同一套回收路径,不会因为入口是键盘就绕过栈一致性。
六、为什么这条链路值得开发者记住
因为你在二开弹窗时,最容易做错的就是跳过其中某一层:
- 直接自己监听
keydown; - 直接卸载组件;
- 不区分 dismiss / close;
- 不尊重 active stack。
一旦这么做,表面上似乎只少了几层包装,实际上你绕过的是 Odoo 已经替你处理好的:
- 关闭语义;
- overlay 生命周期;
- body 滚动锁;
- 多层激活态恢复;
- onClose 一致收尾。
结语
Odoo 的 ESC 关闭链路,本质上不是一个键盘事件实现细节,而是一条 UI 所有权链:
Dialog声明如何响应 escape;dismiss()区分取消语义;dialog service维护栈和激活态;overlay负责真正移除;onRemove统一完成回收。
所以更准确地说:
Odoo 里的 ESC 关闭弹窗,不是“关掉当前框”,而是“让最上层拥有所有权的 dialog 沿统一回收契约退出”。
这才是成熟弹窗系统该有的样子。
DISCUSSION
评论区