预约确认、二次确认、填写说明这类交互,很多时候不需要先装一套弹窗组件。HTML 自带的 <dialog> 负责模态层和焦点管理,表单上的 method="dialog" 负责把按钮结果带回来,popover 负责不打断流程的轻提示。三者组合起来,代码量不大,行为也更容易解释。
下面用一个“预约产品演示”的案例把这条链路走完:点击按钮打开模态弹窗,填写姓名、公司、邮箱,做必填和邮箱格式校验,取消、确认、Esc、点击遮罩都能正确关闭,最后把结果写回页面。完整代码在文末,保存成 .html 文件就能直接打开。
先分清三个 HTML 能力的职责
<dialog> 不是普通的 div。调用 showModal() 后,它会进入浏览器的顶层显示区域,背景内容变成不可交互,焦点也会被限制在弹窗内。按 Esc 默认会触发关闭。这里要注意:只写 <dialog open> 只是把元素显示出来,并不是模态弹窗,也没有焦点陷阱。
form method="dialog" 适合放在 <dialog> 内部。它的作用不是把表单提交到服务器,而是把弹窗关闭,并把提交按钮的 value 作为结果来源。实际项目里我通常会拦截 submit,先做校验,再手动 close('confirm')。这样校验、关闭和结果文案都在一个地方,调试时不容易被浏览器的默认行为绕进去。
popover 和 dialog 不一样。popover 默认是非模态的,点击页面其他位置通常会关闭,适合“填写说明”“快捷菜单”“轻量筛选”这类不需要强制用户停留的场景。不要用 popover 收集必填数据,用户点到外面就关了,体验会很别扭。
案例目标
- 点击“预约演示”打开模态弹窗。
- 姓名、公司、邮箱必填,邮箱使用
type="email"做格式校验。 - “取消”按钮关闭弹窗,并显示取消结果。
- “确认预约”按钮先校验,再关闭弹窗,并显示提交结果。
- 按 Esc 或点击遮罩也能关闭。
- 弹窗内用 popover 放一段填写说明。
- 不支持
dialog或popover的浏览器要有基本降级,不要把弹窗内容直接摊在页面上。
完整代码
这段代码没有引入框架,也没有依赖组件库。生产环境里可以再补视觉样式,但交互骨架已经完整。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>预约演示</title>
</head>
<body>
<main>
<h1>预约产品演示</h1>
<p>这个页面用原生 dialog 和 popover 完成交互,不依赖组件库。</p>
<button id="openDialog" type="button">预约演示</button>
<output id="result" aria-live="polite"></output>
</main>
<dialog id="bookingDialog" aria-labelledby="dialogTitle">
<form id="bookingForm" method="dialog">
<h2 id="dialogTitle">填写预约信息</h2>
<p>带 * 的字段需要填写。</p>
<p>
<label for="name">姓名 *</label><br>
<input id="name" name="name" required autocomplete="name">
</p>
<p>
<label for="company">公司 *</label><br>
<input id="company" name="company" required autocomplete="organization">
</p>
<p>
<label for="email">邮箱 *</label><br>
<input id="email" name="email" type="email" required autocomplete="email">
</p>
<p>
<label for="topic">想了解的方向</label><br>
<select id="topic" name="topic">
<option value="api">API 集成</option>
<option value="data">数据看板</option>
<option value="private">私有化部署</option>
</select>
</p>
<p>
<button type="button" id="cancelBtn">取消</button>
<button type="submit" value="confirm">确认预约</button>
</p>
</form>
<button type="button" id="helpBtn" popovertarget="helpPopover">填写说明</button>
<div id="helpPopover" popover>
<p>邮箱需要是完整地址,例如 name@example.com。</p>
<button type="button" popovertarget="helpPopover" popovertargetaction="hide">知道了</button>
</div>
</dialog>
<script>
const dialog = document.getElementById('bookingDialog');
const openBtn = document.getElementById('openDialog');
const cancelBtn = document.getElementById('cancelBtn');
const form = document.getElementById('bookingForm');
const result = document.getElementById('result');
const helpBtn = document.getElementById('helpBtn');
const helpPopover = document.getElementById('helpPopover');
if (typeof dialog.showModal !== 'function') {
openBtn.hidden = true;
dialog.hidden = true;
}
if (!('popover' in HTMLElement.prototype)) {
helpBtn.hidden = true;
helpPopover.hidden = true;
}
openBtn.addEventListener('click', () => {
dialog.returnValue = '';
dialog.showModal();
});
cancelBtn.addEventListener('click', () => {
dialog.close('cancel');
});
form.addEventListener('submit', (event) => {
event.preventDefault();
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const data = new FormData(form);
const name = String(data.get('name') || '').trim();
dialog.close('confirm');
result.textContent = name
? `已提交,${name},我们会按邮箱联系你。`
: '已提交。';
});
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'cancel' || dialog.returnValue === '') {
result.textContent = '已取消预约。';
}
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog) {
dialog.close('cancel');
}
});
</script>
</body>
</html>
关键点拆解
1. 打开弹窗前重置 returnValue
dialog.returnValue 会保留上一次关闭时的结果。如果用户上次点了“确认”,这次按 Esc 关闭,直接读 returnValue 可能会拿到旧值。所以打开前先写 dialog.returnValue = '',关闭分支会更干净。
2. 提交事件里先 preventDefault,再手动 close
表单上写了 method="dialog",但代码里仍然拦截了 submit。原因是必填校验和结果文案需要统一处理。流程是:阻止默认关闭,调用 form.checkValidity() 检查约束,失败时用 reportValidity() 让浏览器提示,成功后再 dialog.close('confirm')。
如果完全依赖 method="dialog" 的默认行为,按钮的 value 会进入 returnValue,但表单校验的时机和关闭后的文案处理会分散在不同地方。交互一复杂,维护成本就上来了。
3. 取消按钮用 type=”button”
“取消”按钮写的是 type="button",然后手动 dialog.close('cancel')。不要把它写成 submit 再靠 formmethod 兜圈子。取消就是取消,不要触发表单提交和校验。
4. 遮罩点击要自己判断
<dialog> 默认不会因为你点击遮罩就关闭。代码里监听弹窗的 click,当 event.target === dialog 时关闭。这里有一个细节:dialog 自身的内边距区域也可能成为点击目标,如果发现点击内容边缘也关闭,可以给内容再包一层容器,或改成按坐标判断。
5. Esc 关闭要算进取消分支
按 Esc 时,浏览器会关闭 dialog,但 returnValue 可能为空字符串。关闭事件里把 cancel 和空字符串都当作取消处理,结果输出就不会漏掉这条路径。
6. popover 只承担说明,不承担主流程
“填写说明”按钮通过 popovertarget 指向 popover 的 id,popover 内部再用 popovertargetaction="hide" 放一个关闭按钮。这个结构不需要脚本就能工作。帮助内容不要放必填字段,用户点一下外面就关闭了,收集数据还是应该放在 dialog 或普通页面里。
容易踩的坑
- 只给 dialog 加
open属性,不等于模态弹窗,背景仍然可操作。 - 打开前不重置
returnValue,可能读到上一次的关闭结果。 - 手动调用
close()不会触发 required 校验,必须自己调用checkValidity()或reportValidity()。 - 遮罩点击不是 dialog 的默认关闭行为,需要自己监听。
- popover 的触发按钮和内容 id 写错时不会报错,只是点不开,排查时先看属性名。
- 弹窗标题要用
aria-labelledby关联,结果输出用aria-live,否则屏幕阅读器用户不容易感知状态变化。 - 不要用
<a href="#" rel="external nofollow" >充当按钮,键盘行为和语义都不对。
兼容与降级
dialog 在现代浏览器里已经可用。代码里检测 typeof dialog.showModal !== 'function',不支持时隐藏入口和弹窗,避免把弹窗内容直接暴露在页面上。更完整的降级可以跳转到单独的表单页面。
popover 相对新一些。检测 popover 是否存在于 HTMLElement.prototype,不支持时隐藏帮助按钮和帮助内容。也可以把帮助说明改成 <details>,虽然交互形态不同,但信息仍然可读。
上线前建议这样测
- 键盘 Tab 能否在弹窗内循环,Esc 是否能关闭。
- 必填项为空时提交,是否能停留在弹窗并给出提示。
- 邮箱格式错误时提交,是否被
type="email"拦住。 - 取消、确认、Esc、点击遮罩四条关闭路径,结果文案是否一致。
- 确认提交后再次打开,
returnValue是否已经重置。 - popover 打开后点击页面其他位置,是否能正常关闭。
- 在手机软键盘弹出时,确认按钮是否仍然可操作。
- 屏幕阅读器是否能读出弹窗标题和结果输出。
这个案例的重点不是“少写代码”,而是把交互职责放回 HTML 本身。dialog 管模态,form 管数据,popover 管轻提示,脚本只处理校验和结果分支。后续要换成删除确认、退出登录确认、编辑资料弹窗,骨架基本不用大改。

