前阵子接了个企业内部工具的小需求,要做一个“用户反馈”弹层,里面有输入框、评分星星、提交按钮。第一反应是找组件库,但转念一想,这个项目是一个纯静态页面,不想引进任何第三方依赖。后来发现,现在HTML已经原生支持模态框了,而且还能用Popover API做辅助气泡。今天就把我实际改造过程写下来,完全基于原生HTML和少量JavaScript。
先说说原生模态框 之 前有多难用
以前你想在页面上搞一个模态框,要么自己用div加遮罩模拟,要么引Bootstrap或者Element。遮罩层要处理层级、点击事件、键盘Esc关闭。还要考虑body滚动穿透。现在<dialog>元素直接把这些行为全部收敛了。Especially最重要的是,它自带语义和快捷键,屏幕阅读器也能识别。
打开新大门的Dialog元素
原生Dialog用起来特别简单,一个<dialog>标签就是模态框容器。它有两个方法:
dialog.show(); // 非模态,显示浮层,不影响页面其他内容
dialog.showModal(); // 模态,显示浮层且带一层遮罩,其他区域不可交互
关闭也有两种方式:
dialog.close(); // 直接关闭
// 或者用form的method="dialog"提交,会自动关闭并返回form里的数据
这个form close非常有意思,因为它是HTML原生自带的行为,不需要额外写JS。
看个最简单的例子:
<dialog id="demo">
<p>看,原生的!</p>
<form method="dialog">
<button>关闭</button>
</form>
</dialog>
<button id="openDemo">打开对话框</button>
<script>
const dialog = document.getElementById('demo');
document.getElementById('openDemo').onclick = () => dialog.showModal();
</script>
这个框一弹出,后边页面全被遮住了。按Esc也能关闭,不用你监听keyup。这个先记住。
Popover API:轻轻一点出来个小气泡
Popover是另一项拿来即用的API,特别适合做下拉气泡、tips菜单。它的原理就是把一个普通元素变成浮层,然后绑定给某个按钮。
<button popovertarget="tip">悬停我</button>
<div id="tip" popover>这是一个气泡提示</div>
不用写一行JS,点一下按钮就出来,再点一下按钮就消失。这就是声明式UI的魅力。
Popover和Dialog有什么区别?区别在于Popover没有遮罩,并且默认可以用light-dismiss,也就是点击页面上其他区域它会自动关闭。而Dialog除了showModal模式外,也可以用非模态方式显示,但不会自动关闭。我一般这么用:Dialog做正式提交的弹窗,Popover做辅助提示。
实战案例:一个会记住上次输入的用户反馈弹窗
接着说我那实际需求:用户点一个按钮,弹出一个表单,里面有名字、内容、星级。提交后模拟发送数据,然后显示一个“发送成功”的气泡。最骚的是,用户关闭后再次打开,上一次填写的内容还在里面。怎么实现的呢?我用了localStorage保存数据,然后在打开对话框的时候自动回填。整个代码没几行。
第一步:建立dialog结构
<dialog id="feedbackDialog">
<form id="feedbackForm" method="dialog">
<h2>用户反馈</h2>
<label>你的称呼:
<input type="text" name="username" required>
</label>
<label>反馈内容:
<textarea name="content" required></textarea>
</label>
<div>
<span>总体评价:</span>
<label><input type="radio" name="level" value="好"> 好</label>
<label><input type="radio" name="level" value="一般"> 一般</label>
<label><input type="radio" name="level" value="差"> 差</label>
</div>
<menu>
<button value="cancel" formnovalidate>取消</button>
<button value="send">发送</button>
</menu>
</form>
</dialog>
我用了一个form带method=”dialog”,这样点击按钮就会自动关闭对话框,而且不用处理表单submit事件。关键是这里有两个按钮,一个取消一个发送。取消按钮加了formnovalidate,这样就不会触发必填校验。发送按钮没有加,所以会按照HTML5校验规则强制检查必填。
第二步:用Popover做“发送成功”的提示
<button id="showTip" popovertarget="successTip" style="display:none">打开提示</button>
<div id="successTip" popover>发送成功啦!感谢你的反馈。</div>
为什么我藏了一个button在旁边?因为我想在JS里调用popover的toggle方法,这样就不需要手动模拟点击隐藏按钮。不过实际上用popovertarget绑定一个元素,也可以用popover.toggle()。看后面代码:
第三步:写业务逻辑JS
const dialog = document.getElementById('feedbackDialog');
const form = document.getElementById('feedbackForm');
const successTip = document.getElementById('successTip');
function saveDraft() {
const data = new FormData(form);
const draft = {
username: data.get('username') || '',
content: data.get('content') || '',
level: data.get('level') || ''
};
localStorage.setItem('feedback_draft', JSON.stringify(draft));
}
function restoreDraft() {
const stored = localStorage.getItem('feedback_draft');
if (!stored) return;
try {
const draft = JSON.parse(stored);
if (draft.username) form.elements.username.value = draft.username;
if (draft.content) form.elements.content.value = draft.content;
if (draft.level) {
const radio = form.querySelector(`input[name="level"][value="${draft.level}"]`);
if (radio) radio.checked = true;
}
} catch (e) {}
}
// 打开对话框前恢复草稿
document.getElementById('openBtn').onclick = () => {
restoreDraft();
dialog.showModal();
};
// 点击取消按钮时,仍然保存当前填写内容(不校验)
form.addEventListener('click', e => {
if (e.target.value === 'cancel') {
saveDraft();
dialog.close('cancel');
}
});
// 点击发送,并且通过校验后(submit事件会在close之前触发)
form.addEventListener('submit', e => {
// 因为form method=dialog,这里submit事件发生后表单会自己close
saveDraft();
// 清空草稿,因为提交了
localStorage.removeItem('feedback_draft');
// 显示popover提示
successTip.showPopover();
setTimeout(() => successTip.hidePopover(), 2000);
});
这里有个细节:当form method=”dialog”且表单验证通过后,浏览器会先执行submit事件,然后自动close。在submit事件里我们saveDraft又removeItem——虽说先存再删只是多此一举,但这个动作保证了不管用户是取消还是提交,都会先把当前内容保存一份。但提交以后就清除了,所以下次打开不会有旧数据。如果用户点取消,我们明确保存草稿,下次打开就回填。妙啊。
第四步:防止点了遮罩直接关掉后丢草稿
dialog.showModal()打开的对话框,点击遮罩层默认不会关闭,这倒是省心。但如果用户按Esc键关闭呢?也必须在cancel或close事件里保存草稿。我处理一下:
dialog.addEventListener('cancel', e => {
saveDraft();
// 不会阻止默认,让对话框关闭
});
dialog.addEventListener('close', e => {
if (dialog.returnValue !== 'send') {
// 如果不是通过发送按钮关闭的,就说明是取消或者Esc,保留草稿
}
});
其实上面form按钮点击,dialog的close事件也会触发。但因为我设定了returnValue,只有发送按钮的value=”send”才是发送,取消按钮value=”cancel”。所以在close里判断returnValue就好。但考虑到表单提交事件里我们已经把localStorage删除了,所以逻辑上不会冲突。
加一点Web Components的味道
这个项目里我还顺手把整个弹窗封装成了一个自定义元素,这样以后其他页面直接写<user-feedback>就完了。核心思路就是用<template>承装内部HTML,然后在connectedCallback里attachShadow。因为shadow DOM可以起到样式隔离作用,虽然我这里因为特殊原因没有写样式,但你们在实际项目里不用怕样式冲突。
class UserFeedback extends HTMLElement {
connectedCallback() {
const template = document.getElementById('feedback-template');
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));
// 把之前的dialog逻辑搬进shadow根作用域
}
}
customElements.define('user-feedback', UserFeedback);
这样组装的好处是:业务逻辑全都内聚,页面代码干干净净。用到的地方就一行标签,跟原生HTML元素一样。
踩到的一些奇奇怪怪的小坑
虽然原生API很方便,但还是有几个坑值得说一下:
1. form method=dialog配合required校验。如果你在form里放多个button,不加formnovalidate的那个按钮触发提交时,浏览器会先做校验。但有些Android WebView对required校验支持不太好,建议在关键表单上用简易JS校验兜底。
2. popover与dialog叠加层级。popover元素默认层级很高,但若dialog里也用了popover,某些浏览器会出现在dialog后面。解决办法是给dialog设置更高的z-index(但这里我们不用样式,就别管了)。
3. localStorage在隐私模式下可能会写入失败。比如Safari无痕模式,你调setItem直接抛异常。所以我前面的代码里应该包一层try catch,不然会阻断表单提交逻辑。实战中我确实被这个坑过,改成了try catch。
4. 自定义元素和原生dialog组合时,click事件要小心。Shadow DOM里的节点事件在冒泡到外部时会经过retarget处理,你判断event.target时可能会拿到自定义元素本身。解决方法是在shadow dom内部绑定事件时,先确保element就是期望的目标。
最终效果和后续优化
整个功能完成以后,代码量比预想少了至少一半。同事看到以后很吃惊,说原来现在原生就能做这种事,都不用引包了。我也觉得挺爽的。
如果你也想试试,建议就在一个简单的内部页面上用一下,感受下现代HTML带来的清爽。当然这个方案也是有适用边界的,如果你需要非常复杂的动画或者跨域iframe嵌入,那还是老实用组件库更稳。
以后如果再有人问我页面弹窗怎么做,我直接甩给他这篇。最后,祝你的页面也被原生Dialog解放一次。

