原生弹层实战:用 popover 和 dialog 替掉 38KB 的 UI 弹窗库

2026-09-11 0 719

后台项目里挂着一个挺有名的组件库,但真正在用的只有三个东西:Modal、Tooltip、Dropdown。打包分析跑出来,这三个组件加上它们拖进来的定位引擎,一共占了 38KB(min 之后 gzip)。

更烦的是每次升级这个库都要重新验一遍弹层逻辑。它的 Modal 在弹窗里再开弹窗时会算错 z-index,Tooltip 在表格里滚动时偶尔漂到屏幕外面去。改又不好改,因为都是压缩过的产物。

所以我把它们全部换成了浏览器自带的方案。整个过程踩了不少坑,这篇写下来。

先别急着动手:popover 和 dialog 不是二选一

很多人第一次接触 Popover API,会以为它是用来替代所有弹层的。不是。这两个东西解决的是两类不同的问题,搞混了会写出很难维护的代码。

判断标准只有一条:这个浮层出现的时候,用户还能不能操作页面上的其他东西。

不能操作,就是模态。用 <dialog>,调 showModal() 打开。浏览器会自动把页面其余部分设置为 inert,Tab 键不会跑到背景里去,屏幕阅读器也读不到。

能操作,就是非模态。用 popover 属性。用户点开下拉菜单的同时,理论上还可以点页面上的链接。

我们的项目里,确认删除框属于前者,导航下拉和提示气泡属于后者。分完之后思路就清楚了。

popover 有三种模式,默认那个不一定是你想要的

popover 属性可以写三个值,行为差别不小:

<div popover>...</div>            <!-- 等价于 popover="auto" -->
<div popover="manual">...</div>
<div popover="hint">...</div>

auto 是默认值。它的特点:点击弹层外面的任何地方会关闭,按 Esc 会关闭,而且同一时间只会有一个 auto 弹层处于打开状态——打开第二个会自动把第一个关掉。菜单、下拉框基本都是这个。

manual 关掉了上面所有自动行为。不会点外面关闭,不会按 Esc 关闭,也不会互相顶掉。适合那种需要一直挂着的浮层,比如全局的操作引导,或者正在拖拽中的面板。用这个模式意味着关闭逻辑必须自己写。

hint 是专门为 tooltip 这类提示设计的。它的特殊之处在于:它不会关闭已经打开的 auto 弹层,而且如果同一时间打开多个 hint,其他的 hint 会自动关掉。这个语义正好对应「鼠标划过看提示」的场景。

我们有个搜索框,聚焦的时候会弹出一个历史记录面板。一开始我给它写了 hint,结果发现点页面别处它不关。因为 hint 依然有轻触关闭行为,但和 auto 的处理不同,实际的体验差异让人挺意外。后来改回了 auto

实战一:确认删除框改成 dialog + commandfor

原来的写法是调用组件库的 Modal.confirm(),返回一个 Promise。改完之后是这样的:

<dialog id="confirm-delete" closedby="any" aria-labelledby="cd-title">
  <h2 id="cd-title">确认删除该项目?</h2>
  <p>项目下的 12 个环境变量和 3 条部署记录会一并清空,且无法恢复。</p>
  <form method="dialog">
    <button value="cancel">取消</button>
    <button value="confirm">删除</button>
  </form>
</dialog>

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

注意 commandforcommand 这两个属性。commandfor 指向要操作的元素的 id,command="show-modal" 表示以模态方式打开这个 dialog。整段代码没有一行 JavaScript。

配套的 JS 只需要监听关闭事件:

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

dlg.addEventListener('close', () => {
  if (dlg.returnValue === 'confirm') {
    deleteProject(currentId);
  }
});

为什么 returnValue 能拿到 confirm?因为 <form method="dialog"> 里的按钮被点击时,会把按钮的 value 写进 dialog 的 returnValue,然后自动关闭。这是 dialog 元素从设计之初就带着的能力,很多人不知道。

closedby="any" 是后来加上的。它的作用是允许点击 dialog 外部区域关闭。默认值是 closerequest,也就是只能通过 Esc 或者代码关闭。还有 closedby="none",连 Esc 都不管用,用在那种必须做出选择的场景。

顺带一提,command 除了 show-modal,还支持 closerequest-closeshow-popoverhide-popovertoggle-popover。后面三个是给 popover 用的。

实战二:导航下拉用 popover 配锚点定位

HTML 部分非常短:

<button id="nav-trigger" popovertarget="nav-menu">更多</button>

<div id="nav-menu" popover>
  <a href="/settings" rel="external nofollow" >项目设置</a>
  <a href="/members" rel="external nofollow" >成员管理</a>
  <a href="/audit" rel="external nofollow" >操作日志</a>
</div>

popovertarget 会自动处理点击切换,也会自动维护按钮上的 aria-expanded

麻烦在定位。popover 默认会被放在视口正中央——因为它在 UA 样式表里带的是 position: fixed 加上自动外边距居中。第一次看到菜单从屏幕中间冒出来,我还以为写错了什么。

解决办法是 CSS 锚点定位:

#nav-trigger {
  anchor-name: --nav-trigger;
}

#nav-menu {
  position-anchor: --nav-trigger;
  position-area: bottom left;
  margin-top: 6px;
}

anchor-name 给触发按钮起一个锚点名字,position-anchor 让 popover 认这个锚点,position-area 把它放到锚点的正下方靠左。浏览器会自己处理翻转——如果下方空间不够,自动挪到上方;如果右边超出视口,自动往左收。

这比之前用定位引擎算坐标可靠多了。定位引擎需要监听 resize 和 scroll,还得处理容器裁剪,Popover 的定位是浏览器合成层里做的,滚动时不抖。

实战三:hint 模式做悬浮提示

<button popovertarget="tip-retention" class="tip-trigger">数据留存</button>

<div id="tip-retention" popover="hint">
  免费版保留 7 天,付费版 90 天,企业版可配置。
</div>

这里用 hint 而不是 auto,原因是页面上可能存在已打开的下拉菜单。如果用 auto,用户鼠标划过「数据留存」这四个字的时候,下拉菜单会被直接顶掉,体验很割裂。

还有一点值得说:hint 模式下的 popover 会带一个隐式的 role="tooltip"。如果里面只有纯文本,这个角色是对的。但如果提示内容里有可点击的链接,就不该用 hint,得改用 auto 并手动指定 role。

焦点、滚动穿透和 inert

这是替换过程中花时间最多的部分。

<dialog>showModal() 打开时,浏览器会自动做三件事:页面其余内容 inert、焦点移入 dialog、Tab 循环限制在 dialog 内部。这三条都是免费的,不用写一行代码。

popover 不做这些。popover 打开后,背景内容依然可以点击。如果下拉菜单里有一个输入框,用户 Tab 出去之后焦点会跑到背景页面的链接上,看起来像 bug。

我们的处理方式是:下拉菜单本身不需要模态行为,保持现状。但那个搜索历史面板需要,所以改成用 <dialog>show()(非模态),然后手动在面板外面加一个遮罩。看起来绕,但这是当下的正确做法。

另外提一下滚动条。<dialog> 模态打开时,背景页面的滚动会被浏览器自动锁住,不需要再手动往 body 上打 overflow: hidden。这一条在移动端上尤其省事,之前用组件库的时候,iOS 上总会出现背景跟着弹窗一起滚动的问题。

动画里的那个坑

给 popover 加淡入淡出动画,第一版写完发现只有淡入有,淡出是瞬间消失。原因是 popover 关闭时会被设成 display: none,而 display 是不可动画的属性,过渡会直接被跳过。

需要显式允许离散属性过渡,并配合 @starting-style 处理入场:

#nav-menu {
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity 160ms ease, transform 160ms ease,
              display 160ms allow-discrete, overlay 160ms allow-discrete;
}

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

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

:popover-open 是 popover 打开时的伪类。加上 allow-discrete 之后,display 的切换才会被纳入过渡流程,退场动画才能跑完再隐藏。

兼容性与降级

截止目前,popover<dialog> 在主流浏览器上都没问题,包括移动端 Safari。真正需要留意的只有两处:

commandforcommand 这对属性目前只在 Chromium 内核上有,Firefox 和 Safari 还认不出来。所以不能只靠它,得加一段兜底:

if (!('commandForElement' in HTMLButtonElement.prototype)) {
  document.querySelectorAll('[commandfor]').forEach(btn => {
    const target = document.getElementById(btn.getAttribute('commandfor'));
    const cmd = btn.getAttribute('command');

    btn.addEventListener('click', () => {
      if (cmd === 'show-modal') target.showModal();
      else if (cmd === 'close') target.close();
      else if (cmd === 'show-popover') target.showPopover();
      else if (cmd === 'hide-popover') target.hidePopover();
      else if (cmd === 'toggle-popover') target.togglePopover();
    });
  });
}

这段完全不影响支持的浏览器,因为在支持的浏览器里它压根不执行,属性会自己处理。

另一处是 CSS 锚点定位。Safari 已经支持了基础部分,但 position-area 的某些取值组合还没跟上。我们的做法是给 popover 加一个基础定位:

#nav-menu {
  inset: auto;
  top: calc(anchor(bottom) + 6px);
  left: anchor(left);
}

如果锚点定位整体不可用,popover 会退回默认的居中显示——不好看,但功能完整,不会出现「菜单跑到屏幕外面」这种坏掉的情况。

那些真正花时间的坑

顶层渲染不受父元素裁剪。之前有人用 overflow: hidden 的容器包住表格,想让弹层被裁掉作为「意外保护」。换成 popover 之后,它被提升到 top layer,完全不受影响。这是好事——说明以前组件库那些裁剪问题不会再出现了。

dialog 里的表单提交。如果 dialog 内部有一个 method="post" 的表单,按回车会正常提交并跳转页面,dialog 不会自动关闭。要么用 method="dialog",要么在 submit 事件里手动 preventDefault

嵌套 popover。在 auto popover 里再开一个 auto popover,外层会被自动关掉。如果确实需要嵌套(比如下拉菜单里再展开二级),内层要用 manual

回车键的默认行为。页面上如果只有一个按钮,在表单里按回车会触发它。我们那个「删除项目」按钮一度变成了默认提交按钮,用户填完搜索框按回车就弹出了删除确认。后来把按钮挪出了表单才解决。

::backdrop 的样式。dialog 的背景遮罩是 ::backdrop,但给 popover 加是没用的,因为 popover 不在顶层模态栈里。

最后的结果

38KB 的依赖被彻底移掉了。取而代之的是几十行 HTML、大约 60 行 CSS,加上一小段只在旧浏览器上跑的降级代码。

更重要的是那些以前需要跟组件库较劲的地方——z-index 层级、定位漂移、滚动锁定——现在都由浏览器负责了。这些逻辑写在渲染引擎里,比写在业务仓库里靠谱得多。

如果你手上也有类似的项目,我的建议是先数一下到底用了那个库的多少个组件。如果不超过五个,值得花两天试试这条路。超过二十个,还是留着吧,改造的收益跟成本不成正比。

原生弹层实战:用 popover 和 dialog 替掉 38KB 的 UI 弹窗库
收藏 (0) 打赏

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

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

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

淘吗网 html 原生弹层实战:用 popover 和 dialog 替掉 38KB 的 UI 弹窗库 https://www.taomawang.com/web/html/2742.html

常见问题

相关文章

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

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