如何用 dialog.js 实现自定义弹窗

如何用 dialog.js 实现自定义弹窗

如何用 dialog.js 实现自定义弹窗

弹窗看起来只是一个浮层,真正做稳却要处理不少细节:键盘操作、焦点管理、取消行为、页面滚动、内容安全和浏览器兼容性。与其从零用 div 模拟模态框,不如先考虑浏览器提供的原生 <dialog>。

本文中的 dialog.js 是一个自行编写的轻量工具模块,不是某个特定的第三方 npm 包。它基于原生 HTMLDialogElement,提供 alert 和 confirm 两种常见交互,并演示如何安全地插入文本。若你使用的是同名第三方库,请以该库的文档为准;不同项目中叫作 dialog.js 的库,参数和能力并不通用。

一、适用场景与不适用场景

自定义弹窗适合需要用户立即处理、且不适合放在普通页面流程里的操作,例如:

  • 删除、退出、覆盖等操作前的二次确认。
  • 需要用户确认结果后,程序才能继续执行的流程。
  • 简短提示、错误说明或重要状态信息。
  • 需要在当前页面完成的小型表单或设置面板。

以下情形通常不适合用模态弹窗解决:

  • 持续显示的提示、后台任务进度或不需要立即处理的通知。可考虑状态区域、非模态提示或页面内消息。
  • 内容较长、需要频繁查阅或比较的说明。弹窗会遮挡上下文,独立页面往往更合适。
  • 必须保证业务操作成功的场景。弹窗只能表达和收集用户意图,不能替代服务器端鉴权、校验或并发控制。

二、为什么优先使用原生 dialog

原生 <dialog> 元素通过 showModal() 进入模态状态。在模态状态下,浏览器会将对话框置于顶层,并让对话框之外的页面内容处于非交互状态。它还提供 close()、cancel、close 等原生行为,减少自行维护焦点和键盘交互的工作。

这不意味着原生元素自动解决所有无障碍问题。仍需提供清晰的标题和说明、可见的操作按钮、合理的初始焦点,以及符合业务语义的取消行为。对于自定义复杂表单,还应实际测试屏幕阅读器、键盘操作和目标浏览器。

原生 dialog 已被当前主流浏览器支持,但旧浏览器或特定嵌入式 WebView 的支持情况可能不同。项目如果需要覆盖这类环境,应依据目标浏览器清单测试,并按需引入经过评估的 polyfill;不能只因为页面中写了 <dialog>,就假定所有环境都能正确实现模态行为。

三、项目结构与最小实现

下面假设项目使用 JavaScript 模块。将工具保存为 dialog.js,页面通过 type="module" 引入。此示例会在每次调用时创建一个原生对话框,关闭后移除节点。

// dialog.js
const DEFAULTS = {
  title: '提示',
  confirmText: '确定',
  cancelText: '取消'
};

function createDialog({
  title = DEFAULTS.title,
  message = '',
  confirmText = DEFAULTS.confirmText,
  cancelText = DEFAULTS.cancelText,
  showCancel = true,
  closeOnBackdrop = false
} = {}) {
  const dialog = document.createElement('dialog');
  dialog.className = 'app-dialog';

  const heading = document.createElement('h2');
  heading.className = 'app-dialog__title';
  heading.textContent = title;

  const content = document.createElement('p');
  content.className = 'app-dialog__message';
  content.textContent = message;

  const actions = document.createElement('div');
  actions.className = 'app-dialog__actions';

  if (showCancel) {
    const cancelButton = document.createElement('button');
    cancelButton.type = 'button';
    cancelButton.className = 'app-dialog__button';
    cancelButton.textContent = cancelText;
    cancelButton.addEventListener('click', () => {
      dialog.close('cancel');
    });
    actions.append(cancelButton);
  }

  const confirmButton = document.createElement('button');
  confirmButton.type = 'button';
  confirmButton.className = 'app-dialog__button app-dialog__button--primary';
  confirmButton.textContent = confirmText;
  confirmButton.addEventListener('click', () => {
    dialog.close('confirm');
  });
  actions.append(confirmButton);

  dialog.append(heading, content, actions);
  document.body.append(dialog);

  if (closeOnBackdrop) {
    dialog.addEventListener('click', (event) => {
      if (event.target === dialog) {
        dialog.close('cancel');
      }
    });
  }

  return new Promise((resolve) => {
    dialog.addEventListener('close', () => {
      const result = dialog.returnValue === 'confirm';
      dialog.remove();
      resolve(result);
    }, { once: true });

    try {
      dialog.showModal();
      confirmButton.focus();
    } catch (error) {
      dialog.remove();
      throw error;
    }
  });
}

export function alert(message, options = {}) {
  return createDialog({
    ...options,
    message,
    showCancel: false
  });
}

export function confirm(message, options = {}) {
  return createDialog({
    ...options,
    message,
    showCancel: true
  });
}

这里用 dialog.close('confirm') 和 dialog.close('cancel') 明确记录关闭原因,最终通过 returnValue 转成布尔值。点击浏览器提供的关闭按钮或按 Escape 关闭时,returnValue 不会是 'confirm',因此 confirm() 会返回 false。

示例中的 alert() 也返回 Promise:确认后结果是 true,通过 Escape 等方式关闭时是 false。如果项目希望 alert() 只表示“已关闭”,可以将它改成不暴露业务布尔值的接口;关键是要在 API 文档中说清楚返回值,而不是让调用方猜测。

四、为弹窗添加样式

<dialog> 有浏览器默认样式,应显式设置宽度、边框、内边距和背景遮罩。下面的样式使用带前缀的类名,尽量避免影响页面其他按钮和标题。

/* styles.css */
.app-dialog {
  width: min(28rem, calc(100vw - 2rem));
  max-width: none;
  padding: 1.25rem;
  border: 0;
  border-radius: 0.75rem;
  color: #1f2937;
  background: #fff;
  box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 22%);
}

.app-dialog::backdrop {
  background: rgb(15 23 42 / 55%);
}

.app-dialog__title {
  margin: 0 0 0.75rem;
  font-size: 1.25rem;
  line-height: 1.4;
}

.app-dialog__message {
  margin: 0;
  line-height: 1.6;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.app-dialog__actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.75rem;
  margin-top: 1.5rem;
}

.app-dialog__button {
  min-height: 2.5rem;
  padding: 0.5rem 0.9rem;
  border: 1px solid #cbd5e1;
  border-radius: 0.4rem;
  color: #1f2937;
  background: #fff;
  font: inherit;
  cursor: pointer;
}

.app-dialog__button--primary {
  border-color: #2563eb;
  color: #fff;
  background: #2563eb;
}

.app-dialog__button:focus-visible {
  outline: 3px solid #93c5fd;
  outline-offset: 2px;
}

@media (prefers-reduced-motion: no-preference) {
  .app-dialog[open] {
    animation: dialog-enter 140ms ease-out;
  }

  @keyframes dialog-enter {
    from { opacity: 0; transform: translateY(0.35rem); }
    to { opacity: 1; transform: translateY(0); }
  }
}

在动画示例中只对打开状态做了简单进入动画。若要增加退出动画,需要设计关闭状态和清理时机;不能在调用 close() 后立刻移除元素,再期待退出动画继续播放。也要尊重用户的减少动态效果偏好,不要把动画设成不可关闭的交互前提。

五、在页面中调用

把模块、样式接入页面后,可以在事件处理函数中使用 await 等待用户选择。注意调用确认框通常要放在用户操作触发的异步流程里,并在用户取消后及时结束操作。

<button id="delete-button" type="button">删除记录</button>

<script type="module">
  import { confirm, alert } from './dialog.js';

  const deleteButton = document.querySelector('#delete-button');

  deleteButton.addEventListener('click', async () => {
    const accepted = await confirm('确定要删除这条记录吗?', {
      title: '删除记录',
      confirmText: '删除',
      cancelText: '保留'
    });

    if (!accepted) return;

    try {
      // 这里仅示意:真实项目应调用后端删除接口。
      await deleteRecord();
      await alert('记录已删除。', { title: '操作完成' });
    } catch (error) {
      await alert('删除失败,请稍后重试。', { title: '操作失败' });
      console.error(error);
    }
  });

  async function deleteRecord() {
    // 替换为真实业务请求。
  }
</script>

调用方负责业务逻辑:弹窗确认不代表服务端已经完成删除。实际执行前后端仍需检查用户权限、资源状态和请求结果;对于可能重复提交的操作,还应考虑幂等性或按钮禁用等机制。

六、参数的含义与边界

上面的工具只接受明确列出的选项:

  • title:标题文本,默认是“提示”。
  • message:正文文本,默认是空字符串。
  • confirmText:确认按钮文字,默认是“确定”。
  • cancelText:取消按钮文字,默认是“取消”。
  • showCancel:是否显示取消按钮。公开的 alert() 会强制关闭此选项,confirm() 会启用它。
  • closeOnBackdrop:是否允许点击对话框外的遮罩关闭。默认关闭。

这些参数是本例自行定义的接口,不是浏览器原生 <dialog> 的参数,也不是某个通用 dialog.js 包的标准参数。代码没有加入宽度、按钮回调、HTML 模板、自动消失时间等能力;调用这些未实现的选项不会自动生效。

JavaScript 默认参数只会在传入值为 undefined 时使用默认值,传入 null 并不会被自动替换。当前实现把标题和正文交给 textContent:普通字符串可以正常显示,但对象会被转换成字符串,null 也不会被视为一个有明确业务含义的消息。因此实际项目可以在公共 API 入口增加类型校验,例如要求消息和按钮文字必须是字符串。不要把“设置了默认值”误解为“自动验证了所有输入”。

同样,closeOnBackdrop 不是普适的交互规范。对不可逆操作,允许点遮罩关闭可能让用户错过重要说明;对某些普通提示,用户又可能期待点击遮罩退出。应按操作风险和产品要求决定,并确保取消行为不会造成意外数据丢失。

七、内容安全:默认按文本处理

本例使用 textContent 写入标题、正文和按钮文字。这会把输入当作纯文本,而不是 HTML 解析。例如消息是 <img src=x onerror=alert(1)> 时,用户看到的是这段字符,而不是执行其中的标签。这是展示外部输入时更稳妥的默认做法。

不要为了“支持富文本”而直接把不可信内容赋给 innerHTML。如果确实要渲染用户提交的 HTML,必须使用经过维护和正确配置的 HTML 清洗方案,并限定允许的元素和属性;不能只靠正则表达式去除几个危险标签。就算内容经过清洗,也应明确清洗策略、依赖升级和信任边界。

弹窗也不是安全隔离层。它不会自动验证用户输入,不会让前端密钥变安全,不会替代服务端授权,也不会阻止用户修改客户端代码。诸如“确认后转账”“确认后删除账号”等操作,仍须由后端按当前用户身份和服务端数据重新验证。

八、键盘、焦点与辅助技术

模态对话框的关键不只是视觉遮罩。使用 showModal() 打开原生对话框时,浏览器会为模态状态提供相应的交互行为;对话框关闭后,浏览器通常会将焦点恢复到打开对话框前的元素。代码把焦点主动放到确认按钮,是为了让打开后的操作入口明确。

实际项目至少应检查以下内容:

  • 打开弹窗时,焦点是否进入对话框,并落在合理的位置。
  • 用户能否只用键盘在按钮之间移动、执行操作和取消。
  • 标题和正文是否能被辅助技术理解,按钮文字是否准确描述动作。
  • 对长消息和窄屏布局,内容是否可滚动,操作按钮是否仍容易触达。
  • 按 Escape 关闭是否符合该操作的业务要求。

本例通过 <h2> 和普通段落提供标题、正文,没有手动设置 aria-labelledby 或 aria-describedby。如果要为对话框显式关联标题与说明,可以给标题、说明设置唯一的 id,再设置对应的 ARIA 属性;若同一时间可能创建多个弹窗,必须保证这些 ID 不重复。不要因为添加了 ARIA 属性,就省略可见标题或真实的键盘测试。

本例没有提供焦点陷阱实现,因为模态原生对话框通常由浏览器处理对话框外的交互限制。若目标环境不支持原生模态对话框而改用普通 div,就不能直接照搬这里的代码:需要重新实现焦点进入、Tab 循环、背景内容不可交互、Escape 处理、关闭后的焦点恢复等行为,或采用经过验证的无障碍组件。

九、常见错误及修正

1. 用 show() 代替 showModal()

dialog.show() 打开的是非模态对话框,不会获得 showModal() 所提供的模态状态。若产品要求用户处理弹窗后才能继续操作,应使用 showModal(),并确认调用前对话框尚未处于不兼容的打开状态。重复调用或错误状态下调用可能抛出异常,应用可在统一入口处理,但不应通过静默吞掉异常掩盖调用错误。

2. 把消息拼进 innerHTML

将用户输入、接口数据或 URL 参数插入 innerHTML 会引入 HTML 注入风险。本例使用 textContent,如果业务需要结构化内容,应使用 DOM API 创建受控节点,或采用可信的清洗方案。

3. 只做视觉遮罩,不处理模态行为

自行给页面加一层半透明 div,并不等同于模态对话框。背景仍可能接收键盘焦点,读屏用户也未必能识别弹窗。除非确实需要支持不具备原生能力的环境并愿意承担完整实现与测试,否则优先使用原生 <dialog>。

4. 忽略关闭来源

确认按钮、取消按钮和 Escape 都可能关闭弹窗,但业务含义不一定相同。本例将只有确认按钮产生的关闭结果映射为 true,其余都视为未确认。若业务必须区分“取消”“Escape”“点击遮罩”等原因,应在关闭前记录原因,并明确定义 Promise 返回类型,例如返回 'confirm'、'cancel' 或 'dismiss',不要用含糊的真值判断代替业务约定。

5. 弹窗连续堆叠或重复提交

连续触发事件可能同时打开多个对话框,也可能重复发送业务请求。原生对话框管理解决不了请求并发问题。对于不允许重复执行的操作,应该在业务层设置进行中状态,禁用相关按钮或复用同一个未完成的 Promise,并在请求结束后恢复状态。不要假设浏览器会替你合并请求。

6. 把所有信息都放进一个很小的弹窗

消息长度、按钮数量和屏幕空间都会影响可用性。短文本可以使用本文的固定结构;需要输入、滚动长文、多个字段或复杂验证时,应扩展为独立的表单组件,并考虑错误提示、提交状态、关闭确认和移动端布局。过度增加配置项会让工具难以维护,复杂交互通常适合单独建模,而不是继续给简单弹窗增加大量布尔参数。

十、改造成可复用组件前的检查清单

将此示例用于正式项目之前,可以逐项确认:

  • 项目目标浏览器和 WebView 是否支持原生模态 <dialog>,不支持时采用什么降级方案。
  • 弹窗接口的参数类型、默认值、返回值和关闭规则是否写进项目文档。
  • 标题、正文和按钮文字是否始终以文本安全地插入。
  • 调用方是否在确认后仍执行必要的服务端校验。
  • 焦点、键盘、窄屏、长文本和减少动态效果偏好是否经过测试。
  • 重复打开、重复提交、请求失败和组件卸载时是否有明确处理。

一个小型 dialog.js 的价值,在于统一常见交互、减少重复代码,而不是承诺覆盖所有弹窗需求。基于原生 <dialog> 可以快速获得可靠的模态基础;将消息按纯文本展示、清楚定义返回值,并把业务校验留在正确的服务端边界,才是让弹窗既好用又不制造安全错觉的关键。

© 版权声明

相关文章