弹窗到底用 Popover API 还是 Dialog API,这俩玩意儿到底有啥不一样?

4,522字
19–29 分钟
in

做前端开发的时候,经常会碰到需要弹出一个浮层的情况,比如点击按钮出来个菜单,或者提交表单前弹个确认框。以前大家可能随手就用个 div 加上绝对定位糊弄过去,但现在浏览器给咱们提供了两个正儿八经的API:Popover API 和 Dialog API。看着这哥俩功能好像差不多,都能让东西“弹”出来,可真到上手的时候,选哪个简直能把人逼疯。扒了无数资料才发现,这俩在无障碍访问这个硬指标上,路子完全不一样。今天就用大白话掰扯清楚,到底啥时候用哪个,怎么用才能不出岔子。

目录

核心概念先整明白

Popover 是啥

Popover API 可以理解成浏览器自带的一套“临时弹出层”解决方案。只要在元素上写个 popover 属性,再找个按钮用 popovertarget 指向它的ID,点击按钮,这块内容就能优雅地弹出来。它最大的好处就是省事儿,不用写一行JavaScript,浏览器自动帮你处理好了焦点管理、点外面关闭、按ESC关闭这些烦人的事儿。

Dialog 又是啥

Dialog API 是用来做对话框的,它有两种形态:一种是普通的非模态对话框(show方法打开),另一种是模态对话框(showModal方法打开)。模态对话框打开的时候,页面上其他元素对用户来说都是“不可见”的,不管是鼠标点击还是键盘Tab键,都离不开这个对话框,必须得处理完对话框里的内容才能继续操作。这就很适合用来做那种必须用户做出选择的弹窗,比如删除确认、重要协议弹窗。

这俩的区别说白了就一句话

Popover 适合做那种“非模态”的临时浮层,像下拉菜单、提示框、操作菜单这些。而 Dialog 配合 showModal 方法,是专门用来做“模态对话框”的。千万别用 Popover 去做模态框,也别用普通的 Dialog 去模拟 Popover,那样会给自己挖坑。

方案一:用 Popover API 撸一个标准弹窗

下面手把手演示怎么用 Popover API 做一个带关闭按钮、点外面能自动关的弹窗。

第一步:搭架子

<button popovertarget="demoPopover">
  点我弹出内容
</button>

<div popover id="demoPopover">
  <p>这里是弹窗里的内容,可以是任何东西</p>
  <button popovertarget="demoPopover" popovertargetaction="hide">
    关闭
  </button>
</div>

这里要注意的是,<div> 上的 popover 属性告诉浏览器这是个弹窗,id 用来和按钮的 popovertarget 关联。关闭按钮同样用 popovertarget 指向同一个ID,再通过 popovertargetaction="hide" 告诉浏览器点它是用来关闭弹窗的。如果不在关闭按钮上指定这个动作,按钮点下去就是默认的切换(toggle)效果,可能会让用户困惑。

第二步:样式处理(关键点在这)

默认的弹窗样式很丑,而且位置默认在视口中间。咱们得给它调整一下位置,顺便加点动画。

[popover] {
  margin: 0;
  border: none;
  padding: 1rem;
  border-radius: 8px;
  background: white;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);

  /* 定位到触发按钮附近,这里简单演示 */
  position: absolute;
  top: 50px;
  left: 50px;
}

/* 动画效果,让弹窗出现得自然点 */
[popover]:popover-open {
  animation: fadeIn 0.2s ease;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

这里有个容易踩坑的点:千万别给 [popover]::backdrop 伪元素加样式。::backdrop 是用来做模态框背景遮罩的,给普通弹窗加上遮罩会误导屏幕阅读器,让它们以为这是个需要打断用户操作的模态框。如果确实需要背景遮罩,那说明这个弹窗应该用 dialogshowModal 来实现。

第三步:完善行为(其实啥也不用写)

到了这一步,弹窗已经能正常工作了。点按钮弹出来,点关闭按钮或外面任意位置或按ESC键,弹窗都会乖乖关上。浏览器自动处理了焦点:弹窗打开时焦点自动移到弹窗内(会尝试聚焦到第一个可聚焦元素),关闭后焦点回到触发按钮上。aria-expanded 这些属性也完全不用操心,浏览器会在底层替我们管理。

方案二:用 Dialog API 打造一个正经模态框

当需要做那种“不点确定就不能操作页面其他部分”的对话框时,就得用 showModal 了。下面演示从头到尾搭建一个带背景遮罩、具备完整无障碍支持的模态框。

第一步:准备 HTML 结构

<button 
  class="modal-invoker" 
  data-target="confirmModal"
>
  打开重要确认框
</button>

<dialog id="confirmModal">
  <div>
    <h2>确认操作</h2>
    <p>这个操作不可逆,确定要继续吗?</p>
    <div>
      <button class="modal-cancel">取消</button>
      <button class="modal-confirm">确认</button>
    </div>
  </div>
</dialog>

这里在触发按钮上用 data-target 来记录对应的模态框ID,而没有用 idclass 直接关联,是为了让逻辑更清晰。dialog 元素本身不包含 popover 属性,因为它要走 showModal 这条路。

第二步:用 JavaScript 实现关联和打开

const modalTriggers = document.querySelectorAll('.modal-invoker');

modalTriggers.forEach(trigger => {
  const targetId = trigger.dataset.target;
  const modal = document.getElementById(targetId);

  // 手动建立 aria 关系
  trigger.setAttribute('aria-expanded', 'false');
  trigger.setAttribute('aria-controls', targetId);
  trigger.setAttribute('aria-haspopup', 'dialog');

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

注意,这里必须手动设置 aria-expandedaria-controls。因为 dialog 元素默认不会自动和触发按钮建立任何无障碍连接,如果不做这一步,屏幕阅读器用户根本不知道这个按钮会打开什么东西。aria-haspopup="dialog" 是用来告诉辅助技术,这个按钮会弹出一个对话框,属于锦上添花但很必要的细节。

第三步:实现关闭逻辑

模态框内必须提供关闭方式。我们给“取消”和“确认”按钮都加上关闭逻辑,并且要记得把焦点还给原来的触发按钮。

const modals = document.querySelectorAll('dialog');

modals.forEach(modal => {
  const cancelBtn = modal.querySelector('.modal-cancel');
  const confirmBtn = modal.querySelector('.modal-confirm');
  const trigger = document.querySelector(`[data-target="${modal.id}"]`);

  const closeModal = () => {
    modal.close();
    if (trigger) {
      trigger.setAttribute('aria-expanded', 'false');
      trigger.focus();
    }
  };

  cancelBtn.addEventListener('click', closeModal);
  confirmBtn.addEventListener('click', () => {
    // 这里可以放确认后的业务逻辑,比如提交数据
    console.log('用户确认了操作');
    closeModal();
  });
});

这里有个细节,modal.close() 执行后,浏览器默认会回收焦点,但通常会把焦点直接放到 <body> 上,这会让用户迷失。所以必须手动调用 trigger.focus(),把焦点还给当初打开模态框的那个按钮,这样键盘用户才能接着按Tab键继续操作。另外,aria-expanded 的状态也要同步更新,否则辅助技术会认为对话框还开着。

第四步:样式与遮罩

dialog {
  border: none;
  border-radius: 12px;
  padding: 0;
  background: white;
  width: 90%;
  max-width: 400px;
  box-shadow: 0 10px 25px rgba(0,0,0,0.2);
}

/* 只有模态框才有的背景遮罩样式 */
dialog::backdrop {
  background-color: rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(2px);
}

这里的 ::backdrop 只应该出现在 dialog 的样式里,而且是配合 showModal 使用才有效果。要是给 popover 元素写这个伪类样式,就属于越界行为了。背景遮罩能让用户一眼就知道后面的内容暂时不能操作,这也是模态框的核心体验之一。

实际开发怎么选

日常开发中,大部分弹窗场景都可以无脑上 Popover API,因为它真的省心。比如做表格里的编辑面板、导航栏的下拉菜单、消息提示这些小东西,用 Popover 两三行代码搞定,还自带无障碍支持。

只有当业务明确要求“必须做出选择才能继续”的场景,比如删除数据前的确认、提交前展示重要条款,才值得动用 Dialog 加 showModal 这套组合拳。虽然要手动处理 aria 状态和焦点管理,但换来的严谨的模态交互是值得的。

有人可能会问,能不能用 Popover 去模拟模态框,然后自己写遮罩和焦点陷阱?理论上可以,但这就把简单问题复杂化了,而且 Popover 本身没有“其他元素不可交互”的概念,强行实现会跟浏览器的默认行为打架,搞不好出现焦点飘移的诡异bug,得不偿失。

写在最后

现在浏览器的支持情况已经很乐观,两个 API 都能稳定使用。Popover API 的出现,确实让开发临时浮层变得跟写 HTML 标签一样简单。而 Dialog API 在 showModal 模式下的严谨性,依然是做重要弹窗的不二选择。以后遇到弹窗需求,先问自己一句“这玩意需要用户必须处理才能干别的吗”,答案也就出来了。