在日常开发中,我们经常需要创建各种弹出层:下拉菜单、提示框、模态对话框……过去,我们依赖各种JavaScript库或手动管理焦点和ARIA状态。现在,浏览器直接提供了两个强大的API:Popover APIDialog API。但问题来了——它们都能创建弹出层,到底该用哪个?

如果你简单地将它们混为一谈,可能会写出可访问性糟糕的代码。本文将通过深入剖析、代码演示和社区智慧的结晶,帮你彻底理清两者的关系,并掌握正确用法。

一、核心区别:不是谁替代谁,而是各司其职

很多人误以为Dialog API是Popover API的升级版,或者Popover API可以完全替代Dialog API。实际上,它们的设计目标完全不同:

  • Popover API:致力于让 非模态弹出层 的创建变得极其简单。它内置了焦点管理、ARIA关联和轻触关闭,开发者只需写几行HTML就能得到一个可访问的弹出层。

  • Dialog API:专注于提供 真正的模态对话框 能力。它的showModal() 方法可以自动将页面其他部分置为 inert ,创建出无法跳出的模态上下文,这是Popover API无法直接实现的。

形象类比:Popover API像是一个轻量级的、随时可关闭的便利贴;而Dialog API则像是一个需要你处理完才能继续工作的“模态对话框”——比如确认删除的弹窗。

二、Popover API:简单到令人惊叹

2.1 基础用法

创建一个弹出层只需三个步骤:

  1. 在触发器上设置 popovertarget 属性,值指向弹出层的 id。

  2. 给弹出层设置一个唯一的 id。

  3. 在弹出层上添加 popover 属性。

<button popovertarget="menu">打开菜单</button>
<div popover id="menu">
  <ul>
    <li>选项一</li>
    <li>选项二</li>
  </ul>
</div>

2.2 内置的超能力

仅仅两行HTML,你就获得了:

  • 自动焦点管理:打开时焦点移到弹出层内第一个可聚焦元素,关闭时返回触发器。

  • 自动ARIA状态:浏览器自动处理 aria-expanded、aria-haspopup、aria-controls,无需手动添加。

  • 自动轻触关闭:点击外部或按 Esc 键自动关闭。

  • 顶层渲染:无需担心 z-index,弹出层始终显示在最上层。

2.3 最佳实践:与 <dialog>元素结合

虽然任何元素都可以添加 popover 属性,但强烈建议使用 <dialog> 元素来承载弹出内容,因为它天然带有 dialog 角色,语义更准确:

<button popovertarget="tip">提示</button>
<dialog popover id="tip">
  <p>这是一个带有dialog角色的弹出层,屏幕阅读器会正确识别。</p>
</dialog>
2.4 必须避免的错误

绝对不要为Popover添加 ::backdrop样式!背景层是模态对话框的专属视觉特征。给弹出层添加背景层会让用户困惑,以为这是一个模态对话框,但实际行为却是非模态的,造成认知失调。

三、Dialog API:强大的模态能力,但需要更多关怀

3.1 基础用法

<dialog>元素有两种打开方式:

  • show():打开为非模态,类似Popover(但不包含Popover的自动功能)。

  • showModal():打开为模态,自动设置 inert 并阻止与外部交互。

示例:

<button id="openModal">打开模态框</button>
<dialog id="modal">
  <h2>确认删除?</h2>
  <button id="confirm">确认</button>
  <button id="cancel">取消</button>
</dialog>

<script>
  const modal = document.getElementById('modal');
  document.getElementById('openModal').addEventListener('click', () => {
    modal.showModal();
  });
  document.getElementById('cancel').addEventListener('click', () => {
    modal.close();
  });
</script>
3.2 showModal() 的核心价值

showModal() 为你自动处理了模态对话框最复杂的部分:

  • 使页面其他元素inert:用户无法通过点击或Tab键离开对话框。

  • 焦点陷阱:焦点被限制在对话框内,无需手动监听焦点事件。

  • 顶层显示:同样无需操心 z-index。

3.3 你需要自己处理什么?

与Popover API相比,Dialog API提供的是底层能力,你需要手动增强可访问性:

  • 关联触发器与对话框:使用 aria-haspopup="dialog"。

  • 管理焦点:使用 autofocus 属性设置默认焦点元素。

  • 实现外部点击关闭(轻触关闭):默认不支持,需要自己写代码。

  • 处理Esc关闭后的焦点返回:默认Esc关闭后焦点不会回到触发器,需要监 close 事件。

3.4 社区验证的最佳实践(纠正常见误区)

根据最新的可访问性研究和屏幕阅读器测试,以下做法是错误的:

  • 在触发器上使用aria-expanded:这个属性用于展开/折叠后焦点不移动的组件(如手风琴)。对于打开对话框的按钮,正确属性是 aria-haspopup="dialog"。

  • 使用aria-controls连接触发器和对话框:该属性的实际支持度很低,且焦点管理已经建立了足够清晰的关联,属于冗余代码。

正确做法

<button class="modal-trigger" data-modal-id="my-modal" aria-haspopup="dialog">
  打开对话框
</button>
<dialog id="my-modal" aria-labelledby="modal-title">
  <h2 id="modal-title">编辑资料</h2>
  <!-- 表单内容 -->
  <button autofocus>保存</button>
</dialog>

3.5 完整可访问的模态对话框示例

结合最新特性 closedby="any" (使点击外部关闭更简洁)和降级方案,提供一个健壮实现:

const triggers = document.querySelectorAll('[data-modal-id]');

triggers.forEach(trigger => {
  const modalId = trigger.dataset.modalId;
  const modal = document.getElementById(modalId);
  const closeButtons = modal.querySelectorAll('[data-dismiss="modal"]');

  // 打开模态框
  trigger.addEventListener('click', () => {
    modal.showModal();
  });

  // 关闭模态框并返回焦点
  const closeModal = () => {
    modal.close();
    trigger.focus();
  };

  closeButtons.forEach(btn => btn.addEventListener('click', closeModal));

  // 处理 Esc 关闭后的焦点返回(默认行为只是关闭,不返回焦点)
  modal.addEventListener('close', () => {
    // 但我们需要确保焦点回到触发器
    // 注意:这里需要判断是否是因为Esc关闭,但简单处理:只要关闭就返回焦点?
    // 更好的做法:在close事件中判断document.activeElement是否还在dialog内,但比较复杂。
    // 一个简单方案:在keydown中监听Esc并手动处理焦点,但可能重复。
    // 为了简洁,我们可以在close事件中设置一个标志,但这里演示另一种方法:
    // 监听cancel事件(Esc触发)来处理焦点返回
  });

  // 监听 cancel 事件(用户按 Esc 触发)
  modal.addEventListener('cancel', () => {
    trigger.focus();
  });

  // 外部点击关闭(轻触关闭)
  if ('closedby' in HTMLDialogElement.prototype) {
    // 使用新特性 closedby="any"
    modal.setAttribute('closedby', 'any');
  } else {
    // 降级方案:监听对话框外部点击
    modal.addEventListener('click', (e) => {
      const rect = modal.getBoundingClientRect();
      const isOutside = e.clientX < rect.left || e.clientX > rect.right ||
                        e.clientY < rect.top || e.clientY > rect.bottom;
      if (isOutside) {
        closeModal();
      }
    });
  }
});

注意:closedby属性是一个较新的提案,已在Chrome和Edge中可用,但使用前请检查兼容性。

四、选择指南:一表帮你决策

场景

推荐API

理由

工具提示、下拉菜单、通知提示

Popover API

简单,内置可访问性,无需写JS

需要临时展示内容,但不希望用户中断任务流

Popover API

轻触关闭,自然融入交互

必须用户确认或填写的表单(模态)

Dialog API + showModal()

自动inert其他元素,防止误操作

复杂的交互式弹窗,需要完全控制行为

Dialog API

提供底层API,灵活自定义

五、未来:Invoker Commands 让一切更简单

目前有一个已广泛实现的提案:Invoker Commands。它允许你通过HTML属性直接控制对话框的打开和关闭,就像Popover API一样简单:

<button commandfor="my-modal" command="show-modal">打开模态框</button>
<dialog id="my-modal">
  <button commandfor="my-modal" command="close">关闭</button>
</dialog>

这意味着未来你甚至不需要为模态对话框写任何JavaScript打开/关闭逻辑!但在全面普及前,上述JS增强方案仍是可靠的保障。

总结

  • Popover API 是你的默认选择,用它来构建绝大多数非模态弹出层,省时省力,开箱即用。

  • Dialog API 只在需要真正的模态体验时使用,配合本文提供的可访问性增强代码,可以构建出专业级的模态对话框。

  • 避免常见陷阱:不要给Popover加背景层,不要在对话框触发器上用 aria-expanded,理解 aria-haspopup 的正确用法。

  • 拥抱未来:关注 closedby 和 commandfor 等新特性,它们将进一步简化开发。

现在,你已经掌握了这两个API的精髓,可以自信地在项目中做出正确选择,并为所有用户提供一致、可访问的体验。记住,好的弹出层不仅看起来美,更要让每个人都能轻松使用。

彩蛋

文中配有完整示例,点击《popover 和 dialog api 示例》即可查看。