HTML 原生弹层实战:用 popover 和 dialog 干掉最后一行 JS,我踩了四个坑

2026-09-29 0 683

上个月重构一个后台系统。这个系统有将近两百个页面,每个页面都有下拉菜单、确认弹窗、轻提示。这些弹层以前是用一个 jQuery 时代留下来的 UI 组件库做的,代码量大不大先不说,最要命的是无障碍支持很糟糕,键盘 Tab 进去焦点乱飞,屏幕阅读器基本读不出什么有用的东西。

重构开始的时候我列了一个清单:交互能不用 JS 就不用 JS,能用原生 HTML 的元素就不自己造。这两条听起来有点理想主义,但项目真的动起来才发现,现在的浏览器给的能力比我印象里多太多了。

这篇把整个改造过程记下来,包括那些花了时间才想明白的问题。代码尽量贴实用的,不搞玩具示例。

先盘点一下原生到底能做什么

过去大家习惯用 JS 做弹层,是因为 HTML 在很长一段时间里确实只提供了 alert、confirm、prompt 这三个没法样式化的系统对话框。后来 dialog 元素出现了,再后来有了 popover 属性,加上最近出的 command 和 commandfor 属性,一套原生的弹层体系基本齐了。

简单划分一下各自适用什么场景:

  • dialog:模态对话框,需要用户”处理完再继续”,带自动聚焦、Esc 关闭、焦点陷阱。
  • popover:非模态浮层,点击外部自动关闭、自带 light-dismiss 行为,适合下拉菜单、气泡卡片、工具栏。
  • commandfor + command:让按钮直接控制 dialog 或者 popover 的显示和隐藏,不用再写 onclick="..."。

项目里最常出现的三种弹层——下拉菜单、确认框、轻提示——前两种可以用原生完全覆盖,轻提示因为需要自动消失,还需要一点点 JS。

下拉菜单:popover 就能搞定

最典型的下拉菜单长这样。以前需要写一堆 JS 处理点击外部关闭、定位、z-index,现在全部能用声明式写出来:

<button popovertarget="user-menu" class="avatar-btn">
  张三
</button>

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

popovertarget 指向目标元素的 id,点击按钮浏览器自动处理显隐。再配合 popovertargetaction="show" 或者 "hide" 可以指定具体动作,不写默认是 toggle。

样式方面,popover 元素默认是 display: none,显示时是 display: block,还会有一个 UA 提供的默认样式(背景白、边框、内边距)。项目里一般都要覆盖掉:

#user-menu {
  padding: 8px;
  border: 1px solid #e3e7ef;
  border-radius: 10px;
  box-shadow: 0 12px 24px rgb(15 23 42 / 0.12);
  min-width: 160px;
  inset: unset;
  top: anchor(bottom);
  left: anchor(left);
  margin-top: 6px;
}

/* 让箭头指向按钮 */
#user-menu::backdrop {
  background: transparent;
}

上面 anchor() 是 CSS 锚点定位,前面写过一篇文章专门讲,这里不展开。它能让浮层自动吸附到按钮上,还带翻转逻辑。

确认框:dialog 拿出来用

确认框最典型的场景是”删除这条记录”。以前要么用 window.confirm(丑且没法改样式),要么用第三方库(重)。现在 dialog 直接搞定:

<dialog id="delete-confirm">
  <form method="dialog">
    <h2>确认删除?</h2>
    <p>删除后无法恢复。</p>
    <menu>
      <button value="cancel">取消</button>
      <button value="confirm">删除</button>
    </menu>
  </form>
</dialog>

打开和关闭这里有个很意思的写法。以前要在按钮上绑定 onclick="document.getElementById('delete-confirm').showModal()",现在可以直接用命令属性:

<button commandfor="delete-confirm" command="show-modal">
  删除
</button>

command="show-modal" 走的是 showModal() 语义,会开一个真正的模态框,有焦点陷阱、Esc 关闭、和 backdrop 遮罩。command="show" 走的则是非模态的 show() 语义。另外还有 "close" 和 "request-close",分别对应直接关闭和”请求关闭”(会先触发 cancel 事件,可以拦截)。

提到 method="dialog" 表单,它是 dialog 用得最舒服的一个技巧。表单里任何按钮点了都会关闭对话框,按钮的 value 会写进 dialog.returnValue。也就是说,点了”删除”之后我们可以直接读返回值:

const dlg = document.getElementById('delete-confirm');
dlg.addEventListener('close', () => {
  if (dlg.returnValue === 'confirm') {
    doDelete();
  }
});

这个模式下,连”取消按钮要不要专门写一个 event listener”都省掉了。表单提交一次,事件走一次,返回值读一下,逻辑就完了。

四个花时间才想明白的坑

上面这些看起来都挺顺,但真写起来没那么简单。下面这四个坑我都踩过,按踩坑的时间顺序来。

坑一:popover 默认的 inset 和 margin 会打架

popover 元素在 UA 样式表里有这么一条:inset: 0 加 margin: auto,目的是让弹层默认居中显示。如果你要用 anchor() 定位,或者自己写 top/left,必须先重置掉这两个属性:

[popover] {
  inset: unset;
  margin: 0;
}

我第一次不知道这件事,写了一下午的定位样式怎么都不生效,还在怀疑是不是浏览器不支持 anchor(),最后打开 DevTools 看计算样式才发现是 UA 默认值在作祟。

坑二:popover 里的表单提交会立刻关闭浮层

下拉菜单里放搜索框是很常见的需求,输入关键词之后点搜索按钮,然后发现整个菜单”啪”一下就关了。

原因是 popover 里的 form 只要提交(或者用 <button type="submit">),浏览器就认为这次交互完成了,会触发轻关闭(light-dismiss)。解决方式是把按钮改成 type="button",然后在 JS 里手动处理:

<form id="search-form">
  <input name="q" type="search">
  <button type="button" id="search-btn">搜索</button>
</form>

或者干脆把表单提交的默认行为拦掉:

popoverEl.querySelector('form').addEventListener('submit', (e) => {
  e.preventDefault();
  // 处理搜索
});

我最后选了后者,因为不希望按钮还要区分 type,这样写起来更稳。

坑三:dialog 的嵌套滚动会串到 body

项目里有个页面,主内容可以滚动,点击按钮弹出一个 dialog,dialog 里的列表也能滚动。问题是:当 dialog 里的列表已经滚到底了,继续滚鼠标滚轮,后面页面的内容会跟着滚动,体验很割裂。

严格来说这不是 dialog 的问题,所有滚动容器的嵌套都有这个问题。但 dialog 因为是模态的,用户感知更明显——他以为后面那个页面已经”锁住”了。

原生方案没有一行 CSS 能解决。最后加了一段小逻辑:

document.querySelectorAll('dialog').forEach(dlg => {
  dlg.addEventListener('wheel', (e) => {
    const target = e.target.closest('[data-scrollable]');
    if (!target) {
      e.preventDefault();
      return;
    }
    const atTop = target.scrollTop === 0;
    const atBottom =
      target.scrollTop + target.clientHeight >= target.scrollHeight - 1;
    if ((atTop && e.deltaY < 0) || (atBottom && e.deltaY > 0)) {
      e.preventDefault();
    }
  }, { passive: false });
});

关键是 passive: false,不然 preventDefault 不生效。这段代码一共十几行,比想象中小,加一次全局监听就能覆盖所有 dialog,我觉得比在每个弹窗里改 CSS 靠谱。

坑四:焦点管理比预想的复杂

dialog 元素确实自带焦点陷阱——Tab 键不会跳出到页面其他部分。但是它不会自动把焦点放到第一个可聚焦元素上,除非你显式设置。

按规范,showModal() 的时候,浏览器会按下面的顺序找首个聚焦目标:[autofocus] 属性、然后是 dialog 内的第一个可聚焦元素。听起来会自动,但实际项目里经常因为按钮是动态渲染的、或者用了 disabled 属性而失效。

所以我在打开 dialog 之前,会显式把焦点放进去:

const dlg = document.getElementById('delete-confirm');
dlg.addEventListener('show', () => {
  const firstInput = dlg.querySelector('input, button');
  if (firstInput) firstInput.focus();
});

另外提醒一点:dialog 关闭之后,焦点不会自动回到触发它的按钮上。用户按了”删除”,弹窗关闭,Tab 一下,焦点跑到了页面顶部——这是个很影响键盘用户体验的细节,但很容易被忽略。

处理方式很土但是很稳:打开之前记住触发按钮,关闭之后把焦点还回去。

let lastTrigger = null;

document.querySelectorAll('[commandfor]').forEach(btn => {
  btn.addEventListener('click', () => {
    lastTrigger = btn;
  });
});

document.querySelectorAll('dialog').forEach(dlg => {
  dlg.addEventListener('close', () => {
    if (lastTrigger) lastTrigger.focus();
  });
});

什么场景不要用原生方案

改造过程中我也收住了手,有几类场景最终还是保留了原来的组件方案。

第一个是 toast。 popover 的语义是”用户主动触发”,toast 是”系统主动通知”,语义上不合适。而且 toast 需要自动消失、可能需要堆叠显示,这些原生不提供。用 <output> 元素加一点 JS 是更正统的写法。

第二个是需要精确控制的 tooltip。 popover 可以做 tooltip,但它的关闭逻辑是 light-dismiss(点击外部关闭),而 tooltip 一般是 hover 或者 focus 显示,鼠标移开就关。这个行为和 popover 的默认行为不太一致,不如老老实实用 CSS 的 ::before 或者一个轻量的 JS 库。

第三个是移动端的抽屉。 从屏幕边缘滑出的抽屉虽然也可以理解为一种”非模态浮层”,但它的滑入滑出动画、边缘手势、以及和浏览器后退按钮的联动,都不在 popover 的能力范围内。这些东西需要自己处理的时候,原生方案反而变成了束缚。

判断标准可以总结成这样:行为能被 popover 或者 dialog 的语义完整覆盖,就用原生;一旦要”打破”这些默认行为,就换成自定义方案。 别为了图省事硬套,default 行为的限制往往会在意料之外的地方冒出来。

浏览器兼容和降级思路

截至写这篇文章,popover 和 dialog 在主流浏览器的近两个大版本里都已经可用。commandfor 和 command 相对新一些,Chrome 从 135 开始支持,Safari 和 Firefox 也在陆续跟进。所以在老浏览器上还是要有兜底。

最省事的兜底方式是用特性检测:

if (!HTMLElement.prototype.hasOwnProperty('popover')) {
  // 加载一份极简的 JS polyfill
  // 比如 github 上的一些 popover polyfill 项目
}

但如果不想引入 polyfill,也可以做一个非常轻量的降级:

document.addEventListener('click', (e) => {
  const btn = e.target.closest('[popovertarget]');
  if (!btn) return;
  if (HTMLElement.prototype.hasOwnProperty('popover')) return;

  const target = document.getElementById(btn.getAttribute('popovertarget'));
  if (target) {
    target.hidden = !target.hidden;
  }
});

配合 CSS 里把 [popover] 的默认 display 处理一下,就能在老浏览器上跑出一个功能能用、只是少了点细节的版本。这种降级策略在内部系统里完全够了。

改造完的收益

整个后台系统改下来,最大的收益其实不是”代码量减少”。

首先是无障碍支持从”能用”变成”正确”。dialog 自带 role="dialog" 和 aria-modal="true",屏幕阅读器会正确地宣布”对话框”、”模态”。以前用 div 手搓的弹窗,这些都要一个个手动加,还经常漏。改成原生之后,一遍扫描下来没有严重问题。

其次是行为一致性。以前每个页面下拉菜单的关闭逻辑稍微有点不同——有的点外部关,有的按 Esc 关,有的点里面的链接不关,各个组件维护者风格不一样,用的人经常抱怨”这个能关那个不能关”。现在全都走 popover 的默认行为,点外部关、Esc 关,谁都一样,用户预期稳定了,也就少了很多”这个组件是不是坏了”的疑问。

最后是代码审查的负担变轻了。以前一个下拉菜单的 PR,光看 JS 部分就要花不少时间,要确认事件监听有没有正确移除、z-index 有没有冲突、点击外部判断有没有用 contains 正确区分。改成声明式之后,PR 里少了一大截 JS,剩下的 CSS 和 HTML 一眼能看完,审查速度快了很多。

最后说两句

这几年 HTML 平台加的特性越来越像”组件库”了。dialog、popover、details 这些元素背后,其实是浏览器在把一些被反复实现的东西标准化。以前每一个前端项目都要自己写一遍开关逻辑,用过十来个库,其实本质上都在做同一件事。

从性能、可访问性、长期可维护性上看,用原生元素的好处是显而易见的。唯一的代价是要学的东西变多了——不只是学会 API,还要理解它的默认行为、边界在哪里、什么时候该用它、什么时候不该用。

如果你手头也有一个在用第三方弹层库维护了挺久的老项目,可以挑一个使用场景最标准的页面先试一下。不用一次改完,改一个下拉菜单、再改一个确认框,感受一下原生的感受。真香了自然会继续。

HTML 原生弹层实战:用 popover 和 dialog 干掉最后一行 JS,我踩了四个坑
收藏 (0) 打赏

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

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

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

淘吗网 html HTML 原生弹层实战:用 popover 和 dialog 干掉最后一行 JS,我踩了四个坑 https://www.taomawang.com/web/html/2838.html

常见问题

相关文章

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

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