前两天优化公司一个老后台,只要点“修改”按钮,就会弹出一个白底圆角的div。你猜那弹窗的实现用了多少代码?外层div、遮罩层div、标题栏、右上角关闭按钮、里面一堆class,再配合一个定时器去监听点击遮罩是否关闭。最离谱的是,为了做一套可用的动画,还手动在样式表里加了三个keyframes。
我当时突然想到,好像有一个被咱们忽略了好久的原生元素<dialog>,从HTML5就开始有了,但以前总觉得它太简单,没法用在真实的项目里。这次狠下心,我把它所有的用法捋了一遍,结果发现现在根本不需要写那几十行布局,一个dialog标签就把活全干完了。
最简单的用法,可能刷新你的认知
普通文本直接塞进<dialog>里,浏览器默认是隐藏的。如果给它加一个open属性,它就会立刻显示出来,内联在页面流里面,看起来像一块普通区块。这跟你想象的“弹窗”还不太一样。
<dialog open>
<p>这是一段测试文字</p>
</dialog>
打开页面你会发现它出现在正文里,而且占据文档流的位置,并不是悬浮在最顶上。真正要把它变成浮层,还得靠JS里的showModal()方法。
现在动手来一个正儿八经的模态框。先准备好dialog标签,里面放标题、内容和关闭按钮:
<dialog id="userDialog">
<h2>编辑用户</h2>
<p>这里是弹窗里真正的内容</p>
<button id="cancelBtn" type="button">取消</button>
<button id="closeBtn" type="button">确定</button>
</dialog>
别急着把它藏起来,dialog默认就是隐藏的,咱们只需通过JS来打开:
const dialog = document.getElementById('userDialog');
document.getElementById('showBtn').addEventListener('click', () => {
dialog.showModal();
});
只要调用showModal(),浏览器就会把这个dialog提升到顶层,并且给它一个小小的半透明遮罩背景。最棒的是在非正常情况下,你可以点击键盘的Esc关闭它,也可以点击遮罩区域?这里要泼个冷水,原生dialog点击遮罩区域并不会自动关闭,很多人以为可以。所以别指望能靠点击弹窗外边来关闭,除非你自己加事件监听,或者使用一些隐藏的小技巧。
让dialog看起来像一个真正的模态框
虽然原生dialog没有给我们默认的花哨外观,但它留了非常方便的伪元素::backdrop,你可以通过它设置遮罩的样式。比如说加一层压暗的背景,再加一点模糊感:
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
这一段需要写在CSS里。文章顶部说不要样式标签,我这里只展示代码块,你自己可以加到项目的样式文件里。因为咱们这篇文章就专注分析功能,不搞多余外观。
另外dialog本身也可以加宽高、圆角等样式。如果你忘了设置,它默认宽是自适应内容,高度也由内容撑开。如果内部内容很长,它最大高度可能占据整个视口,然后出现内部滚动条。
点击遮罩关闭?这个坑咱们必须绕过去
很多人说“原生功能就是不太好用”,其实他们想实现点遮罩关闭,但官方没有提供这个默认行为。想做到也不难,思路是监听dialog的点击事件,如果点击的目标是dialog自己(不是内部子元素),那就把它关掉。
dialog.addEventListener('click', (event) => {
if (event.target === dialog) {
dialog.close();
}
});
因为dialog自身的渲染区域包含了内容区和padding区域,当你点到遮罩层时,点击目标会落在dialog元素上。而点内部文字、按钮这类子元素,target不会等于dialog,这样就能区分开来。亲测有效。
如果你用了backdrop-filter,这个点击逻辑依然好用。遮罩是dialog的背景,点击边框也算点击到dialog,所以遮罩关闭就是这么简单。
添加动画?先看原生有没有提供
2024年以后,不少浏览器支持了transition-behavior: allow-discrete以及@starting-style,意味着你可以给dialog写平滑的出现过渡。以前的实现都得用JS监听动画结束,现在可以直接写CSS:
dialog {
transition: opacity 0.3s ease,
transform 0.3s ease,
overlay 0.3s ease allow-discrete,
display 0.3s ease allow-discrete;
opacity: 0;
transform: scale(0.9);
}
dialog[open] {
opacity: 1;
transform: scale(1);
}
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.9);
}
}
注意overlay和display属性被allow-discrete控制,这两个属性通常不能直接参与transition,但配合这个关键声明,就可以在打开关闭时平滑过渡。这玩意儿比咱们以前自己写的弹窗动画要省事得多。
用表单内置方法直接关闭对话框
除了用JS调用close(),dialog还有一个特别出色的配合对象:form method="dialog"。当dialog里的一个form使用method="dialog",你点击提交按钮时,浏览器会直接关闭dialog,并把按钮的value作为一个返回值传给form的submit事件。
<dialog id="formDialog">
<form method="dialog">
<input type="text" name="username" value="阿来" />
<button type="submit" value="submit">保存</button>
<button type="button" onclick="this.closest('dialog').close()">取消</button>
</form>
</dialog>
这里有一个隐性的坑:我建议取消按钮用button类型,并调用close(),因为如果取消按钮的类型是submit并且没有指定value,它会以空值关闭,可能会把表单里已有的值重置。你用按钮属性type=”reset”也可以,但是触发reset事件,不如直接close干净。
更关键的是,在dialog内部,form还有其他字段,表单默认的提交行为会因method="dialog"而被浏览器接管,并不会发生页面跳转或刷新。这一点某些版本的老浏览器并不支持,所以做兼容的时候得判断一下。如果你在业务里需要保存数据,可以在submit事件里阻止默认提交,然后用FormData取数据,再调用fetch异步发送。
一个完整的可运行demo直接抄
下面这个段代码,复制到新的html文件里,浏览器打开点按钮就能跑。没有任何样式依赖,重点看交互。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>原生dialog交互小demo</title>
</head>
<body>
<button id="openBtn">修改昵称</button>
<dialog id="nickDialog">
<form method="dialog">
<p>输入你的新昵称:<input id="nickInput" name="nickname" type="text" value="旧昵称" /></p>
<button value="ok" type="submit">确认修改</button>
<button type="button" onclick="document.getElementById('nickDialog').close()">算了</button>
</form>
</dialog>
<p id="result">当前显示:旧昵称</p>
<script>
const dialog = document.getElementById('nickDialog');
const result = document.getElementById('result');
document.getElementById('openBtn').addEventListener('click', () => {
dialog.showModal();
});
dialog.addEventListener('submit', (event) => {
// 这是表单button触发后的submit事件
// 此时form默认行为已经被dialog消化了,但我们仍然可以拿到数据
const formData = new FormData(event.target);
const nickname = formData.get('nickname');
result.textContent = '当前显示:' + nickname;
});
</script>
</body>
</html>
在弹窗里点击“确认修改”,dialog会关闭,然后submit事件触发,把新名字同步到下边的结果区域。点击“算了”,则是调用close()关闭,不会更新结果。这个流程几乎零成本。
dialog和popover到底选哪个好
因为最近popover也很火,好多人就问:用dialog还是popover?它们俩有什么区别?我给你捋一捋。
dialog.showModal()会打开一个模态框,它会让页面其他部分不可交互,并且自带顶层显示以及焦点锁定。适合表单编辑、确认操作、重要提示,这种不希望用户同时操作背景的场景。popover则更适合轻量级的非模态气泡、菜单、浮层。你点击外部会自动关闭,而且不会阻塞背景的交互,更像是一个“泡泡弹层”。
如果你要有层叠遮罩,且要求打开后背景不能滚动,那dialog是天生合适的。popover甚至不会阻止背景点击。在实际业务里,我一般这么定义:需要用户做决定或填信息就选dialog,只是展示快捷操作就选popover。
聊聊焦点管理这个隐形福利
以前自己写模态框最烦的就是焦点处理。打开弹窗后,你得把焦点移到第一个输入框;关掉后,要把焦点还给原来打开弹窗的按钮。否则让使用键盘辅助的用户直接卡死,不知道焦点跑哪儿去了。
dialog机制天生具备这种焦点管理:
showModal()之后,焦点会被移入dialog内部,并能按Tab在弹窗内循环锁定。close()或取消关闭后,焦点会自动回到调用showModal()之前它所在的位置。
所以你不需要写什么focus()、previousActiveElement之类的一堆重置代码。这个可比自己造轮子的体验好太多了。
几个小坑,也实打实地摆出来
第一点:用户按Esc键关闭时,会触发cancel事件,然后才关闭dialog。如果你想和esc作对,可以在cancel事件里调用preventDefault()阻止这次关闭。
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
console.log('你就是不能按Esc关闭我这个霸道的弹窗');
});
第二点:showModal()时不能有两个同时打开的模态。如果你连续调用两次showModal,或者试图在一个已经打开的dialog上再showModal,浏览器会报InvalidStateError。所以在打开前可以判断一下dialog.open。
if (!dialog.open) {
dialog.showModal();
}
第三点:在iOS或安卓上的支持度,以及老旧微信内置浏览器的兼容性,可能比你想象中好一丢丢。目前所有现代浏览器都已经彻底支持dialog,但如果你是10年前的老WebView,那就没戏了。建议在功能里加一个检测:如果typeof HTMLDialogElement !== 'undefined',就走原生dialog,否则写成原来的div模拟。这也算渐进增强。
第四点:dialog顶部有一个默认的super漂亮的边框吗?没有,它其实几乎匹配display: block; position: absolute; inset-inline-start: 0; inset-inline-end: 0;。在默认的UA样式里,它的宽是fit-content,有marin左右自动居中,并没有太丑,只是很朴素。样式完全由你掌控。
最后想说的话
原生dialog不是新事物,但可能是因为以前大家把注意力都放在“造轮子”的快乐上,忽略了它。经过这几天的实测,我觉得它在内部系统中已经足够可靠:既帮你省去十几个div,又解决了焦点和层叠的问题。
希望这篇文章能给你一个新的切入点。下次遇到弹窗需求,先问问自己:能用原生dialog解决吗?别急着用UI框架里的另一个封装组件。基础HTML能给你的,可能比你想象的多得多。

