别再引 JS 弹窗库了:dialog 与 popover 属性的完整实战与避坑指南

2026-09-19 0 252

两年前接了一个后台管理项目,技术栈是原生 HTML 加一点 Alpine.js。需求里出现频率最高的组件就是“弹个框”:删除确认、导出参数、批量操作的二次确认。当时第一反应是找个成熟库,看了一圈,不是体积太大就是依赖 Vue 或者 React 的运行时,硬塞进原生页面里很别扭。

后来决定自己写。写到最后发现,模态框最难的那部分——焦点困在框里、背景不能滚动、ESC 关闭、点击遮罩关闭——全都是浏览器早就内置好的能力,我只需要把 showModal() 调对地方就行。再往后做下拉菜单的时候又遇到了 popover 属性,两个东西合起来,几乎把我之前写的 600 多行弹窗代码全部删掉了。

这篇把这两块拆开讲清楚,顺带把踩过的坑记下来。

一、dialog 元素的基础,比想象中多不少细节

最朴素的一个对话框长这样:

<dialog id="dlg">
  <p>这是一段说明文字。</p>
</dialog>

默认是隐藏的,直接打开页面看不到它。要显示,得走 JS:

const dlg = document.getElementById('dlg');
dlg.show();        // 非模态
dlg.showModal();   // 模态

showModal()show() 的差别,是我认为 dialog 元素最值钱的地方。模态模式下浏览器会自动做四件事:

  • 把元素提升到一个叫顶层(top layer)的地方,无论父级有没有 overflow: hidden、有没有 transform 造成的层叠上下文,都不会被裁剪或挡住
  • 插入 ::backdrop 伪元素,也就是那层半透明遮罩
  • 把页面其余部分的交互屏蔽掉,同时阻止背景滚动
  • 把键盘焦点圈死在对话框内部,Tab 键不会跑到后面的页面上去

第四点最容易被低估。自己用 div 搭模态框,焦点管理要写不少代码:打开时记录当前焦点、把焦点送到框里第一个可聚焦元素、监听 Tab 键做循环、关闭时把焦点还给触发按钮。这四步漏一步,键盘用户就会骂人。dialog 用了 showModal() 之后,浏览器全包了,关闭时焦点还会自动落回原来触发它的那个元素上。

非模态的 show() 不锁任何东西,就是单纯把元素显示出来,跟一个普通 div 没区别,位置也还在原来的文档流里。

二、backdrop 的样式,以及那个经典的滚动条问题

遮罩层用 ::backdrop 来控制:

dialog::backdrop {
  background: rgb(0 0 0 / 0.45);
  backdrop-filter: blur(2px);
}

这里有个真实被测试提过 bug 的地方。模态打开之后页面滚动被禁止,滚动条消失了,整个页面会向右“跳”一下,看起来像是内容抖了一下。解决思路是给根元素补上滚动条宽度的内边距:

html {
  scrollbar-gutter: stable;
}

一行搞定,比用 JS 去算 window.innerWidth - document.documentElement.clientWidth 再手动补 padding 干净太多。这个属性在 Chrome 94 之后就有了,现在可以放心用。

三、form method=”dialog”,少写一半的 JS

一个确认框,如果只是想知道用户点了“确定”还是“取消”,完全不需要自己绑 click 事件:

<dialog id="confirm-dlg">
  <form method="dialog">
    <p id="confirm-text">确定要删除这条记录吗?</p>
    <button value="cancel">取消</button>
    <button value="ok">删除</button>
  </form>
</dialog>

表单的 method 设成 dialog 之后,提交动作不会发请求,而是直接关闭所在对话框。哪个按钮触发的提交,它的 value 就会成为对话框的 returnValue。按钮默认就是 type="submit",不需要额外标注。

配套的 JS 逻辑很短:

const dlg = document.getElementById('confirm-dlg');
const textEl = document.getElementById('confirm-text');

function confirmAction(message) {
  if (dlg.open) return Promise.resolve('');

  dlg.returnValue = '';      // 清掉上一次的结果
  textEl.textContent = message;
  dlg.showModal();

  return new Promise(resolve => {
    dlg.addEventListener('close', () => resolve(dlg.returnValue), { once: true });
  });
}

// 用法
const result = await confirmAction('这条记录删掉之后无法恢复。');
if (result === 'ok') {
  // 执行删除
}

这里有三个坑,都踩过。

第一,dlg.returnValue 不会自动重置。用户第一次点了“确定”,第二次再打开时如果直接按 ESC 关闭,returnValue 仍然停留在上次的 "ok",你的代码会以为用户又确认了一遍。所以每次 showModal() 之前手动把它清空。

第二,重复调用 showModal() 会抛异常。如果对话框已经是打开状态,再调一次会得到一个 InvalidStateError。所以在函数开头加一句 if (dlg.open) return 做保护。上面代码里返回了一个空字符串来短路,实际项目中也可以直接抛错或者忽略。

第三,ESC 关闭时 returnValue 是空字符串。这一点其实是好事,只要判断 result === 'ok' 就自然不会误判。但如果你的逻辑是“只要不是取消就算确认”,那就危险了——用户按 ESC、按浏览器返回、点击遮罩,都会走到这个分支。建议反过来判断,只认白名单里的值。

四、cancel 事件:拦一下 ESC

有时候业务上不希望用户随手按 ESC 就把框关掉,比如表单里已经填了一堆内容。这时候可以监听 cancel 事件,它是可以取消的:

dlg.addEventListener('cancel', (e) => {
  if (hasUnsavedInput()) {
    e.preventDefault();   // 拦住这次关闭
    showInlineHint('内容还没保存,再按一次 ESC 才关闭');
  }
});

cancel 只在按 ESC 的时候触发,点按钮关闭不会触发它。这个区别要记住,别把它当成通用的“即将关闭”钩子。

另外,点击遮罩关闭是 dialog 元素的默认行为之外的能力,浏览器不会自动帮你做。想实现的话,在 dialog 上监听点击,然后比较点击坐标和元素矩形:

dlg.addEventListener('click', (e) => {
  if (e.target !== dlg) return;       // 点在内容上,忽略
  const r = dlg.getBoundingClientRect();
  const inside =
    e.clientX >= r.left && e.clientX <= r.right &&
    e.clientY >= r.top  && e.clientY <= r.bottom;
  if (!inside) dlg.close('backdrop');
});

之所以要判断坐标而不是简单地比较 e.target === dlg,是因为 dialog 自身有 padding,点在 padding 区域上 target 也是 dlg,不判断坐标就会误伤。

不过我对遮罩关闭这个交互一直持保留态度。如果是需要用户明确决策的场景(比如删除确认),让误触变得太容易不是好事。具体看业务,别为了“符合直觉”而牺牲安全性。

五、popover 属性,另一个方向的浮层

dialog 解决的是“打断用户、必须先响应”的场景。popover 解决的是另一类:下拉菜单、工具提示、快捷操作面板、通知条。这些东西的共同点是——用户不处理它也没关系,点别处它就该自己消失。

用法是把属性直接写在元素上:

<button popovertarget="user-menu">账号</button>

<div id="user-menu" popover>
  <a href="/profile" rel="external nofollow" >个人资料</a>
  <a href="/settings" rel="external nofollow" >设置</a>
  <button id="logout">退出登录</button>
</div>

两行 HTML,没有 JS,点击按钮菜单就出来了,点页面其他地方自动关闭。这个自动关闭的机制有个名字叫 light dismiss。

popover 属性有两个取值,不写等于 auto

  • auto:点外部关闭、按 ESC 关闭、打开新的 auto 浮层时旧的自动关闭
  • manual:谁都不管它,只能靠 JS 调 hidePopover() 关掉,适合做常驻的提示条或者 toast

触发按钮上还能指定动作,默认是 toggle:

<button popovertarget="tip" popovertargetaction="show">显示提示</button>
<button popovertarget="tip" popovertargetaction="hide">隐藏提示</button>

JS 侧对应三个方法:showPopover()hidePopover()togglePopover(),都挂在元素上。

六、popover 的默认样式,以及必须覆盖的那部分

打开 Chrome 的开发者工具看一个 popover 元素的样式,会看到浏览器塞了这么一堆:

[popover] {
  position: fixed;
  inset: 0;
  width: fit-content;
  height: fit-content;
  margin: auto;
  border: solid;
  padding: 0.25em;
  overflow: auto;
  color: CanvasText;
  background-color: Canvas;
}

所以默认情况下它出现在屏幕正中央,还带着一圈边框。做下拉菜单的第一步就是把 insetmargin 覆盖掉,否则用 top: 100% 之类的定位完全不会生效,你会盯着屏幕怀疑人生——我当年就是这样。

#user-menu {
  inset: auto;
  margin: 0;
  border: 1px solid #d0d7de;
  border-radius: 8px;
  padding: 6px;
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
}

另外,popover 显示时的状态可以用 :popover-open 伪类选中,这很适合做动画:

#user-menu {
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 0.15s, transform 0.15s, overlay 0.15s allow-discrete,
              display 0.15s allow-discrete;
}

#user-menu:popover-open {
  opacity: 1;
  transform: translateY(0);
}

@starting-style {
  #user-menu:popover-open {
    opacity: 0;
    transform: translateY(-6px);
  }
}

这里不写 display: none 的切换是没用的——因为 popover 关闭时浏览器的 UA 样式直接给了 display: none,过渡根本不会跑。要在 transition 里加 display 并带上 allow-discrete,让离散属性的变化也能参与过渡。@starting-style 则是为了给“从无到有”那一帧提供起始值,否则元素会直接从最终状态出现,看不到淡入。

这段 CSS 第一次写的时候会觉得很绕,但只要理解了“popover 关闭时是真的 display: none”,逻辑就顺了。

七、把下拉菜单放到按钮下面:anchor positioning 与兜底

现在的问题变成了:怎么让菜单精准贴在按钮下方?

CSS Anchor Positioning 就是为这个场景设计的,Chrome 125 之后可以直接用:

#user-btn {
  anchor-name: --user-btn;
}

#user-menu {
  position-anchor: --user-btn;
  top: anchor(bottom);
  left: anchor(left);
  margin-top: 6px;
  inset: auto;
}

更简洁的写法是用 position-area

#user-menu {
  position-area: bottom span-right;
  margin-top: 6px;
}

好处是它和“锚点”之间的关系是声明式的:按钮移动、页面滚动、容器布局变化,菜单都会跟着走,不需要任何 JS 重新计算。而且可以配 @position-try 做边界翻转——空间不够时自动跑到上方:

@position-try --flip-top {
  position-area: top span-right;
  margin-bottom: 6px;
}

#user-menu {
  position-area: bottom span-right;
  position-try-fallbacks: --flip-top;
}

问题在于浏览器支持还没铺开。写这篇文章的时候,Safari 和 Firefox 的稳定版还没跟进,所以必须准备降级方案。

降级思路很直接:用 getBoundingClientRect() 读出触发按钮的位置,手动设置 lefttop。因为 popover 打开时处于顶层,但它的定位参照依然是常规规则,position: fixed 用的还是视口坐标,所以可以直接把 rect 的值搬过去。

const supportsAnchor = CSS.supports('position-anchor', '--x');

function placeMenu(trigger, menu) {
  if (supportsAnchor) return;   // 支持的话交给 CSS

  const r = trigger.getBoundingClientRect();

  menu.style.inset = 'auto';
  menu.style.margin = '0';
  menu.style.position = 'fixed';
  menu.style.left = r.left + 'px';
  menu.style.top = (r.bottom + 6) + 'px';
}

const btn = document.getElementById('user-btn');
const menu = document.getElementById('user-menu');

btn.addEventListener('click', () => {
  // popovertarget 是浏览器自己处理的,这里只在点击后做位置兜底
  requestAnimationFrame(() => placeMenu(btn, menu));
});

window.addEventListener('resize', () => {
  if (menu.matches(':popover-open')) placeMenu(btn, menu);
});

requestAnimationFrame 包一层是因为 popover 打开时先要进入顶层,尺寸和位置才算得准。滚动的情况更麻烦一些,简单项目里可以用 position: absolute 换成相对定位,或者干脆监听滚动事件重新算——反正不支持 anchor positioning 的浏览器本来就要多写点代码,这是过渡期的代价。

八、一个容易忽略的无障碍问题

popovertarget 这种声明式写法很方便,但浏览器不会自动给触发按钮加上 aria-expanded。屏幕阅读器用户按到那个按钮时,听不到“展开/折叠”的状态提示。

补救办法是用 toggle 事件同步状态:

menu.addEventListener('toggle', (e) => {
  btn.setAttribute('aria-expanded', e.newState === 'open' ? 'true' : 'false');
});

toggle 事件的 newState"open""closed",不管是点击外部关闭还是 ESC 关闭都会触发,比自己在 click 里猜状态可靠得多。

如果不想写 JS,还有一个纯 CSS 的取巧写法,前提是按钮和菜单是相邻兄弟节点:

#user-btn:has(+ #user-menu:popover-open) {
  /* 打开状态下的按钮样式 */
}

这只能改视觉,改不了 aria 属性,所以正式项目还是建议加上那几行 JS。

九、什么时候用 dialog,什么时候用 popover

判断标准其实只有一条:用户不处理,流程能不能继续?

不能继续,用 dialog 加 showModal()。删除确认、登录、支付密码输入都属于这一类。背景被锁、焦点被圈、必须先给出回答,这些都是应该的。

能继续,用 popover。下拉菜单、筛选面板、颜色选择器、一排快捷按钮,都属于这一类。用户点开看一眼,不想要就点别处,整个流程不被打断。

还有两个边界情况值得留意。

一是提示类的东西,比如复制成功后的 toast。用 popover 的话记得加 manual,不然它会被其他 auto 浮层挤掉,也会在用户点别处时莫名其妙消失。然后自己用 setTimeout 控制显示时长。

二是嵌套。在 popover 里再开一个 popover,浏览器是支持的,会形成一层栈,后开的压在前面,ESC 时按顺序一层层关。但 dialog 里再开 dialog 体验会糟,模态叠模态用户很容易迷路,尽量在交互设计阶段就避免。

十、踩坑清单

  • showModal() 之前先判断 dlg.open,否则重复调用会抛 InvalidStateError
  • 每次打开确认框都要重置 returnValue,它不会自动清空
  • ESC 关闭时 returnValue 是空字符串,判断结果要认白名单,别写 if (value !== 'cancel')
  • 点遮罩关闭需要自己实现,并且要判断点击坐标,不能只看 e.target
  • 模态打开时页面会跳一下,html { scrollbar-gutter: stable } 能解决
  • popover 的 UA 样式里有 inset: 0; margin: auto;,不覆盖掉就没法自定义定位
  • 想让 popover 有进出场动画,transition 里必须带上 display ... allow-discrete,配合 @starting-style
  • popovertarget 不会自动同步 aria-expanded,需要监听 toggle 事件手动加上
  • 需要长时间停留的提示用 popover="manual",否则会被 light dismiss 干掉
  • anchor positioning 要检测支持情况,CSS.supports('position-anchor', '--x') 是最省事的判断方式

最后说一句感受。这两个 API 让我把项目里的弹窗代码从 600 多行压到了 200 行出头,删掉的几乎全是焦点管理、遮罩点击、ESC 响应这些和业务毫无关系的胶水代码。代价是浏览器的版本要求提高了——dialog 在 Chrome 37 就有了,但 popover 属性要 Chrome 114 起,anchor positioning 更晚。如果是面向国内用户、且不需要兼容老旧安卓浏览器的项目,现在就可以上;如果必须照顾旧设备,那就把 dialog 先用起来,popover 部分做个特性检测走降级路径,两边都不耽误。

别再引 JS 弹窗库了:dialog 与 popover 属性的完整实战与避坑指南
收藏 (0) 打赏

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

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

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

淘吗网 html 别再引 JS 弹窗库了:dialog 与 popover 属性的完整实战与避坑指南 https://www.taomawang.com/web/html/2784.html

常见问题

相关文章

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

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