去年的项目验收阶段,我打开了公司后台系统的 node_modules,看到一个 2019 年引入的弹窗库。270KB 的体积,负责的职责是”点按钮,弹出一个框”。这四年围绕它写了两千多行的 CSS、JS 包装和业务适配层,但它本质上就是做了一个浏览器早就原生支持的功能——打开一个模态框。
更麻烦的是它的无障碍问题。屏幕阅读器识别不了当前是模态状态,键盘用户 Tab 键能跑到弹窗背后的页面上,手机端在键盘弹出时位置全乱。这些都是那个库时代前端工程师的常见困扰。
HTML 早就把这些问题想好了,就是我们常说的开源作者和”前端老兵”们,因为历史包袱没有及时跟进。<dialog> 元素 2014 年就有了规范,2022 年所有主流浏览器都完成了支持。属性 popover 也在 2023 年跟进。这两个东西组合起来能覆盖绝大多数弹窗场景。
这篇文章就是这次替换全过程的记录。不是讲 API 文档,是讲我们怎么一步步替换掉老库、中间踩了什么坑、以及最终留下来的那些真实可用的代码。
先看老的弹窗库做了什么
把老方案拆开看,一个模态框弹窗需要处理的事情大致是这些:
- 在页面某个隐藏的容器里渲染 DOM
- 给
body加overflow: hidden防止滚动穿透 - 计算居中位置(包括窗口 resize 时重算)
- 监听
Esc键关闭 - 点击遮罩层关闭
- 管理焦点:打开时把焦点移入弹窗,关闭时还给触发按钮
- 管理 z-index 层级(多个弹窗叠加时)
- 把弹窗外的内容标记为
aria-hidden="true" - 处理 Window 焦点丢失的情况
这一整套做下来,200KB 的库并不冤。问题在于浏览器已经把这套逻辑原生实现了,而且是 C++ 层面的、和渲染引擎配合的、不可能有 JS 实现的缺陷的原生实现。
dialog 元素基础用法
最朴素的写法:
<dialog id="myDialog">
<p>这是一个模态框</p>
<button>关闭</button>
</dialog>
默认是隐藏的。要显示它:
const dialog = document.getElementById('myDialog')
dialog.showModal() // 模态打开
dialog.show() // 非模态打开,相当于浮层
dialog.close() // 关闭
showModal() 一调用,浏览器做了这几件事:
- 弹窗以模态形式显示在页面中心
- 背后加一层
::backdrop遮罩(默认半透明黑) - 阻止用户与弹窗外内容交互
- 把焦点自动移入弹窗里第一个可聚焦元素
- 锁定 Tab 键在弹窗内循环
- 注册
Esc键关闭行为 - 把弹窗外的内容标记为 inert 状态
这些全都免费。我们老库里靠 JS 硬撑的那套逻辑,现在一行 JS 都不用写。
关闭方式也简单。任何带 form method="dialog" 的按钮,点击就会关闭它所在的 dialog:
<dialog id="confirm">
<form method="dialog">
<p>确定要删除这条记录吗?</p>
<button value="cancel">取消</button>
<button value="confirm">确定</button>
</form>
</dialog>
点击”确定”,dialog.returnValue 会是 "confirm"。点击”取消”,会是 "cancel"。这个机制比手动绑一堆事件干净得多。
关闭事件监听:
dialog.addEventListener('close', () => {
console.log('用户选择了:', dialog.returnValue)
})
把删除确认框重写一遍
我们后台系统里最常见的场景是”点击删除按钮,弹出确认框,确认后发 API 请求删掉记录”。用老库写是这样的:
function confirmDelete(id) {
modal.confirm({
title: '确定删除?',
content: '删除后不可恢复,请谨慎操作。',
onOk: () => api.delete(id),
onCancel: () => {}
})
}
用原生 dialog 写:
<dialog id="deleteConfirm" class="modal">
<form method="dialog">
<h2>确定删除?</h2>
<p>删除后不可恢复,请谨慎操作。</p>
<div class="actions">
<button value="cancel">取消</button>
<button value="ok">确定</button>
</div>
</form>
</dialog>
const dialog = document.getElementById('deleteConfirm')
function confirmDelete(id) {
dialog.showModal()
dialog.addEventListener('close', function handler() {
dialog.removeEventListener('close', handler)
if (dialog.returnValue === 'ok') {
api.delete(id)
}
}, { once: true })
}
注意 { once: true },这样监听器一次就自动移除,不用手动 remove。虽然代码里写了 remove 是出于兼容考虑,但实际配了 once 就已经足够了。
比库版本的好处:没有回调地狱,用事件驱动更灵活;关闭逻辑集中在 dialog 自己的 DOM 上,通过 <form method="dialog"> 声明式地表达了”哪些按钮是关闭按钮”。这种”用声明式代替命令式”是原生元素带来的典型收益。
popover 属性:另一种弹窗
dialog 用来做模态框很合适,但有些场景不需要模态——比如用户点击头像弹出的菜单、点击帮助图标弹出的提示气泡、点击筛选按钮弹出的面板。这些用 dialog 是杀鸡用牛刀,因为它们不需要焦点锁定、不需要 backdrope 遮罩。
popover 属性就是给这种场景设计的:
<button popovertarget="userMenu">用户菜单</button>
<div id="userMenu" popover>
<a href="/profile" rel="external nofollow" >个人资料</a>
<a href="/settings" rel="external nofollow" >设置</a>
<button popovertarget="userMenu" popovertargetaction="hide">关闭</button>
</div>
不需要一行 JS。点击按钮,菜单弹出;点击菜单外任何地方,菜单自动关闭;按 Esc 也能关。popovertarget 属性把触发按钮和弹窗关联了起来。
popover 属性有三种取值:
| 取值 | 行为 |
|---|---|
popover="auto"(默认) |
点击外部自动关闭,同一时刻只有一个 auto popover 显示 |
popover="manual" |
不会自动关闭,需要手动调 API 关闭,可以有多个同时显示 |
popover="hint" |
不关闭其他 auto popover,用于 tooltip 这类轻量提示(部分浏览器支持) |
用 JS 控制的话,有 showPopover()、hidePopover()、togglePopover() 三个方法,跟 dialog 类似。还可以监听 beforetoggle 和 toggle 事件做一些副作用。
给弹窗加上样式
原生元素默认有浏览器样式,通常需要覆盖。dialog 上来就是居中的,我们一般需要的是清理默认边框、自定义尺寸和圆角。这部分用 CSS 处理,注意下面这些点。
dialog 的遮罩层用 ::backdrop 伪元素控制:
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
dialog {
border: none;
border-radius: 12px;
padding: 24px;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
这里有个细节值得说:::backdrop 是独立的渲染层,它不在 dialog 的上下文中,所以 ::backdrop 里能用的属性有限。比如不能给它设置 padding,它铺满整个视口。想调整它的样式,只能从颜色、透明度、模糊、阴影这几个维度入手。
另外 dialog 打开时会自动获得焦点。如果弹窗内有输入框,你可能希望焦点直接落在第一个输入框上,而不是”关闭”按钮上。用 autofocus 属性:
<dialog>
<form method="dialog">
<input type="text" name="title" autofocus>
<button>提交</button>
</form>
</dialog>
注意,如果 dialog 里有多个 autofocus,只有第一个生效。
表单验证与 :user-invalid
重写过程中一个意外的收获,是发现表单验证的交互可以做得更顺手。
以前在弹窗里做表单,无论如何都要写一套 JS 逻辑判断输入是否合法,再把错误信息挂到对应字段下方。原生 HTML 表单有 required、pattern、minlength、type="email" 这些约束,浏览器原生就能判断和阻止提交。缺的只是样式的响应。
配合新的 :user-invalid 伪类,可以把”用户输错了”和”还不该提示”这两种状态区分得很清楚:
input:user-invalid {
border-color: #d93025;
background-color: #fff8f8;
}
input:user-invalid + .error-hint {
display: block;
}
:user-invalid 只在用户尝试提交过之后、或者离开输入框之后才会触发。这一点跟 :invalid 区别极大——:invalid 会在输入框还是空的时候就报错,用户体验很糟。:user-invalid 让提示更有分寸。
我们的注册表单提交按钮就从”总是可点击”改成了”用户没提交过一次前不显示错误”,提交过一次后,所有错误提示立刻出现。这一个小改动让投诉少了将近一半。
我们踩过的五个坑
坑一:::backdrop 里的 z-index 不好使。第一版我们想给 backdrop 加个 z-index,结果没效果。后来明白了,backdrop 是浏览器控制的渲染层,它永远在 dialog 内容和页面其他内容之间。你要调层级,就调 dialog 本身,不要调 backdrop。
坑二:dialog 的默认 margin 是 auto,但会被某些 CSS reset 覆盖。我们项目里引入了一份比较老的 CSS reset,把 dialog { margin: 0 } 给覆盖进去了,导致所有弹窗都跑到左上角。定位了半天才找到。用原生元素的时候,先 toggle 一下 CSS reset 的文件看看有没有隐形覆盖。
坑三:showModal 之后 dialog 是”打开”状态,但动画还没跑完。想加打开动画,得用 @starting-style 或者监听 transitionend。我们一开始在 showModal 后立刻加类加过渡,结果动画不触发。后来改成:
dialog {
opacity: 0;
transition: opacity 0.2s;
}
dialog[open] {
opacity: 1;
}
@starting-style {
dialog[open] {
opacity: 0;
}
}
@starting-style 是 2024 年才普及的特性,用来指定元素第一次渲染时的样式,配合 transition 就能做出”从未显示到显示”的过渡。关闭动画稍微麻烦一点,需要在 close 之前先跑一遍动画再真正 close,可以监听 cancel 事件处理。
坑四:嵌套 dialog 的焦点管理。我们有一个场景是”确认框里再弹一个提示框”。原生支持嵌套,但在嵌套关闭时,焦点不会自动回到外层的对应按钮上,而是回到了 <body>。这个我们最后用 close 事件里手动做了焦点回迁:
inner.addEventListener('close', () => {
outer.querySelector('[data-last-focused]')?.focus()
})
麻烦一点,但可控。老库反而因为它的 z-index 管理机制比较”简单粗暴”,这个场景处理得更顺。
坑五:Safari 早期版本对 dialog 的支持有 bug。特别是 backdrop 的点击事件处理,还有 showModal() 时报错的场景(同一个 dialog 已经打开过又一次 showModal)。生产环境的对策是加一层 try-catch 保险:
try {
dialog.showModal()
} catch (err) {
if (err.name === 'InvalidStateError') {
// 已经打开,忽略
} else {
throw err
}
}
这类健壮性问题在自研弹窗库里也要处理,只是原生方案暴露得更直接一点。
一键把三个弹窗场景整理成统一 API
经过几轮迭代,我们把项目里所有弹窗抽象成一个组合函数。业务代码只需要动一处就能用:
class Modal {
constructor(id) {
this.el = document.getElementById(id)
}
open() {
try {
this.el.showModal()
} catch (err) {
if (err.name !== 'InvalidStateError') throw err
}
return new Promise(resolve => {
const onClose = () => {
this.el.removeEventListener('close', onClose)
resolve(this.el.returnValue)
}
this.el.addEventListener('close', onClose)
})
}
close(value = '') {
this.el.returnValue = value
this.el.close()
}
}
业务里的用法变成了:
const result = await new Modal('deleteConfirm').open()
if (result === 'ok') {
await api.delete(id)
}
这个写法比老的 modal.confirm({ onOk, onCancel }) 更符合当下前端的习惯——Promise 化的交互,配合 async/await 天然就好读。同事接手之后第一个反馈就是”这个写法比老库顺”。
性能上到底提升了多少
做了个粗略的对比。同样的删除确认弹窗,同样在 Chrome 上打开 1000 次,一次完整开-关循环计时。
| 指标 | 老弹窗库 | 原生 dialog |
|---|---|---|
| 打包体积 | +270KB | 0 |
| 弹窗打开平均耗时 | 18ms | 2.4ms |
| 弹窗关闭平均耗时 | 12ms | 1.8ms |
| 内存占用(1000 次循环) | 稳定在 42MB | 稳定在 14MB |
差距主要来自两处:老库要在打开时创建 DOM、绑定事件、做居中计算和层叠管理;原生方案直接靠浏览器引擎处理这些,大部分工作都在编译层就已经优化好。
打开耗时从 18ms 降到 2.4ms 这个数字,对单个弹窗感知不明显,但如果页面里有很多弹窗要按需打开,累积起来就是可观的体验差距。
什么时候不适合用 dialog/popover
坦率说,原生方案不是所有场景都合适。我们团队后来讨论过,下面这几种情况还是得用第三方库。
- 复杂动画场景。比如从某个按钮”生长”出来的弹窗、带路径动画的弹窗,原生方案做起来比较费劲,需要写一堆 CSS transition 和起始样式。
- 需要在弹窗里嵌套另一个系统级弹窗时。那种”提示框里再弹选择器再弹日期选择”的三层叠加,浏览器原生 dialog 处理三层以上比较别扭。
- 需要支持非常老的浏览器。虽然 2024 年之后主流浏览器全支持了,但如果项目要求支持 IE 或者早期 Safari,那就得用 polyfill 或者干脆放弃。
- 需要动效和主题高度定制的组件库生态。如果你的项目深度依赖某个 UI 库,替换成本可能大过收益。这种情况下可以只把最核心的几个弹窗换掉,其他的保留。
我们的项目做替换时也不是一刀切,是全量替换到 80% 之后,发现剩下 20% 用原生做反而更复杂,就直接保留老库——但只引入按需加载的模块,整体体积从 270KB 降到了 60KB。
写在最后
这次重写最大的感受是:前端很多”复杂”其实是历史包袱造成的,不是问题本身复杂。当我们习惯用某个库、某个模式来解决问题之后,就很难再回头看”这件事真的有那么复杂吗”。
原生 dialog 和 popover 就是这样一个例子。它们代表的其实是一种回归——把交给浏览器的事情还给浏览器,让 JS 只处理业务逻辑。代价是要理解原生元素的行为边界,好处是代码量、体积、可维护性全部改善。
如果你手上的项目也有类似的”弹窗困局”,建议先花半天时间做一个 dialog 的 POC。可能会发现能删掉的东西比想象中多。

