我们项目里有四十多个弹窗,全是 div 套 div,遮罩用 position: fixed; inset: 0; background: rgba(0,0,0,.5),弹窗本体再定个 z-index: 9999。这套写法我从 2016 年写到 2024 年,直到有天测试报了一个 bug:用户在弹窗里按 Tab 键,焦点跑到了弹窗背后的搜索框上,然后又跑到了底部导航栏。整条焦点链在页面里到处乱窜,弹窗形同虚设。
修是能修,加个 keydown 监听,把 Tab 拦下来手动算焦点循环。但那一刻我意识到,这个弹窗上需要我做的事情已经包括:焦点陷阱、ESC 关闭、点击遮罩关闭、aria-modal、aria-labelledby、滚动锁定、层级管理、关闭后焦点还原。八件事,每一件都是浏览器原生的 dialog 元素默认就做好的。
所以就有了这次迁移。下面把过程、坑、和最后的成果都写下来。
先看 dialog 到底白送了什么
写一个最小可用的 dialog:
<dialog id="confirm">
<p>确定要删除这条记录吗?</p>
<button value="cancel">取消</button>
<button value="ok">确定</button>
</dialog>
<script>
const dlg = document.getElementById('confirm');
dlg.showModal();
</script>
就这几行,浏览器帮你干了下面这些事:
弹窗打开时会自动出现在顶层(top layer),无论页面里其它元素的 z-index 堆到多高都压不住它。焦点会被自动移到弹窗内第一个可聚焦元素上。按 Tab 键只在弹窗内部循环,不会跑出去。按 ESC 会触发关闭(这个行为在 modal 状态下是默认的,拦不住,后面细说)。弹窗背后的内容对屏幕阅读器会变成 inert 状态,读屏软件不会读到。关闭之后焦点自动回到打开它的那个按钮上。
这五件事,如果自己用 div 实现,至少要写一百多行 JS,还很容易在某个浏览器上出错。而 dialog 是浏览器内核级的实现,稳定性不用你操心。
showModal 和 show 的区别,很多人搞混
dlg.showModal() 打开的是模态对话框——就是有遮罩、背后不可交互、有焦点陷阱的那种。
dlg.show() 打开的是非模态对话框——没有遮罩,背后照样能点,通常用来做浮动面板、Toast 提示这类东西。
如果你只调 dlg.show(),会发现弹窗既没遮罩也拦不住背后操作,然后你以为 dialog 不好用。其实是你调错了方法。
另外还有一个容易踩的点:如果 dialog 上没有 open 属性,浏览器默认是 display: none。你调了 showModal 之后它会变成 display: block。但如果你在 CSS 里手写了 dialog { display: none; } 又没写 dialog[open] { display: block; },那弹窗就永远打不开。这种问题搜都搜不到,因为它看起来完全正常。
::backdrop 和滚动穿透
遮罩层现在由 ::backdrop 伪元素来画,CSS 长这样:
dialog::backdrop {
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
}
它只在 showModal 状态下存在,show 状态下没有。
接下来就是老生常谈的滚动穿透。iOS 上你打开弹窗,手指在弹窗上滑到底之后,继续滑会把背后的页面也带着滚起来。这个问题的通用解法是给 body 加个类:
body.modal-open {
overflow: hidden;
position: fixed;
width: 100%;
}
但这里有个反直觉的地方:大多数情况下你根本不需要这么做。因为 dialog 是顶层元素,出问题时浏览器对背后的滚动处理本身就比 div 遮罩好很多。真正会出问题的场景只有一个——弹窗内容本身是可滚动的,而且你希望滚动到边界时不传播到父层。这种情况用 overscroll-behavior: contain 就够了:
dialog {
overscroll-behavior: contain;
}
比给 body 加 fixed 温和得多,也不会引起页面跳动和滚动位置丢失。
method=”dialog” 这个表单特性,省掉一堆 JavaScript
这是我这次迁移里最意外的一个收获。看下面这段:
<dialog id="confirm">
<form method="dialog">
<p>确定要发布吗?</p>
<button value="cancel">再想想</button>
<button value="publish">发布</button>
</form>
</dialog>
<script>
const dlg = document.getElementById('confirm');
const form = dlg.querySelector('form');
async function askPublish() {
dlg.showModal();
return new Promise((resolve) => {
dlg.addEventListener('close', () => {
resolve(dlg.returnValue);
}, { once: true });
});
}
const result = await askPublish();
if (result === 'publish') {
// 真正执行发布
}
</script>
method="dialog" 让表单的提交变成”关闭对话框”这个动作,并且把被点击按钮的 value 写到 dlg.returnValue 上。不需要给按钮绑定 click 事件,不需要 e.preventDefault,不需要手动 close。浏览器全包了。
更关键的一点:因为走的是表单提交流程,回车键会自动触发第一个非 type=button 的按钮。也就是用户在弹窗里按回车,默认点的是”取消”还是”确定”,取决于你把哪个按钮写在前面。这个顺序在删除确认这种场景里很重要,别写反了。
ESC 关不掉怎么办
ESC 默认能关,但有时候你确实不想让它关——比如表单填了一半,用户按下 ESC 丢掉一堆输入,体验很糟。
标准做法是监听 cancel 事件然后 preventDefault:
dlg.addEventListener('cancel', (e) => {
e.preventDefault();
// 这里可以做二次确认,或者干脆忽略
});
注意是 cancel 事件而不是 keydown。cancel 是 dialog 特有的,ESC 按下时触发。keydown 也能拦,但那是跟浏览器抢活儿干,而且有输入法、无障碍工具等边缘情况要考虑,别自己找麻烦。
多层嵌套弹窗的层级问题
产品喜欢叠弹窗。从”订单详情”里点”申请退款”,再点”确认退款条款”。三层叠在一起,关掉最上面那个之后,下面两层的状态要正确恢复。
好消息是 dialog 天生支持这种嵌套。每个 showModal 都会把对应 dialog 推到 top layer 的栈顶。ESC 只会关掉最上面那个,焦点也只会回到它打开前的元素上。
坏消息是有个陷阱:如果你在最顶层 dialog 打开时给 body 加了 modal-open 类,那关闭任意一层时都要判断是否还有其它 dialog 打开着。一不小心就在关闭第一层时把 body 的滚动锁解开了,结果第二层弹窗还能滚动背景。这个 bug 我们线上被抓过一次。
所以我又回到了上面说的那个结论:尽量别动 body。真要用 overscroll-behavior,那是写在 dialog 上的,天生和层数无关。
iOS Safari 的两个坑
Safari 从 15.4 开始稳定支持 dialog,16.4 之后 popover 也进来了。但即使用新版 Safari,我遇到过两个非标准问题。
第一个是键盘弹起时的位置错位。在 iOS 上点击 dialog 里的输入框,软键盘弹出,dialog 会停留在原来的位置而不是跟随键盘上移。如果你的 dialog 是垂直居中的,输入框很可能被键盘挡住。解决办法是监听 visualViewport 变化:
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
const dlg = document.querySelector('dialog[open]');
if (!dlg) return;
const height = window.visualViewport.height;
const offset = window.visualViewport.offsetTop;
dlg.style.transform = 'translateY(' + (-offset) + 'px)';
dlg.style.maxHeight = height + 'px';
});
}
这段代码只在 iOS 上有实际效果,Android 上 visualViewport 的行为已经足够好,加了也无害。
第二个是 dialog 内的 fixed 定位元素会相对 viewport 而不是 dialog 定位。这个反直觉但其实是符合规范的,因为 dialog 自己是顶层元素。如果你希望在 dialog 内部有一块”吸顶”的头部或者”吸底”的按钮栏,用 position: sticky 而不是 fixed。
popover:不是 dialog 的替代品
popover 是另一个新的原生特性,用法更简单:
<button popovertarget="menu">打开菜单</button>
<div id="menu" popover>
<a href="#" rel="external nofollow" rel="external nofollow" >选项一</a>
<a href="#" rel="external nofollow" rel="external nofollow" >选项二</a>
</div>
连一行 JS 都不需要,点击按钮就能打开那个 div。点击页面其它地方自动关闭。它也会有 ::backdrop,但默认是透明的。
它和 dialog 的关系可以这么理解:dialog 是”打断用户的事”,popover 是”顺手给用户看的事”。确认删除、填写表单、选日期——这些用 dialog。右键菜单、气泡提示、下拉筛选面板——这些用 popover。
但 popover 有一个限制我不能忍:它默认不是模态的,背后内容依然可聚焦,只是视觉上被遮住了。如果你的场景需要焦点陷阱,还是得用 dialog。
顺带说一下,popover 在 2024 年初被 Chrome、Edge、Safari 17.4、Firefox 125 全部支持,基本可以放心用了。但如果你要兼容更老的环境,得配个 polyfill,或者降级到 dialog 方案。
View Transitions:让关闭动画不闪
原生 dialog 的关闭是瞬间消失的,没有过渡。想加动画只能自己排版:
dlg.addEventListener('click', (e) => {
if (e.target.dataset.close === undefined) return;
dlg.classList.add('closing');
dlg.addEventListener('animationend', () => dlg.close(), { once: true });
});
配合 CSS 里的 fade-out 动画。这个是常规写法,但会在动画末尾出现一次画面跳变——因为动画结束和 close 之间有一帧空隙。
更顺滑的方式是用 View Transitions API。这个 API 目前 Chrome、Edge、Safari 18 支持,Firefox 还在推进中。它的思路是让浏览器在”关闭前”和”关闭后”两个状态之间自动做交叉淡化:
async function closeWithTransition(dlg) {
if (!document.startViewTransition) {
dlg.close();
return;
}
const transition = document.startViewTransition(() => {
dlg.close();
});
await transition.finished;
}
配合给 dialog 加个 view-transition-name,就能做出非常丝滑的淡出。这个特性在今年热度很高,但要注意它跟 dialog 的交互有一些细节坑,比如在 startViewTransition 的回调里不能有异步操作,否则会报错。
而且别忘了加降级,不支持的环境里就是没有动画,不影响功能。
迁移清单
把项目里四十多个弹窗全换掉之后,我整理了一个执行顺序,给准备做同样事情的朋友参考。
第一步是引入一段全局 CSS,给 dialog 一个基础样式。不要每个组件重写一遍,容易不一致。第二步是把所有弹窗的开关逻辑替换成 showModal 和 close,别再用 v-if 控制暗层的显示隐藏了 —— dialog 有自己的 open 状态,两套控制逻辑混用会打架。第三步是删掉手写的焦点陷阱和 ESC 监听代码,只要没有特殊需求,这些都可以删。第四步是把所有点击遮罩关闭的逻辑接上:dialog 本身不会自动点遮罩关闭,需要用 e.target === dlg 判断一下。第五步是处理所有需要”返回值”的弹窗,把回调风格改成 method="dialog" 加 returnValue 的写法。
我们大概花了两天改完,代码量少了差不多一千二百行。删掉的东西比新增的东西多,这种感觉挺爽的。
最后提醒一句:不是所有弹窗都该用 dialog。比如全局的 Toast 提示,它不需要焦点陷阱、不需要用户交互,用 dialog 反而是过度设计。这类东西用 popover,甚至就一个普通 div 配 fixed 定位,反而更合适。工具是工具,不是信仰。

