用 dialog.js 实现一个简单的网页对话框

用 dialog.js 实现一个简单的网页对话框

用 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。它适合轻量、明确的确认交互,但不包含兼容旧浏览器、富文本、安全授权、并发队列或复杂表单等能力。使用时应把这些边界说清楚:前端负责呈现交互,敏感操作的授权和数据校验仍必须由业务服务端完成。

© 版权声明

相关文章