用原生 dialog 和 popover 替掉 div 弹窗:一份带踩坑的迁移实录

2026-10-17 0 704

我们项目里有四十多个弹窗,全是 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 定位,反而更合适。工具是工具,不是信仰。

用原生 dialog 和 popover 替掉 div 弹窗:一份带踩坑的迁移实录
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 html 用原生 dialog 和 popover 替掉 div 弹窗:一份带踩坑的迁移实录 https://www.taomawang.com/web/html/2952.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务