原生 dialog 的 show 与 showModal:模态、表单关闭和返回值
同一个 <dialog>,用 show() 打开以后背景还能点,用 showModal() 打开以后背景不能交互。差别不在于有没有一层半透明背景,而在于浏览器是否把它作为模态对话框处理。
给普通打开的 dialog 再画一个遮罩,不能自动补齐模态语义。反过来,用原生 showModal 以后再手动给整个页面祖先加 inert,又可能把组件自己的状态管理弄复杂。先把两种入口的合同分清,通常比再加一套焦点插件更有价值。
下面做一个同内容、双入口的实验页。需要支持原生 dialog 的现代浏览器;页面会在不支持时禁用入口,不尝试用几行 CSS 冒充兼容实现。实验不提交网络请求,表单只用于产生关闭结果。
一份对话框,两种打开方式
保存为 dialog-modes.html 后打开:
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>dialog 打开模式实验</title>
<style>
body { max-width: 44rem; margin: 2rem auto; padding: 1rem; font: 16px sans-serif; }
dialog { border: 1px solid #666; border-radius: .4rem; max-width: 28rem; }
dialog::backdrop { background: rgb(0 0 0 / .35); }
button { margin: .25rem; }
</style>
<button id="plain" type="button">非模态打开</button>
<button id="modal" type="button">模态打开</button>
<button id="background" type="button">背景动作</button>
<button id="switch" type="button">尝试直接切到模态</button>
<p id="status" role="status">尚未打开</p>
<p id="background-result"></p>
<dialog id="editor" aria-labelledby="dialog-title">
<form method="dialog">
<h2 id="dialog-title">编辑显示名称</h2>
<label>名称 <input id="name" name="name" required autofocus></label>
<p>确认会接受本次输入,取消不会产生保存动作。</p>
<button id="cancel" value="cancel" formnovalidate>取消</button>
<button id="confirm" value="confirm">确认</button>
<button id="code-close" type="button">用代码关闭</button>
</form>
</dialog>
<script>
const dialog = document.querySelector('#editor');
const status = document.querySelector('#status');
const name = document.querySelector('#name');
let backgroundActions = 0;
function open(mode) {
if (dialog.open) {
status.textContent = '请先关闭当前对话框';
return;
}
dialog.returnValue = '';
name.value = '';
if (mode === 'modal') dialog.showModal();
else dialog.show();
status.textContent = `已打开:${mode}`;
}
if (typeof dialog.showModal !== 'function') {
for (const id of ['plain', 'modal', 'switch']) {
document.getElementById(id).disabled = true;
}
status.textContent = '此浏览器不支持本实验所需的原生 dialog';
}
document.querySelector('#plain').onclick = () => open('plain');
document.querySelector('#modal').onclick = () => open('modal');
document.querySelector('#background').onclick = () => {
document.querySelector('#background-result').textContent =
`背景动作 ${++backgroundActions} 次`;
};
document.querySelector('#switch').onclick = () => {
try { dialog.showModal(); }
catch (error) { status.textContent = `切换失败:${error.name}`; }
};
document.querySelector('#code-close').onclick = () => dialog.close('code');
dialog.addEventListener('cancel', () => {
status.textContent = '收到取消请求';
});
dialog.addEventListener('close', () => {
const result = dialog.returnValue;
status.textContent = result === 'confirm'
? `确认名称:${name.value}` : `已关闭,结果:${result || '未指定'}`;
});
</script>
</html>
先用非模态入口打开,背景动作仍可执行;此时点击“尝试直接切到模态”,应捕获 InvalidStateError。要改变模式,先关闭,再以新模式打开,不要直接篡改 open 属性去绕开组件生命周期。
再用模态入口打开,观察背景动作不能继续触发、::backdrop 出现,初始焦点进入名称输入。按 Escape 会产生取消请求并关闭;填入名称后确认,则关闭结果为 confirm。取消按钮用了 formnovalidate,因此名称为空也允许取消。
show() 与 showModal() 不是程度不同的同一个显示方法。前者打开非模态对话框,后者进入顶层呈现并让同文档其他部分按模态规则变成不可交互。即使把普通页面元素的 z-index 写得很大,也不能据此推断它能盖过顶层模态对话框。
method=dialog 提交的是关闭意图
表单上的 method="dialog" 不会向服务器发送一份普通 HTTP 表单。按钮值成为关闭结果,控件当前值仍可由业务读取。若真正要保存到后端,需要在应用逻辑中明确发请求,并决定请求成功之前要不要保持窗口打开。
当前例子直接接受输入后关闭,只用于观察浏览器行为。它没有持久化名称,也没有异步保存,因此不应给界面加一个未经证据支持的“保存成功”。真实系统收到 confirm 后,还必须通过接口确认业务结果。
原生表单约束仍参与确认路径。required 名称为空时,确认不能靠正常提交关闭;取消则显式跳过这项约束。把所有关闭按钮都写成同样的 submit,又没有区分取消的校验需求,是常见的“用户必须先填好才能退出”问题。
returnValue 也有寿命。它不是每次打开都会自动变成你希望的默认值,例子在入口重置为空,避免上次 confirm 残留到下一次 Escape 关闭,被误判成再次确认。结果状态与表单输入一样,都应在适当入口初始化。
直接调用 close('code') 则使用指定结果关闭。程序关闭与用户取消请求不走完全相同的事件路径,因此不能只监听 cancel 就指望记录所有关闭行为。最终结果可以在 close 处理器集中读取,业务动作仍按明确值分支。
cancel 与 close 不能互相替代
取消请求常来自 Escape 等关闭意图,cancel 事件可以被阻止;close 表示对话框已经完成关闭。它们对应“是否允许离开”和“已经离开”两个时点。
如果存在未保存内容,可以在 cancel 处理器里按规则调用 preventDefault,并给出可操作提示。不要无条件拦住所有取消,再把关闭按钮也藏在不可达位置。模态窗口一旦没有可靠出口,用户就会被困在一段看似很简单的原生交互里。
阻止取消也不等于阻止所有关闭方法。代码仍可能直接调用 close,表单提交也有自己的关闭路径。组件需要统一管理自己的入口,不能让一条事件监听器承担它从未承诺覆盖的全部动作。
关闭以后是否需要执行清理、是否恢复先前焦点、是否更新页面状态,应按真正的交互场景验证。浏览器提供默认聚焦和回焦机制,应用仍要保证原入口没有在打开期间被删除、禁用或替换。原入口已经不存在时,需要有合理的替代落点。
不要把 tabindex 加到 dialog 本身当作万能修复。初始焦点应落在适合当前任务的位置:简单表单可以放首个输入,长说明或危险操作确认则可能需要其他安排。默认机制能减少工作,不会替产品选择最合适的阅读起点。
异步保存时,先决定关窗还是等待
如果把名称保存改成远程请求,method=dialog的自动关闭可能并不符合需要。可以拦截表单submit,在确认动作上先preventDefault,等服务端成功以后再显式close;取消动作仍保留退出政策。这个改变应围绕业务状态实现,不要一边依赖默认关闭,一边在close事件里才发现保存失败又立刻重新打开。
等待期间还需要保留可理解的反馈。只禁用确认按钮可以减少重复点击,但不能让服务器自动获得幂等;请求结果未知时,也不能简单把按钮恢复就当作从未提交。是否允许用户取消等待、取消以后请求是否继续、成功回来后是否还更新当前页面,都要有明确的拥有者。
对于没有异步工作的确认框,保持原生method=dialog反而更简单。不是所有窗口都值得引入“正在保存、保存失败、取消后迟到结果”几种状态;只有真实操作跨过了网络或其他异步边界,才需要管理那份寿命。
顶层呈现不等于全局业务锁
模态状态限制的是当前文档的交互,不会暂停已经发出的 fetch、计时器、其他标签页或后台任务。用户暂时不能点背景按钮,业务数据仍然可能在变化。
所以,确认操作提交时仍要核对数据版本与权限,不能把“对话框开着”当作服务器状态未变的证据。即使界面一次只呈现一个模态交互任务,也不代表整个系统只有一个写入者。
如果 dialog 位于 iframe,模态作用范围与所属文档相关,不能直接推断父页面全部交互都会被锁住。跨文档界面的覆盖与焦点管理要另外设计,不能依赖子文档的一次 showModal 把外部世界一起接管。
CSS 仍会影响可用性。最大高度、内部滚动与窄屏布局需要照常处理,尤其长内容不能把底部取消按钮推出可访问区域。顶层图层解决呈现层级,不会自动解决所有尺寸问题。
最后,别只验证鼠标点击。用 Tab 检查焦点去向,用 Escape 验证取消,用空字段验证确认约束,再多开关几次观察结果有没有残留。原生组件的优势,是已有一套清楚的默认合同;理解它以后顺着合同补业务,通常比一边手动切属性、一边修补浏览器行为更稳。