上个月重构一个后台系统。这个系统有将近两百个页面,每个页面都有下拉菜单、确认弹窗、轻提示。这些弹层以前是用一个 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,还要理解它的默认行为、边界在哪里、什么时候该用它、什么时候不该用。
如果你手头也有一个在用第三方弹层库维护了挺久的老项目,可以挑一个使用场景最标准的页面先试一下。不用一次改完,改一个下拉菜单、再改一个确认框,感受一下原生的感受。真香了自然会继续。

