以前做模态框,第一反应是引入Element Plus或者Vant的Dialog组件。但这次需求很简单:一个登录弹窗,左边一个输入框,右边一个验证码,下面两个按钮。我一看这场景,直接用了原生<dialog>,结果发现比想象中顺手。当然也踩了几个小坑,今天写下来当个记录。
dialog的基础用法比想象爽
你以为要写一堆定位和遮罩?根本不用。dialog标签自带showModal()和close()方法,并且默认带了一个::backdrop伪元素,就是半透明遮罩。还自带顶部层级,不会被其他元素覆盖。
最安逸的是:showModal()时会自动锁焦点,按Tab只能在对话框内循环,不会跳到背后的网页。这点以前自己写roll,写出来总是容易漏Tab键。
先来一个最简单的对话框
<dialog id="myDialog">
<p>这是一个原生对话框</p>
<button id="closeBtn">关闭</button>
</dialog>
<button id="openBtn">打开对话框</button>
<script>
const dialog = document.getElementById('myDialog');
document.getElementById('openBtn').addEventListener('click', () => {
dialog.showModal();
});
document.getElementById('closeBtn').addEventListener('click', () => {
dialog.close();
});
</script>
点开按钮,对话框出现在屏幕中央,背景被一个黑色半透明遮罩盖住。按Esc也能关闭,按Tab不会跑出对话框,这是原生自带的。
真正的转折点:form method=”dialog”
原本我以为表单提交要手动处理,后来发现原生dialog里有个神奇用法:form可以带着method="dialog"。这样提交时,表单数据并不会真的发送到服务器,而是会把submitter按钮的value作为返回值传给close(),然后自动关闭对话框。
<dialog open>
<form method="dialog">
<button value="cancel">取消</button>
<button value="ok">确定</button>
</form>
</dialog>
点击“确定”按钮,对话框的returnValue就是“ok”,然后关闭。这个特性减少了不少代码。
但问题来了:表单里除了提交按钮还有输入框。如果输入框内容校验失败,我们不希望它关闭对话框。那method="dialog"就不太够用了,因为它不管验证,只要有submit事件就关。
我要的是带表单校验的登录框
所以就回到了传统方式:拦截form的submit事件,做校验,再手动决定是否关闭。我的需求如下:
- 用户名不能为空,且必须包含“@”。
- 密码至少6位。
- 校验失败时在对话框内显示错误提示。
- 校验通过后假装请求接口,再关闭对话框。
我一开始用简单的方式:在dialog里放一个form,监听它的submit事件,然后preventDefault(),再用el.value等原生属性获取输入值。
<dialog id="loginDialog">
<form id="loginForm">
<label>邮箱<input type="email" id="email" required></label>
<label>密码<input type="password" id="password" minlength="6" required></label>
<div id="error" style="color:red; display:none;">密码至少6位</div>
<button type="submit">登录</button>
<button type="button" id="cancelBtn">取消</button>
</form>
</dialog>
里面用了required和minlength,但浏览器对表单校验的提示样式很不统一。我干脆把所有校验逻辑放在JS里,可靠。
监听submit事件,不急着关
const dialog = document.getElementById('loginDialog');
const form = document.getElementById('loginForm');
const error = document.getElementById('error');
form.addEventListener('submit', (e) => {
e.preventDefault(); // 不自动关闭
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
if (!email.includes('@')) {
error.textContent = '邮箱格式不正确';
error.style.display = 'block';
return;
}
if (password.length < 6) {
error.textContent = '密码至少6位';
error.style.display = 'block';
return;
}
// 模拟提交请求
error.style.display = 'none';
setTimeout(() => {
dialog.close('success');
}, 500);
});
这里dialog.close('success')会把返回值设为success,外部可以通过监听close事件拿到这个值。
焦点和初始焦点
虽然showModal()会把焦点困在对话框内,但它默认会把焦点放在第一个可聚焦的元素上。我的第一个可聚焦元素是邮箱输入框,正好是想要的位置。然后按Tab会依次经过密码框、登录按钮、取消按钮。基本符合预期。
但如果你想让某个按钮一开始就获得焦点,可以在showModal()之后手动调用focus()。
dialog.addEventListener('close', () => {
// 关闭后恢复焦点到打开按钮
document.getElementById('openBtn').focus();
});
这是可访问性里很重要的一条。例如用户用键盘打开弹窗后关闭,焦点应该回到触发元素上。原来没注意到这个问题,后来用Tab键盘操作才发现弹窗关掉后焦点没了,浏览器不知道把焦点放哪,直接掉到了地址栏。
踩坑记录:滚动穿透
这是所有弹窗的老大难。默认情况下,即使弹出模态框,背后的页面依然可以用滚轮滚动。这样体验很糟,背景内容会滚动出视线,模态框还浮在上面。
以前我还得写JS去监听wheel事件阻止传播,但dialog似乎没有内置处理。我的解决方法简单粗暴:当打开对话框时,给body添加overflow: hidden,关闭时移除。但这样会有一个副作用:页面宽度可能会多出滚动条的位置,导致布局偏移。不过对于日常页面问题不大。
function openDialog() {
dialog.showModal();
document.body.style.overflow = 'hidden';
}
function closeDialog() {
dialog.close();
document.body.style.overflow = '';
}
如果你用了dialog.addEventListener('cancel')(按Esc触发),也要在里面恢复overflow,不然关了弹窗页面还是不能滚。
dialog.addEventListener('cancel', () => {
document.body.style.overflow = '';
});
更精细的做法是保存当前的scroll位置,恢复到相同的位置,而不是仅仅让overflow复原。但我的场景足够用了。
另一点:方法dialog与表单的诡异行为
如果我在form上没有method="dialog",就不会有自动关闭。但如果你用了method="dialog",同时你又想用JS阻止它关闭,你会发现在submit事件里preventDefault()根本拦不住。我试过了,只要form的method是dialog,不管preventDefault与否,关闭动作已经发生了。因为关闭对话框的动作是在表单提交之前就触发的?反正不建议混合使用。
所以method="dialog"适合那种不需要校验的简单确认框,有校验逻辑就老老实实用普通表单,自己控制关闭。
做一个完整可运行的demo
下面是我去掉样式(为了符合你的要求),只留功能的完整页面代码。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>原生dialog表单示例</title>
</head>
<body>
<h2>点击登录按钮打开对话框</h2>
<button id="openBtn">登录</button>
<dialog id="loginDialog">
<form id="loginForm">
<label>邮箱:<input type="email" id="email" placeholder="name@example.com"></label>
<label>密码:<input type="password" id="password" placeholder="至少6位"></label>
<div id="error" style="color: red; display: none;"></div>
<button type="submit">登录</button>
<button type="button" id="cancelBtn">取消</button>
</form>
</dialog>
<script>
const dialog = document.getElementById('loginDialog');
const form = document.getElementById('loginForm');
const error = document.getElementById('error');
const openBtn = document.getElementById('openBtn');
const cancelBtn = document.getElementById('cancelBtn');
openBtn.addEventListener('click', () => {
dialog.showModal();
document.body.style.overflow = 'hidden';
document.getElementById('email').focus();
});
cancelBtn.addEventListener('click', () => {
dialog.close('cancel');
});
dialog.addEventListener('close', () => {
document.body.style.overflow = '';
openBtn.focus();
});
dialog.addEventListener('cancel', () => {
document.body.style.overflow = '';
openBtn.focus();
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
if (!email.includes('@')) {
error.textContent = '邮箱格式不正确';
error.style.display = 'block';
return;
}
if (password.length < 6) {
error.textContent = '密码至少6位';
error.style.display = 'block';
return;
}
error.style.display = 'none';
// 模拟请求
setTimeout(() => {
dialog.close('success');
}, 300);
});
</script>
</body>
</html>
关于取消按钮的细节
我特意把取消按钮设成了type="button",并让它执行dialog.close('cancel')。如果不设置type, 它默认是submit,会触发表单提交,然后走到了登录逻辑里。所以记住:在表单里,按钮一定要写清楚type。
另一个细节:用户按Esc也会触发cancel事件。这个事件默认会把returnValue设为空字符串?实际上按Esc时,浏览器会先触发cancel事件,默认行为是关闭对话框。如果我们在cancel事件里执行了e.preventDefault(),弹窗就不会关闭。但我想关闭,所以没有阻止默认行为,但我同时在里面手动恢复了overflow。其实在close事件里恢复overflow就够了,因为cancel后必然触发close,除非你阻止了默认行为。不过以防万一,两个地方都写了也无妨。
为什么不用第三方库
最大的原因就一个字:轻。一个登录框而已,不需要引整个UI框架。原生dialog已经解决了模态框最难的几个点:焦点管理、遮罩、顶层显示。我需要处理的只有业务逻辑和滚动穿透。虽然滚动穿透的解决方案不算完美,但它的确是小项目快速上手的利器。
如果你要浏览器兼容到IE或者旧版本微信浏览器,那还是老老实实用div模拟。不过今天都快2025年了,正常使用没太大问题。
小结
原生dialog不是万能的,但它解决了我大部分痛点。尤其是焦点陷阱和顶层显示,这两个功能自己写真的烦。我认为在不需要复杂动画、不需要嵌套弹窗的场景下,优先使用原生dialog是一个很好的选择。希望这篇文章能让你少踩一点坑。

