前端

Odoo 弹窗按下 ESC 为什么不是“关掉当前框”这么简单:dialog、overlay 与激活态所有权链路讲透

现成 UI 里按 ESC 关闭弹窗看起来天经地义,但 Odoo 的实现并不是谁监听到键盘就自己销毁。结合 dialog.js 与 dialog_service.js,本文专门讲透 ESC 从热键到 dismiss 再到 overlay remove 的所有权链路,以及多层弹窗为什么必须依赖 isActive。

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

“按 ESC 关闭弹窗”这件事,很多人会下意识觉得没什么可讲的:

  • 监听键盘;
  • 触发 close;
  • 把弹窗删掉。

但 Odoo 的 dialog.jsdialog_service.js 恰恰说明,这种理解太粗了。

在成熟 Web Client 里,ESC 关闭链路真正要回答的是:

  1. 现在谁有资格响应 ESC?
  2. 关闭时走的是 dismiss 还是直接 close?
  3. onClose 回调、overlay 移除、stack 激活态恢复由谁负责?
  4. 多层弹窗下,为什么下面那层不能顺便一起响应?

所以 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() 的逻辑是:

  1. 如果 this.data.dismiss 存在,先 await this.data.dismiss()
  2. 然后再 return this.data.close({ dismiss: true })

这个顺序很关键。

它说明 Odoo 明确区分:

  • “用户按 ESC 取消”
  • 和 “业务上确认关闭”

ESC 默认对应的是 dismiss 语义,而不是普通 close。这样调用方就可以在 dismiss 里做:

  • 取消预览;
  • 还原临时状态;
  • 拦截尚未提交的局部变更;
  • 记录一次用户主动取消。

所以 ESC 不是 DOM 删除快捷键,而是带语义的退出入口。

三、真正移除 UI 的还是 overlay 层

很多人做弹窗时,容易把 dialog 和 overlay 混成一层。

但 Odoo 的 dialog_service.js 很清楚:

  • dialog service 依赖 overlay service;
  • 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_modalisActive 把这个所有权写得很硬,这也是为什么它的多层弹窗更稳。

五、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

评论区

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