原生 Popover API 实战:不引入 UI 库也能做出体面的弹层菜单

2026-10-04 0 435

上个月重构一个后台工具条,需求说起来很朴素:一个「更多操作」按钮,点开是一列菜单项,点外面自动收起,按 ESC 也能收,菜单里再点东西不关。

按老习惯,这种活要么上某个 UI 库的 Dropdown 组件,要么自己写一套 —— 监听 document 的 click、判断 e.target 是不是在容器里、维护一个 isOpen 状态、处理 z-index、处理焦点、处理滚动穿透。代码不多,但脏,而且每个项目都要重写一遍,细节还总漏。

这次我直接用了 HTML 原生的 Popover。写完之后,整个下拉菜单的 JS 是零行。不是「用了几行」,是真的零行。这篇文章就把这个重构过程完整写一遍,包括过程中撞到的那些坑。

先说清楚 popover 是什么

Popover API 的核心就两件事:一个全局属性 popover,一个全局方法 showPopover() / hidePopover(),再加一个 togglePopover()。

一个带有 popover 属性的元素,默认是不渲染的。它不是 display: none 意义上的不渲染 —— 它压根就不在顶层渲染树里,直到你显式把它「弹出来」。

弹出来之后,它会自动进入浏览器的顶层(top layer)。这个「顶层」跟 z-index: 9999 完全是两回事:它是独立于文档流之外的渲染层,任何元素的 z-index 都盖不住它,也不受父元素 overflow: hidden 影响。长年被定位上下文坑过的人看到这里应该会笑出来。

最妙的一点是,它自带三大行为:点击外部关闭、按 ESC 关闭、打开时自动管理和焦点相关的交互。这些行为过去都要自己写,现在浏览器给了。

最小的能跑的例子

先看一眼最朴素的形态,不用任何 JS:

<button popovertarget="demo-tip">看提示</button>

<div id="demo-tip" popover>
  这是一段提示信息。
</div>

就这么点东西。popovertarget 的值指向目标元素的 id,按钮就变成了开关;再点一次,关闭。点页面其他任何地方,也关闭。ESC,还是关闭。

浏览器原生支持从 2024 年开始就比较完整了 —— Chrome 114+、Safari 17+、Firefox 125+ 都已经覆盖。写这篇文章的时候,2025 年的移动端基本不用考虑兼容问题了。后面我会讲低版本浏览器的处理方式。

popover 属性的三种取值

很多人第一次用会以为 popover 是个布尔属性,写了就行。其实它是有取值的,取了空值就等价于 popover="auto"。

取值 含义 典型场景
auto(默认) 打开时会自动关闭其他同类型的 popover;点击外部、按 ESC 都会关闭 下拉菜单、气泡提示、工具栏弹出面板
manual 不自动关闭任何 popover;点击外部和 ESC 也不关闭,只能通过调用方法或 popovertarget 关闭 需要保持打开的侧边浮层、Toast、引导步骤
hint 比较新的值,和其他 auto 不互斥,用作辅助提示 悬停提示、短时提示气泡

我的实践经验:95% 的场景用 auto。唯一需要 manual 的地方,是你要在弹层里再叠一层弹层,而内层关闭时不想把外层也带走 —— 但即便这个场景,通常也有别的方式解决,下面会讲。

hint 这个值比较新,支持面窄一些,做工具提示可以考虑,但别当主力。

真实案例:工具栏的下拉菜单

回到我那个重构。目标是一个类似富文本编辑器工具栏的结构,点击「更多」按钮弹出菜单,菜单项点了就执行动作并关闭。

结构大致长这样:

<div class="toolbar">
  <button data-action="bold">加粗</button>
  <button data-action="italic">斜体</button>

  <button popovertarget="more-menu">更多</button>

  <div id="more-menu" popover>
    <button data-action="underline">下划线</button>
    <button data-action="strike">删除线</button>
    <button data-action="code">行内代码</button>
  </div>
</div>

菜单能弹了,能关了。但有个问题:点击菜单项之后,菜单不会自动关。因为 popover 的「自动关闭」只认点击外部和 ESC,点它自己内部的东西不会触发关闭。

所以这里需要一丁点 JS:

const menu = document.getElementById('more-menu')

menu.addEventListener('click', (event) => {
  const target = event.target.closest('button[data-action]')
  if (!target) return

  executeAction(target.dataset.action)
  menu.hidePopover()
})

function executeAction(action) {
  // 具体的业务动作
  console.log('执行:', action)
}

六行。这已经是整个下拉菜单的全部 JS 了。对比一下过去要写的那些 document 级监听、e.target 判断、定时器防风、焦点恢复 —— 是数量级的差别。

退场动画怎么做

这是几乎所有第一次用 popover 的人都会卡住的地方。你想给弹层加个淡出动画,写好了 keyframes,发现出场有效,退场一点动画都看不到,元素「啪」地就没了。

原因在于,popover 关闭时,元素会立刻从顶层移除,display: none 生效,动画没有机会播放。

解决方法有三种,从简单到复杂排列。

方法一:用 @starting-style + transition。这是目前最干净的方案,专门为这种「元素从无到有」的场景设计的。

#more-menu {
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 200ms ease, transform 200ms ease,
              display 200ms allow-discrete,
              overlay 200ms allow-discrete;
}

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

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

关键在 display 200ms allow-discrete 和 overlay 200ms allow-discrete。allow-discrete 这个关键字的作用是,让 display 和 overlay 这两个本身离散的属性也能参与过渡。没有它,动画出不来。

方法二:手动延迟隐藏。如果你不想用新特性,可以在 JS 里先加一个 class,等过渡结束再调 hidePopover()。缺点是要监听 transitionend,而且和原生关闭路径(比如点击外部)不好统一。

方法三:用 CSS 的 ::backdrop。这个仅对 dialog 有效,popover 的 ::backdrop 行为不太一致,不建议走这条路。

我最终选了方法一。@starting-style 目前在 Chrome 117+、Safari 17.5+、Firefox 129+ 都已经支持,2025 年写这个东西不算激进。

popover 和 dialog 到底怎么选

这两个东西经常被放在一起讲,但它们在语义和用途上差别很大,用错了会很难受。

popover 是无模态的。它没有焦点陷阱,不锁背景滚动,用户能一边看着弹层一边操作后面的内容。适合菜单、提示、快捷面板。

dialog 有两种形态。调 show() 时是无模态的,调 showModal() 时是模态的 —— 焦点被锁在对话框里,背景变暗,ESC 也会关闭。适合表单、确认框、需要用户专注处理的场景。

还有一个很实用的区别:dialog 支持 form method="dialog",表单提交时对话框自动关闭,并且可以把提交按钮的 value 作为返回值。这在做「确认 / 取消」这种两选一的对话框时非常顺手。

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

JS 里监听 close 事件,读一下 dialog.returnValue 就能知道用户点了哪个按钮。

const box = document.getElementById('confirm-box')

box.addEventListener('close', () => {
  if (box.returnValue === 'ok') {
    deleteRecord()
  }
})

// 打开
box.showModal()

这套组合拳,比我用过的任何一个 UI 库的确认框都干净。而且没有任何 DOM 拼装,全在 HTML 里。

嵌套弹层怎么办

我遇到过一个场景:下拉菜单里有一项叫「设置样式」,点了之后要再弹出一个面板选颜色。菜单本身是个 popover,颜色面板也是。默认情况下,内层弹出时,外层的 auto popover 会被自动关掉,这是浏览器的设计 —— 同一时刻只允许一个 auto popover 打开。

有几种处理方式。

方案一:让内层用 popover="manual"。这样它不会触发外层关闭。代价是内层也不再自动关闭,点击外部和 ESC 都不会收起,需要自己监听。

方案二:把颜色选择直接嵌在外层菜单里。这种最常见。菜单项展开成子列表,不用另开一个 popover,问题自然消失。

方案三:外层也用 manual,全局用 JS 统一管理。当你有三级以上弹层需求时,基本就得走这条路了。但说实话,三级以上的弹层交互本身就不该做,用户体验一团糟。

我用的是方案二。菜单项点开就地在下方展开,一个 popover 搞定。

造型和定位

popover 弹出时,浏览器默认把它放在触发元素的中间位置,并且水平垂直居中。这显然不是我们想要的,需要自己调。

传统做法是用 CSS Anchor Positioning,但这个特性目前跨浏览器支持还不太行。我用的是另一个更稳的做法 —— 用 position: fixed 加定位属性,配合 popovertarget 触发源的相对位置在 JS 里算一下。或者干脆手工写一个绝对定位。

#more-menu {
  position: fixed;
  inset: auto;
  margin: 0;
  /* 由 JS 设置 top / left 或者用 anchor 定位 */
}

#more-menu:popover-open {
  display: block;
}

更省事的方式是利用 popover 默认就有的样式重置。现代浏览器给 popover 的默认样式里有 position: fixed、inset: 0、margin: auto、width: fit-content、height: fit-content,外加一个白底加黑边的框。你不喜欢默认的样子就用 CSS 覆盖,但至少位置是可控的。

如果实在不想手算位置,还有个土办法:用 popovertarget 的按钮作为参照,在 toggle 事件里用 getBoundingClientRect() 算一下位置,然后给弹层设置 top 和 left。

const menu = document.getElementById('more-menu')
const trigger = document.querySelector('[popovertarget="more-menu"]')

menu.addEventListener('toggle', (event) => {
  if (event.newState !== 'open') return

  const rect = trigger.getBoundingClientRect()
  menu.style.top = (rect.bottom + 6) + 'px'
  menu.style.left = rect.left + 'px'
})

这个方案在滚动页面时可能有偏移,配合 position: fixed 使用的话问题不大。真要精细,还是等 Anchor Positioning 全面铺开吧。

低版本浏览器的降级方案

如果项目要兼容 Chrome 100 以下那种环境,需要做检测和降级。我的做法是:

const supportsPopover = typeof HTMLElement !== 'undefined'
  && 'popover' in HTMLElement.prototype

if (!supportsPopover) {
  // 加载一个极简的 polyfill
  // 或者给 [popover] 元素加一个降级样式类,手动处理开关
}

说实话,如果只是偶尔需要几处弹层,我倾向于不 polyfill,而是直接写一个几十行的迷你实现兜底。真正需要 polyfill 的场景是:项目的设计系统严重依赖 popover 语义,多个组件共用一个机制。

社区里有现成的 @oddbird/popover-polyfill,配合 babel 或者其他构建工具能做到按需加载,体积不到 3KB。但如果可用浏览器份额到了 99% 以上,加这个依赖就有点没必要了。

踩坑清单

把这次重构中撞到的问题列一下,都是实打实踩出来的。

坑一:popovertarget 按钮的默认行为是 toggle,不是 open。很多人以为按钮只会打开弹层,实际上再点一次是关闭。如果你希望按钮只负责打开,关闭交给别处,需要自己在 toggle 事件里判断并拦截。

坑二::popover-open 伪类只在打开时匹配。你不能用它来判断「这个元素是不是 popover」。判断类型要用 element.popover 属性或者 matches(':popover-open')。

坑三:toggle 事件里的 newState 值。它是字符串 'open' 或 'closed',不是布尔值。我第一次写 if (event.newState) 结果两个分支都进了,因为 'closed' 也是 truthy。

坑四:popover 里的表单默认不会提交。这个坑特别深。如果你在 popover 里放一个 <form>,即便里面有 submit 按钮,浏览器也不会提交它 —— 因为 popover 的定义里没有隐含的表单关联。想让表单工作,需要显式设置 form 属性指向一个外层表单,或者用 JS 手动处理。

坑五:CSS 的层叠顺序。popover 在顶层,意味着任何伪元素、任何 ::before、任何装饰层如果不在同一个 popover 里,都会被它盖住。你在弹层里加一个小三角,用 ::after 来做的话没问题;但如果你想在弹层外面盖一层遮罩,就得换个思路 —— 遮罩得是独立元素,而且不能用普通 z-index 去压 popover。

坑六:iOS Safari 上的滚动穿透。这个在 17.4 之后修了,但 17.0 附近有几个版本还是会出现背景跟着弹层滚动的情况。如果目标用户里有停留在旧版本的人,需要在 toggle 时给 body 加 overflow: hidden。

坑七:键盘导航。popover 打开后,Tab 键会按正常文档顺序走,不会把焦点限制在弹层内。想要箭头键上下切换菜单项,需要自己写键盘处理。这不是 popover 的缺陷 —— 它本来就是无模态的,但写菜单的时候经常会忘记这一点。

最后

重构完成后,我统计了一下代码:下拉菜单从原来的 180 行 JS + CSS 变成了 12 行结构 + 40 行样式 + 6 行 JS。更重要的是,行为不再需要自己维护了 —— 点击外部关闭、ESC 关闭、焦点管理、顶层渲染,这些过去要靠库或者手写逻辑保底的事情,现在浏览器兜底了。

当然,原生方案还没到完美的程度。定位和嵌套依然是需要权衡的地方,@starting-style 这类新特性也在逐步铺开。但对于绝大多数日常弹层需求,Popover API 已经足够好用,值得从今天开始,在新项目里先试试它,再决定要不要请 UI 库进场。

工具本来就是这样的:能用原生的地方,就别再叠一层。

原生 Popover API 实战:不引入 UI 库也能做出体面的弹层菜单
收藏 (0) 打赏

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

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

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

淘吗网 html 原生 Popover API 实战:不引入 UI 库也能做出体面的弹层菜单 https://www.taomawang.com/web/html/2860.html

常见问题

相关文章

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

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