搞前端的小伙伴们估计都遇到过这种纠结:想要个弹窗,一看文档,Popover API也行,Dialog API也行,这俩货到底有啥区别?随便选一个会不会后面踩坑?别急,咱今天就把这俩兄弟扒个干净,以后用起来心里门儿清。
摘要 咱今天聊聊网页里做弹窗时,Popover API和Dialog API这对看起来很像但实则天差地别的兄弟。文章会从它俩在无障碍支持上的本质区别说起,掰扯清楚为什么大部分情况无脑选Popover就行,只有做模态框时才需要动用Dialog。咱不光讲道理,还会手把手把两种方案的完整代码流程走一遍,从写HTML到处理焦点、控制属性,让那些小白也能跟着一步步把弹窗功能给整出来。重点会强调普通弹窗和模态框在交互上的核心差异,比如背景层、焦点陷阱这些细节,帮你在实际项目里做出更靠谱的选择。
Popover与Dialog到底差在哪
说白了,Dialog其实是Popover的一个特殊版本,而模态框又是Dialog里更讲究的一种。普通弹窗就是点个按钮,蹦出来一块内容,点旁边或者按Esc就能让它消失;模态框就不一样了,它蹦出来的时候,背后那一大堆页面元素都得“休眠”,用户只能跟这个框框交互,直到把它关掉才能继续干别的。
从视觉上也能一眼分辨:正经的模态框背后会有一层灰蒙蒙的遮罩,告诉用户“后面的东西现在不能碰”;而普通弹窗是不该有这层遮罩的,要是哪个普通弹窗也整了个背景层,那就属于把角色给演错了。
用Popover API搭建弹窗
三行代码搞定
用Popover API做个弹窗,可以说爽到飞起,只需要三个要素就能让它跑起来:
- 在触发按钮上写个
popovertarget属性 - 给弹窗内容一个唯一的
id - 在弹窗元素上加上
popover属性
<button popovertarget="demoPopover">点我打开</button>
<div popover id="demoPopover">
<p>这里随便放点什么内容都行</p>
<button popovertarget="demoPopover" popovertargetaction="hide">关掉</button>
</div>这里用div作为弹窗容器,也可以换成dialog元素,只是把popover属性加上就行。如果用dialog元素,浏览器默认会给它一个“dialog”的角色,这对大部分弹窗场景来说刚刚好。
自带buff加持
这玩意儿最牛的地方在于,写了这几行代码之后,浏览器直接给配齐了一套无障碍支持:
- 焦点自动管理:打开弹窗时,焦点直接飞进弹窗里;关掉弹窗时,焦点又自动回到刚才点的那颗按钮上。
- ARIA属性自动绑定:不用费劲去写
aria-expanded、aria-controls这些,浏览器在背后悄悄都给安排妥当了。 - 轻触关闭:点弹窗外面的区域,或者按Esc键,弹窗就自己关掉,不用多写一行JS。
当然,光有这些还不够,样式上得自己折腾一下,比如给弹窗加个背景色、阴影、定位,这些跟平时写样式没啥区别,就是记得别给它整那个::backdrop伪元素,那是模态框才该用的东西。
用Dialog API打造模态框
为啥非得用这个
既然Popover API这么省事,为啥还要用Dialog API?关键就在“模态”这两个字上。当需要做一个真正意义上的模态框时,Dialog API的那个showModal()方法才是亲爹。它能做到:
- 把页面里其他可交互元素全部“惰化”(inert),让键盘焦点和屏幕阅读器都碰不到它们
- 自动在背后生成那层遮罩层
- 保证用户没法通过Tab键跑到弹窗外面的元素上
这些能力靠Popover API自己折腾出来,那代码量可就海了去了。所以咱的策略很简单:普通弹窗用Popover,模态框才上Dialog。
手把手搭一个模态框
下面咱就把一个完整的模态框从零开始整出来,所有细节都不放过。
先搭好HTML骨架
<button
class="modalInvoker"
data-target="realModal"
aria-haspopup="dialog"
>
打开模态框
</button>
<dialog id="realModal">
<div class="modalContent">
<p>这里放正经的模态框内容</p>
<button class="modalCloser">关掉</button>
</div>
</dialog>这里有个小细节,HTML里没有直接写aria-expanded,因为这东西得靠JS动态控制才靠谱,写死在HTML里反而容易乱套。
初始化所有连接
JS的第一步是把所有触发按钮和对应的模态框绑定起来。咱用data-target来建立关系,这样结构特别清晰,以后复用起来也方便。
const modalInvokers = Array.from(document.querySelectorAll('.modalInvoker'));
modalInvokers.forEach(invoker => {
const modalId = invoker.dataset.target;
const modal = document.querySelector(`#${modalId}`);
invoker.setAttribute('aria-expanded', 'false');
invoker.setAttribute('aria-controls', modalId);
});这一步把aria-expanded初始设为false,aria-controls指向对应的模态框,让辅助技术知道这俩玩意儿是连在一起的。
处理打开动作
当点击触发按钮的时候,需要干两件事:
- 把
aria-expanded改成true,告诉屏幕阅读器“这玩意儿打开了” - 调用
showModal()把模态框亮出来
modalInvokers.forEach(invoker => {
// ... 前面的初始化代码
invoker.addEventListener('click', () => {
const modalId = invoker.dataset.target;
const modal = document.querySelector(`#${modalId}`);
invoker.setAttribute('aria-expanded', 'true');
modal.showModal();
});
});这里不用操心关闭的事儿,因为模态框打开后,用户根本点不到外面那个按钮。
给模态框配个关闭按钮
因为showModal()出来的东西默认不会轻触关闭,用户点遮罩层或者按Esc都不会鸟它,所以必须在模态框内部放一个关闭按钮。
const modalClosers = Array.from(document.querySelectorAll('.modalCloser'));
modalClosers.forEach(closer => {
const modal = closer.closest('dialog');
const modalId = modal.id;
const invoker = document.querySelector(`[data-target="${modalId}"]`);
closer.addEventListener('click', () => {
modal.close();
invoker.setAttribute('aria-expanded', 'false');
invoker.focus();
});
});这里做了几件事:把模态框关掉、把触发按钮的aria-expanded改回false、再把焦点还给那个按钮。这样整个交互就闭环了。
| API | 焦点管理 | ARIA支持 | 轻触关闭 | 模态背景 |
|---|---|---|---|---|
| Popover | 自动处理 | 自动绑定 | 自带功能 | 不支持 |
| Dialog | 需手动实现 | 需手动补充 | 需代码实现 | showModal时自带 |
能不能用Popover做模态框
理论上是可以的,但那样就得自己动手解决两个大问题:一是把所有其他元素惰化掉,让它们不能被Tab键选中也不能被屏幕阅读器读到;二是自己写代码实现焦点陷阱,保证Tab键只能在模态框内部循环。这活儿可比给Dialog API补点属性麻烦多了,属于自找苦吃。
未来可能有新玩法
目前有个关于invoker commands的提案正在路上,如果这东西落地了,Dialog API也能像Popover API那样直接用属性控制,到时候做模态框可能就更简单了。不过在目前这阶段,咱还是得老老实实按上面的套路来。
上面写的这些代码都是最基础的可用版本,足够让小白跑通整个流程。至于样式美化、动画过渡、更复杂的交互场景,咱可以留到下次再继续深挖。总之一句话:大部分时候Popover API就是那个省心省力的选择,只有当需求明确需要模态行为时,再搬出Dialog API来镇场子。
