
用 dialog.js 实现一个简单的网页对话框
网页中的对话框看起来只是一个浮层,真正实现时却常常涉及焦点管理、键盘操作、页面遮罩、用户取消以及输入内容安全等问题。本文从零编写一个名为 dialog.js 的小模块,使用浏览器原生的 <dialog> 元素实现异步确认框。示例不依赖第三方库,可直接运行,也会说明它适合什么场景,以及哪些边界不能忽略。
这里的 dialog.js 是本文创建的文件名,不是某个特定第三方库的 API。浏览器原生对话框能力由 HTML 的 <dialog> 元素提供,JavaScript 模块负责创建结构、设置文本、打开对话框并把用户选择转换为 Promise 结果。
一、适用场景与实现边界
这种轻量对话框适合需要等待用户做出选择的场景,例如删除前确认、离开未保存页面前询问、提示操作结果,或者要求用户确认一次不可轻易撤销的操作。与浏览器自带的 alert()、confirm() 相比,自定义对话框可以统一视觉样式,也能更自然地融入页面布局。
不过,对话框并不适合承载所有交互。只展示一段非关键提示时,页面内的状态消息通常更不打断用户;内容较多或需要多个输入字段时,完整页面或侧边面板可能更合适。对话框应当聚焦于一项明确任务,避免层层弹出或把复杂流程塞进一个小窗口。
本文示例依赖浏览器对原生 HTMLDialogElement 的支持。使用 showModal() 打开时,对话框进入模态状态,浏览器会处理模态层及大部分焦点行为;这不同于仅用 CSS 把一个普通 div 画在页面上。若项目必须支持不具备所需原生能力的旧浏览器,应先确认目标浏览器范围,再选择经过维护的兼容方案或实现完整的自定义无障碍行为。不能只添加一个遮罩就认为已经实现了模态对话框。
二、项目结构
下面使用两个文件:index.html 放置页面和样式,dialog.js 提供创建确认框的函数。通过 ES module 的方式导入,因此示例需要从本地 HTTP 服务打开,不能直接双击 HTML 后依赖 file:// 环境加载模块。开发时可以使用编辑器提供的静态服务器,或者运行 python -m http.server,再访问服务器显示的本地地址。
三、编写 dialog.js
这个模块导出一个 confirmDialog(options) 函数。调用后它会显示对话框,并返回一个 Promise:用户选择确认时得到 true,选择取消、按 Escape,或通过其他允许的关闭方式结束对话框时得到 false。
// dialog.js
export function confirmDialog({
title = "请确认",
message = "",
confirmText = "确定",
cancelText = "取消"
} = {}) {
if (typeof HTMLDialogElement === "undefined") {
return Promise.reject(
new Error("当前浏览器不支持 HTMLDialogElement")
);
}
const dialog = document.createElement("dialog");
dialog.className = "app-dialog";
dialog.setAttribute("aria-labelledby", "app-dialog-title");
dialog.setAttribute("aria-describedby", "app-dialog-message");
const heading = document.createElement("h2");
heading.id = "app-dialog-title";
heading.textContent = String(title);
const description = document.createElement("p");
description.id = "app-dialog-message";
description.textContent = String(message);
const actions = document.createElement("div");
actions.className = "app-dialog__actions";
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.textContent = String(cancelText);
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.textContent = String(confirmText);
confirmButton.className = "app-dialog__confirm";
actions.append(cancelButton, confirmButton);
dialog.append(heading, description, actions);
document.body.append(dialog);
return new Promise((resolve, reject) => {
let settled = false;
function finish(result) {
if (settled) return;
settled = true;
dialog.remove();
resolve(result);
}
cancelButton.addEventListener("click", () => {
dialog.close("cancel");
});
confirmButton.addEventListener("click", () => {
dialog.close("confirm");
});
dialog.addEventListener("close", () => {
finish(dialog.returnValue === "confirm");
}, { once: true });
try {
dialog.showModal();
} catch (error) {
dialog.remove();
if (!settled) {
settled = true;
reject(error);
}
}
confirmButton.focus();
});
}
函数会先在 DOM 中创建元素,再调用 showModal()。它通过 textContent 设置标题、正文和按钮标签,因此传入的字符串会被作为文本呈现,而不是当作 HTML 解析。用户点击按钮时,代码用 close() 关闭对话框,并以关闭时的 returnValue 判断结果。
对话框关闭事件是统一的结果出口:点击取消按钮会得到 false;用户按 Escape 时,原生对话框会触发取消并关闭,未设置确认值,因此也得到 false。settled 标记用于避免结果被重复处理。若 showModal() 因浏览器状态等原因抛出异常,模块会移除已创建的节点并拒绝 Promise,而不是遗留一个无法使用的元素。
四、页面、样式与调用方式
将下面代码保存为 index.html。示例通过按钮触发确认框,根据 Promise 结果更新页面状态。样式使用 ::backdrop 设置原生模态对话框背后的遮罩;对话框元素本身仍然是 HTML 内容,不需要把它的文字拼接成 HTML 字符串。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>dialog.js 示例</title>
<style>
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
margin: 2rem;
}
button {
font: inherit;
padding: 0.45rem 0.9rem;
cursor: pointer;
}
.app-dialog {
width: min(26rem, calc(100% - 2rem));
padding: 1.25rem;
border: 0;
border-radius: 0.75rem;
box-shadow: 0 1rem 3rem rgb(0 0 0 / 22%);
}
.app-dialog::backdrop {
background: rgb(0 0 0 / 45%);
}
.app-dialog h2 {
margin: 0 0 0.75rem;
font-size: 1.25rem;
}
.app-dialog p {
margin: 0 0 1.25rem;
}
.app-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.app-dialog__confirm {
color: white;
background: #2457c5;
border: 1px solid #2457c5;
border-radius: 0.35rem;
}
#status {
margin-top: 1rem;
}
</style>
</head>
<body>
<h1>删除记录</h1>
<p>点击按钮,查看自定义确认对话框。</p>
<button id="delete-button" type="button">删除记录</button>
<p id="status" aria-live="polite"></p>
<script type="module">
import { confirmDialog } from "./dialog.js";
const button = document.querySelector("#delete-button");
const status = document.querySelector("#status");
button.addEventListener("click", async () => {
try {
const confirmed = await confirmDialog({
title: "删除这条记录?",
message: "删除后无法从当前页面恢复。",
confirmText: "删除",
cancelText: "保留"
});
status.textContent = confirmed
? "用户确认了删除。"
: "操作已取消。";
} catch (error) {
console.error(error);
status.textContent = "无法打开对话框,请检查浏览器支持。";
}
});
</script>
</body>
</html>
这里的删除操作只是更新提示文字,并没有连接服务端。实际应用中,应在确认结果为 true 后再调用业务接口,并处理网络失败、权限不足和服务端校验结果。客户端确认框只负责收集用户意图,不能代替服务端的权限检查或业务规则。
五、参数限制与输入安全
示例的参数是一个对象,支持 title、message、confirmText 和 cancelText 四个字段;它们都会转换为字符串,未传入时使用默认值。传入 null、对象或其他非字符串值时,String() 会执行相应的字符串转换,因此调用方仍应传入有意义的文本。这个实现没有提供自定义 HTML、图标、按钮数量、任意按钮回调、输入表单、动画配置或多实例队列,也没有对字符串长度作限制。需要这些能力时,应明确扩展接口,而不是假设当前参数已经支持。
尤其不要为了让标题“支持富文本”而把 textContent 随意改成 innerHTML。如果标题或正文来自用户输入、URL 参数、接口响应或其他不可信数据,直接写入 innerHTML 可能造成跨站脚本攻击。当前实现把这些值当作文本显示,能避免这类字符串被解释为标签或脚本;但这并不意味着整个页面因此自动安全,其他 DOM 写入、事件处理和服务端输出仍需要分别审查。
如果产品确实要求展示富文本,应采用有明确安全策略的 HTML 消毒方案,并限定允许的元素和属性。不能仅靠字符串替换删除几个标签,也不要把“数据来自自己的接口”视为可信证明:接口内容可能由用户提交,或在上游已经被污染。
六、原生 dialog 的关键行为
showModal() 与 show() 不等价。前者将对话框以模态方式打开,浏览器会阻止用户与对话框外的页面内容进行常规交互,并把页面其他部分置于非活动状态;后者打开的是非模态对话框,背景页面仍可操作。本文的确认框需要用户先做出选择,所以使用 showModal()。
原生对话框通常会在打开时管理焦点,在关闭时尝试把焦点还给此前获得焦点的元素。示例在打开后将焦点移到“确定”按钮,适合操作后果明确且常见用户会继续确认的简单场景。若确认按钮代表删除、付款等高风险操作,默认焦点通常应放在取消按钮,避免用户因键盘误触而执行危险操作。可以调整按钮顺序或在显示后调用 cancelButton.focus(),同时应测试键盘与屏幕阅读器体验。
标题和正文通过 aria-labelledby、aria-describedby 与对话框关联。示例使用固定 ID,因为一次只创建一个对话框。如果应用允许多个实例同时存在,或者对话框内容被改造成可同时挂载多个副本的组件,固定 ID 就可能造成重复;应为每个实例生成唯一 ID。当前模块没有排队机制,也没有限制并发调用。连续调用可能同时创建多个模态对话框,浏览器对这种使用可能报错或产生难以预期的交互,因此应由调用方避免并发打开,或在模块中实现显式的队列与状态管理。
Escape 通常会关闭原生模态对话框。若业务要求用户必须明确选择,例如某项操作尚未保存,确实可能需要拦截关闭行为。但阻止 Escape 关闭会改变键盘交互预期,不能只为了“强制看提示”而随意这么做。应同时提供清晰可见、可通过键盘操作的退出方式,并保证在窄屏、放大文字和键盘导航场景下仍可使用。
七、常见错误
1. 用 CSS 显示对话框,却没有模态行为
给普通元素设置 position: fixed 和高层级,只能让它视觉上覆盖页面。它不会自动限制背景交互、管理焦点,也不会自动提供 Escape 关闭行为。若使用自定义浮层,需要自行完整处理这些行为;简单确认框通常优先考虑原生 <dialog>。
2. 忘记处理取消路径
只监听确认按钮会让 Escape 或其他关闭方式无法得到正确业务结果。本文在 close 事件中统一读取 returnValue,关闭且未标记为确认时返回 false。调用方也应把“取消”和“未确认”视作正常结果,而不是异常。
3. 在每次打开时不断追加节点
若只创建并显示元素,却没有在关闭后移除,重复打开会让 DOM 持续累积。示例的 finish() 会调用 dialog.remove()。如果改为复用同一个元素,则需要重新设置内容、重置结果状态并正确管理事件监听器,不能把创建新实例的代码直接套到复用逻辑上。
4. 把关闭值当作可信业务授权
returnValue 是前端交互状态,不是安全凭证。用户可以控制客户端代码和请求;确认按钮返回 true 只说明当前页面收到了一次确认,不证明用户有权限执行操作,也不证明请求内容没有被篡改。真正执行删除、转账或修改权限等操作时,服务端必须独立认证、授权和校验。
5. 认为对话框解决了所有无障碍问题
原生元素能提供重要基础,但内容本身仍须清晰,按钮名称要有意义,焦点顺序要合理,颜色对比度要足够。用户需要看得出当前正在处理什么,也需要知道如何取消。可使用键盘完成全部操作,并在目标浏览器、触屏设备及辅助技术环境中验证。只在某一台桌面浏览器上点击一次,不足以证明交互符合所有用户的需要。
八、何时需要扩展
如果对话框还要收集输入,可以把表单控件放进 <dialog>,并把提交值作为结果返回。但需要继续处理输入校验、错误提示、表单提交方式及取消时的状态清理。如果需要多个动作,应明确定义每个动作对应的结果值,不要再用简单的真假值表达彼此不同的业务选项。
如果需要长时间运行的异步操作,不应让用户误以为按钮已经生效。可以在确认后关闭对话框,再展示页面内的加载状态;也可以保留对话框并明确标注正在处理,同时避免重复提交。无论采用哪种方式,都要考虑网络失败、用户离开页面以及请求重复发送。对话框只是交互入口,不是异步任务管理机制。
结语
一个实用的确认框不只是几行显示与隐藏代码。本文用原生 <dialog> 实现了一个小型 dialog.js 模块,通过 Promise 返回确认结果,以 textContent 安全地显示文本,并在关闭后清理 DOM。它适合轻量、明确的确认交互,但不包含兼容旧浏览器、富文本、安全授权、并发队列或复杂表单等能力。使用时应把这些边界说清楚:前端负责呈现交互,敏感操作的授权和数据校验仍必须由业务服务端完成。









