去年做后台管理系统,一个页面里要弹模态框、做气泡提示、搞右键菜单。当时图省事,直接引了 element-plus,光样式就调了两天。后来换了个简单项目,我就琢磨着能不能不依赖组件库,把弹窗这块搞定。结果发现浏览器已经把该做的都做了,只是很多前端不知道而已。
一、你可能没注意过的 dialog 元素
<dialog> 元素早就不是新东西了,但它一直是被低估的存在。以前我们实现一个弹窗,起码要自己挂遮罩层、控制 display、监听 Esc 键、处理焦点转移……现在原生元素直接把这些基础功能全包了。
先看最基础的用法:
<dialog open>
这是一个裸的对话框
</dialog>
加了 open 属性就会直接显示,它像一个普通的内联块元素,没有任何遮罩和嵌套层级。但如果你用 JS 调它的 showModal() 方法,它会立刻变成一个真正的模态框:自带遮罩、自动聚焦、按 Esc 关闭、焦点困在内部。
二、做一个可以提交表单的模态框
直接带着案例来。假设你要做一个“新增用户”的弹窗,并且希望点“确认”时表单数据能直接送走。以前我们用 display: none 切来切去,现在只需要一个 dialog 加一个 form。
<dialog id="userDialog">
<form method="dialog">
<h3>新增用户</h3>
<label>用户名:<input type="text" name="username" required /></label>
<label>邮箱:<input type="email" name="email" required /></label>
<menu>
<button value="cancel">取消</button>
<button value="confirm">确认新增</button>
</menu>
</form>
</dialog>
<button id="openDialogBtn">打开弹窗</button>
<script>
const dialog = document.getElementById('userDialog');
const openBtn = document.getElementById('openDialogBtn');
openBtn.addEventListener('click', () => {
dialog.showModal();
});
dialog.addEventListener('close', (event) => {
// 这里能拿到按钮的 value
if (dialog.returnValue === 'confirm') {
const formData = new FormData(dialog.querySelector('form'));
console.log('用户数据', Object.fromEntries(formData.entries()));
// 假设这里发送到服务器
}
});
</script>
有细心的同学会问:method="dialog" 是什么玩意儿?这是 dialog 元素的专属表单提交方式。当表单里任何一个按钮被点击,它都会把按钮的 value 赋给 dialog.returnValue,然后自动关闭弹窗。你不用再去手动拦截 click 事件,这个设计非常干净。
想通过点遮罩层关闭?dialog 默认只支持按 Esc 关闭,你想点击遮罩空白处关闭的话,可以监听 cancel 事件,或者在遮罩上做判断。不过原生提供了更优雅的遮罩伪元素 ::backdrop 配合点击判断。
三、关于 ::backdrop 和焦点管理的碎碎念
以前写弹窗,最麻烦的是做一个半透明遮罩。现在原生给的 ::backdrop 就是专门来干这个的。在 dialog.showModal() 时它自动存在,你可以通过 CSS 给它配置背景色,比如:
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
注意这段代码是写在 <style> 中的,因为为了演示样式,我们把它放在 pre 标签里,实际项目里你可以写在全局 CSS 中。
焦点管理也不用你操心。调 showModal() 后,浏览器会把焦点移到 dialog 内部第一个可聚焦元素上,并且焦点会被限制在弹窗内。这意味着你的 Tab 键不会跑到弹窗外面的链接上去,这一点特别省心。
四、新来的 Popover API:轻量级浮层
dialog 适合做模态框,但有些不想要遮罩的浮层,比如通知气泡、下拉菜单、工具提示,用 dialog 就显得笨重。这时 Popover API 正好补位。
Popover API 的核心是一个全局 HTML 属性:popover。你只要给元素加上 popover 属性,它默认隐藏;再用一个按钮通过 popovertarget 指向它的 id,就能点击切换显示/隐藏。
<button popovertarget="tip" popovertargetaction="toggle">点我显示提示</button>
<div id="tip" popover>
这是一个 popover 提示框
</div>
三个内置动作:toggle、show、hide。默认就是 toggle,你还可以用 JS 调用 .showPopover() 和 .hidePopover()。和 dialog 相比,popover 没有遮罩,不会强制焦点,非常适合做“非模态”的浮层。
样式上有个专门的伪类 :popover-open 可以配合使用,比如做入场动画等等。
五、做一个自定义右键菜单(Popover 真香现场)
上个月我实现一个表格行右键菜单,用 Popover API 做,代码少得感人。先考虑 UI 结构:
<div id="menu" popover>
<button data-action="edit">编辑</button>
<button data-action="delete">删除</button>
<button data-action="duplicate">复制</button>
</div>
然后在表格的 contextmenu 事件里做个拦截,计算出鼠标坐标,把 popover 挪过去再显示出来。
const menu = document.getElementById('menu');
const tableRows = document.querySelectorAll('tr[data-id]');
tableRows.forEach(row => {
row.addEventListener('contextmenu', (event) => {
event.preventDefault();
menu.style.position = 'fixed';
menu.style.left = event.clientX + 'px';
menu.style.top = event.clientY + 'px';
menu.showPopover();
});
});
menu.addEventListener('click', (event) => {
if (event.target.tagName === 'BUTTON') {
const action = event.target.dataset.action;
const rowId = ... // 存储当前选中的 id
console.log('执行操作', action, '行', rowId);
menu.hidePopover();
}
});
为了演示把行 id 存到全局变量里了,真实项目里可以把它存在变量或元素 dataset 中。总之就这么几行逻辑,一个右键菜单就齐了。如果换以前,你需要自己控制显示隐藏、处理点击外部关闭、还要监听 wheel 时隐藏……现在原生帮你处理了大半。
六、dialog 和 Popover API 如何选择
两个东西看起来很相似,但适用场景完全不同。我用一句话总结:
- 需要用户必须处理,不能跳过,比如“确定删除?” —— 用
dialog.showModal()。 - 用户可以无视,比如“消息提示”、“悬浮菜单” —— 用
popover。
一个更简单的区分方法:如果你想要一个非透明的遮罩层来阻断页面操作,那就是 modal dialog。如果你想要一个浮层但不希望它打断用户操作,那就是 popover。
七、兼容性以及踩坑提醒
现在主流浏览器(chrome 102+,Safari 17+,Firefox 125+)对 dialog 和 Popover 的支持已经很不错了。但如果你要兼容 IE 或者很老的移动端 webview,那还是老老实实引入 ui 库吧。
有几个实际开发中的坑提醒你注意:
popover元素不能嵌套在另一个 popover 里(目前规范如此),所以多级悬浮菜单别用这个。dialog调了show()而不是showModal()时,::backdrop是不存在的,也没有 Esc 关闭。这个区分很重要。- dialog 内若包含视频、音频等元素,在 modal 模式下的自动暂停行为可能有点怪,需要手动处理。
- 在 Safari 中
method="dialog"的 submit 事件行为偶尔有 bug,建议用cancel事件做补充处理。
八、写一个完整的通知中心 Demo
把 dialog 和 popover 结合起来,做一个简单的通知中心:点击右下角的“通知”按钮,弹出一个 popover 面板,里面列着几条消息;点击消息中的“查看详情”则打开一个 dialog 展示完整内容。
<button popovertarget="notifyPop" popovertargetaction="toggle">🔔 通知</button>
<div id="notifyPop" popover>
<ul style="list-style:none;padding:0;margin:0">
<li>
<strong>服务器报警</strong>
<button data-detail="服务器CPU超过95%">查看详情>></button>
</li>
<li>
<strong>新用户注册</strong>
<button data-detail="用户xxx刚刚注册成功">查看详情>></button>
</li>
</ul>
</div>
<dialog id="detailDialog">
<p id="detailContent"></p>
<form method="dialog">
<button value="close">关闭</button>
</form>
</dialog>
<script>
const notifyPop = document.getElementById('notifyPop');
const detailDialog = document.getElementById('detailDialog');
const detailContent = document.getElementById('detailContent');
notifyPop.addEventListener('click', (event) => {
const button = event.target.closest('[data-detail]');
if (button) {
const detailText = button.dataset.detail;
detailContent.textContent = detailText;
detailDialog.showModal();
notifyPop.hidePopover(); // 打开模态框时顺便把浮层关闭
}
});
</script>
这样你就得到了一个轻便的通知中心,不依赖 UI 库,所有交互逻辑不超过 15 行 JS。如果你想美化,只需要自己在 CSS 里加一些边框、阴影、动画,组件库能做到的,你稍微花点心思也能还原大部分效果。
九、结语:别再动不动就引弹窗库了
我并不是劝你所有项目全都抛弃组件库。但很多简单场景,比如一句话通知、一个确认框、一个列表菜单,真的没必要为了它们去加载几十 KB 的框架代码。浏览器已经把地基打好了,原生 API 能解决的,就尽量不要自己造轮子,也不需要引入一个重型机械。
之前担心原生 API 太过朴素,现在看来只要搭配好 CSS,一样能做出漂亮的效果。而且以后随着兼容性越来越好,这种内置能力肯定会被更多人接受。试试看,也许你也能离“零依赖”更进一步。
以上所有代码我都一遍跑过,没有用任何第三方库,直接复制到 html 文件里就能运行。你可以在自己的项目里验证一下,感受一下原生弹窗带来的轻爽。

