跳到正文

用 Vue 3 封装原生 dialog 和 popover

编辑资料时,需要一个阻止背景操作的弹窗;邮箱旁边的说明按钮,又只需要展开一小段提示。这两种界面都浮在页面上,但对焦点、关闭方式和背景交互的要求不同。以前写组件,要自己处理这些行为,再借助 Teleport、遮罩和 z-index 解决显示位置。

浏览器提供的 <dialog> 和 Popover API 已经接走了其中一部分工作。模态 dialog 可以让背景变为不可交互,popover 可以原生处理点击外部关闭;两者还能进入 Top Layer,绕开普通层叠上下文。到了 Vue 项目里,剩下的问题变成了另一组具体约束:父组件的 v-model 怎样和浏览器状态同步,Esc 关闭怎样回传,关闭动画结束前是否保留模态,以及原有 Select、Tooltip 能不能继续正常工作。

这篇文章以 Vue 3.5.44 的普通 VDOM 为实现基线,提供两个可运行组件,并比较组件协调动画和纯 CSS 动画的取舍。关于分层界面、::backdrop 与现代进退场动画的讨论,借鉴了大漠的《Web UI:用 dialog 和 popover 搭建分层界面》;示例、机制图和工程判断按当前规范重新组织,兼容数据核对于 2026 年 11 月 1 日。

浏览器已经负责了哪些行为

把一个 div 显示出来,只完成了弹窗的视觉部分。真正的模态还要限制背景交互、安排初始焦点、避免 Tab 进入背景控件,并在适当时机恢复焦点。非模态说明则通常允许用户继续操作页面,没有必要把背景一起锁住。

dialog 的模态与非模态

原生接口很短,打开方式却直接决定行为:

<button id="edit" type="button">编辑资料</button>
<dialog id="profile-dialog" aria-labelledby="profile-title">
  <h2 id="profile-title">编辑资料</h2>
  <form method="dialog">
    <button value="cancel">取消</button>
    <button value="save">保存</button>
  </form>
</dialog>
const dialog = document.getElementById('profile-dialog');
document.getElementById('edit').addEventListener('click', () => dialog.showModal());
dialog.addEventListener('close', () => console.log(dialog.returnValue));

showModal() 会打开模态 dialog,将它放进 Top Layer,同时让同一文档中对话框及其后代之外的内容变为 inert。这里的 inert 是浏览器对交互和可访问性的限制,不需要给背景节点逐个添加属性。通过 .show() 打开,或者直接设置 open 属性,则属于非模态显示,不获得同样的背景限制和顶层呈现。showModal 的行为

按钮上的 commandfor="profile-dialog" command="show-modal" 还可以声明式地打开模态 dialog;command="close" 可以关闭并传递按钮的 value。但这组属性的兼容门槛比 showModal() 高,Vue 组件没有必要为了减少一次方法调用,强制要求所有浏览器先支持它。按钮命令属性

popover 是显隐和顶层机制

点击展开的说明可以直接写成:

<button type="button" popovertarget="privacy-note">为什么需要邮箱?</button>
<div id="privacy-note" popover="auto">
  邮箱用于接收账户通知。
</div>

auto popover 默认支持点击外部、Esc 等轻量关闭行为;打开另一个不属于同一嵌套关系的 auto popover,也会关闭已有浮层。manual 则把显隐控制更多地交给作者,不能指望它自动处理同一组关闭行为。hint 是另一套用于提示类浮层的规则,支持情况需要单独检查,不能从基础 Popover API 推断。Popover API 的使用方式

popover 自身没有固定的组件语义。菜单、列表框、Tooltip、非模态说明对话框,都可能使用这项显示能力,但各自还需要正确的角色、键盘操作和焦点策略。HTML 标准里的菜单示例也明确把方向键导航留给作者实现。HTML 的 popover 作者说明

因此,本例把带交互按钮的说明面板实现成非模态 role="dialog",没有把它叫作完整 Tooltip 或菜单。

Top Layer 改变绘制位置,DOM 仍在原处

Top Layer 是每个文档自己维护的顶层绘制集合,模态 dialog 和打开的 popover 可以进入其中。普通页面节点无论设置多大的 z-index,都不能压过顶层元素;多个顶层元素按进入顺序叠放。

组件内的 DOM 层级与顶层绘制顺序分别保留

图中左边的组件 DOM 没有搬家,右边才是简化后的绘制顺序。Vue 仍然管理原来的节点、插槽和事件关系,CSS 变量也继续按 DOM 继承。原生浮层通常不需要额外 Teleport 才能突破祖先裁剪,这正是它在框架中容易封装的原因。Top Layer 的定义

嵌套 popover 在原生 dialog 上方显示,页面背景仍由模态遮罩限制

这个顶层属于当前 document。把示例放进 iframe 后,模态只覆盖并限制 iframe 内的文档,外层博客仍可操作;微前端 iframe 也不会因为内部调用 showModal 就拥有覆盖整个宿主页面的能力。

先操作一个 Vue 3 示例

示例包含独立 dialog、独立 popover,以及 dialog 内部的嵌套 popover。表单只更新当前页面内存,不发送网络请求。可以单独打开演示,也可以在下面的预览里操作;独立页面更适合观察完整的遮罩和焦点行为。

先打开 dialog,留空点击保存,再点击取消,比较表单校验与返回值。随后试 Esc、阻止 Esc、遮罩关闭和「关闭后 60ms 重新打开」。观察模型、DOM 状态和动画阶段,不必只靠肉眼判断窗口是否已经结束。

文章中的代码区用于阅读,下方预览运行同一份 Vue SFC 的 Vite 构建快照;修改组件时,在本地运行 pnpm dev,再用 pnpm build:playground 更新快照。它保留真实 Vue 运行时与全部交互,不依赖在线转译器重新编译 SFC。

操作原生 dialog 与 popover正在加载代码工作区...

真实 Vue 示例中,模型、DOM 状态和事件记录分别展示

需要迁入项目时,把源码包中的 copy/ 放到对应的组件和 composable 目录,并调整 import。组件要求 Vue 3.5+,因为使用了 useTemplateRef 和 useId;defineModel 本身从 Vue 3.4 开始提供。SCSS 由 Sass 编译器处理,本例固定为 sass-embedded。

完整源码、目录说明与检查方式见组件源码与本地运行方法弹窗。本地运行入口是源码包根目录的 pnpm install 和 pnpm dev,无需改博客主项目的依赖。

对业务页面暴露的接口可以保持熟悉的形状:

<script setup lang="ts">
import { ref } from 'vue';
import NativeDialog from './components/native-dialog/index.vue';
import NativePopover from './components/native-popover/index.vue';

const editing = ref(false);
const helpOpen = ref(false);
</script>

<template>
  <button type="button" @click="editing = true">编辑资料</button>
  <NativeDialog v-model="editing" title="编辑资料">
    <template #default="{ close }">
      <button type="button" @click="close('button', 'cancel')">取消</button>
    </template>
  </NativeDialog>
  <NativePopover v-model="helpOpen" label="保存说明" title="保存在哪里?">
    <p>关闭面板后,表单草稿仍由父组件保留。</p>
  </NativePopover>
</template>

v-model 与浏览器状态怎样同步

封装里存在两种不同职责的状态。v-model 表示调用方希望窗口打开还是关闭;dialog.open 和 :popover-open 表示浏览器当前的原生显隐状态。增加退出动画后,这两者可以短暂不同。

关闭动画期间,期望状态与原生状态可以暂时不同

managed 模式中,模型变为 false 时,窗口开始退出;动画结束才调用原生关闭 API。这个阶段出现 v-model=false、dialog.open=true 是有意保留的过渡,背景也仍受模态约束。业务不能把「模型已经为 false」当作 DOM 已经移除,或原生焦点已经恢复。

由父层提供初值

组件使用必传 model,不另设一个初始为 true 或 false 的私有镜像:

const visible = defineModel<boolean>({ required: true });

父层用 ref(false) 初始化;组件通过这个 model 发出更新。defineModel 已经声明 model prop 和对应更新事件,不需要再重复声明一次 modelValue / update:modelValue。父子默认值分叉的情况可参考Vue 组件 v-model。

等 DOM 挂载后调用 API

下面是 dialog 源码里的 watcher;完整打开、关闭分支放在 synchronize 中:

// native-overlays/copy/components/native-dialog/index.vue:75-82,watcher 节选
watch(
  [visible, element],
  ([wantsOpen, dialog]) => {
    if (dialog) void synchronize(wantsOpen, dialog);
  },
  { immediate: true, flush: 'post' },
);

把模板 ref 也作为输入,可以覆盖「父层初始值已经为 true,但 dialog 还没有挂载」的情况;flush: 'post' 让回调在 Vue 完成本轮 DOM 更新之后运行。Vue 的 watcher 调度时机

模板上没有绑定 :open="visible"。直接写 open 会打开非模态 dialog,而且手动移除 open 不等价于执行 close 算法,不能替代原生 API。SSR 阶段也只输出稳定的关闭结构,真正的模态打开留在客户端挂载后完成。

浏览器也能改变状态

只做「watch 模型,然后调用 show / hide」还不够。用户按 Esc、点击 popover 外部、提交 method=dialog 表单,都会经过浏览器自己的路径,需要把结果接回 model。

dialog 监听原生 close,popover 监听 toggle。popover 的回调读取当时的 :popover-open,不会把每条历史通知机械重放为模型变化。原因是 toggle 会排队,并可能把同一轮里的多次切换合并;收到通知时,元素也可能已经经过更新的打开或关闭操作。toggle 的事件合并

dialog 也有迟到事件问题。同一节点执行 close 后马上又 showModal,旧 close 事件到达时,新的窗口已经打开。示例先检查 dialog.open,再决定是否同步为 false;每次打开还会重置返回值,避免上一次 save 被误认成这一次的结果。

关闭请求、关闭完成与表单结果

关闭不是一个统一入口。能否取消、有没有返回值、是否经过表单校验,取决于具体路径。

关闭入口 主要事件与行为 示例的处理
Esc / 浏览器关闭请求 dialog 触发可取消的 cancel 允许业务同步阻止;managed 模式接入退出动画
dialog.close(value) 执行关闭,触发 close,不先询问 cancel 关闭完成后校准 model 并返回结果
dialog.requestClose(value) 先触发 cancel,再决定是否关闭 可用于统一原生关闭请求;能力需要另测
form method="dialog" 先走表单约束校验,成功后关闭并设置 returnValue managed 模式在 submit 中接入动画
组件按钮 / 父层模型 由封装定义 用户入口检查 busy;父层 false 可以结束流程
auto popover 轻量关闭 beforetoggle / toggle 接受浏览器结果,回写 model

cancel 的可取消性与 requestClose 的行为见 MDN cancel 和 requestClose。因此,仅监听 cancel 并不能自动拦住 close、表单关闭和所有组件按钮。

示例提供的 @cancel 只承诺同步取消。若关闭前要弹出「是否丢弃草稿」或等待异步保存,需要先同步阻止本次关闭,再进入业务确认流程;异步结果回来后还要确认当前窗口、路由与请求仍然有效。不能在 cancel 回调里 await 结束后,才期待 preventDefault 追回已经执行的默认动作。

method=dialog 仍会执行表单校验

保存按钮使用 value="save";取消按钮使用 value="cancel" formnovalidate。取消按钮需要跳过校验,否则邮箱为空时,取消也可能被 required 阻止。

<form method="dialog">
  <input name="email" type="email" required>
  <button type="submit" value="cancel" formnovalidate>取消</button>
  <button type="submit" value="save">保存</button>
</form>

这里的提交不会把表单发到服务端,但仍然经过浏览器校验。它返回的是触发提交按钮的 value,不是完整表单数据;真实保存需要业务自己的提交逻辑。完整机制和 required 示例见 dialog 表单关闭。

组件不会为了帮所有插槽关闭而额外包一层 form,因为插槽本身可能已经是表单。HTML 不允许嵌套 form,这类结构错误会影响实际解析与提交归属。form 的内容约束

两种动画分别保留了什么

给弹层加 opacity 和位移很容易,难点在于原生关闭会切换 display,并把元素从 Top Layer 移除。如果关闭后立刻通过 v-if 移除整个组件,视觉退出也没有可以继续绘制的节点。

纯 CSS 让原生状态先关闭

现代 CSS 可以把普通透明度过渡与离散属性配合起来。下面是简化后的 dialog 动画结构,完整样式还包含 backdrop 和减少动态效果分支:

dialog {
  opacity: 0;
  transform: translateY(12px);
  transition:
    opacity .2s,
    transform .2s,
    display .2s allow-discrete,
    overlay .2s allow-discrete;
}

dialog[open] { opacity: 1; transform: none; }

@starting-style {
  dialog[open] { opacity: 0; transform: translateY(12px); }
}

@starting-style 提供从 display:none 进入时的起点;display 过渡让退出期间仍能绘制;overlay 过渡推迟离开 Top Layer。overlay 的值由浏览器管理,作者的职责是声明过渡,不能像普通开关一样手动把 overlay 设成 auto 来创建顶层。Chrome 的进退场动画说明、MDN overlay

这条路径里,原生 close / toggle 可以先到达,像素动画随后才结束。@closed 表示原生显隐状态已经关闭,不承诺视觉退出也完成了。

组件协调让原生关闭发生在动画之后

managed 模式把表面动画放到 close() / hidePopover() 前面,使用 Web Animations 的 finished Promise 等待结束,而不是靠一个与 CSS 时长可能漂移的 setTimeout。新一轮操作会取消旧动画,并使旧完成回调失效。

组件协调与纯 CSS 动画采用不同的原生关闭时点

图中上面一行在退出期间仍保持原生模态,下面一行先完成原生关闭,再由受支持的 CSS 保留视觉效果。两种时序都可以成立,业务需要先决定自己关心的是关闭意图、原生交互状态,还是像素动画结束。

动画中的快速重开尤其容易出错。假设旧关闭动画还有 140ms 才结束,用户已经重新打开;旧回调若继续执行 close,就会把新窗口关掉。示例用递增 revision 区分当前操作,新动画和卸载都会让旧 revision 失效;同时处理取消动画后 finished 的拒绝,避免产生未处理异常。Animation.finished

减少动态效果开启时,managed 路径直接完成状态切换,native 路径取消过渡。动画也不能成为唯一的完成通知,关闭后业务动作应依赖明确的组件事件或操作结果。prefers-reduced-motion

auto popover 的浏览器关闭不能统一等待

popover 在打开前的 beforetoggle 可以取消,关闭前的 beforetoggle 不可以。也不能在该回调里重入调用 showPopover / hidePopover,企图撤销正在执行的切换。beforetoggle 的可取消条件

本例因此明确区分两条路径:组件按钮或父层模型发起关闭时,可以先播放 managed 动画;Esc、点击外部或另一个 auto popover 导致的原生关闭,接受浏览器结果并同步 model,退出效果允许降级。需要所有关闭入口都平滑退出时,可以优先评估原生 CSS 动画的支持范围;改成 manual 后自行重建关闭和嵌套策略,则是另一项实现工作。

兼容性要按能力分别判断

「支持 dialog」不能推出支持 commandfor,也不能推出完整退场动画可用。下面是 2026 年 11 月 1 日核对的起始版本,Safari 列同时对应其 iOS 引擎基线;具体组合仍需要目标设备验证。

能力 Chrome Firefox Safari / iOS Safari
基础 dialog / showModal 37 98 15.4
基础 popover 114 125 17
command / commandfor 135 144 26.2
closedby 134 141 仅预览版
@starting-style 117 129 17.5
transition-behavior 语法 117 129,部分支持 17.4
display 过渡 117 尚不支持 18
overlay 过渡 117 尚不支持 尚不支持

依据分别来自 MDN BCD 的 HTMLDialogElement、全局 HTML 属性、button、dialog、starting-style、transition-behavior 与 overlay。静态节选见本次兼容数据与判读范围弹窗。

Firefox 接受 allow-discrete 语法,不代表已经实现 display 过渡;因此 CSS.supports('transition-behavior', 'allow-discrete') 只能作为语法探针。示例也把这个限制直接写在能力检测面板里。

@property、oklch、backdrop-filter 则是另外几项增强。需要平滑动画的自定义颜色变量可以通过 @property 注册类型;普通遮罩、渐变和模糊不应该成为基础打开 / 关闭功能的前提。注册自定义属性

对旧内核,按项目需求选择保留已有组件、明确能力回退或引入 polyfill。JavaScript 转译不能补出浏览器的 Top Layer,dialog polyfill 也明确记录了层叠上下文限制。dialog-polyfill 的限制

正式使用时还需要处理什么

原生能力减少了基础设施代码,业务交互、定位和终端适配仍然需要应用负责。下面这些问题会直接改变组件边界,不能等到换完所有弹窗后才检查。

现有组件库的浮层可能在两个方向上冲突

原生 dialog 内的 Select、DatePicker 或 Tooltip,如果通过 Portal / Teleport 挂到 body,它们就可能处于模态背景,既被顶层遮住,又失去交互。调整普通 z-index 无法把它们提到原生 Top Layer;需要将对应浮层挂到 dialog 后代容器,并重新检查裁剪、键盘和焦点。

反方向也有问题:页面已有自定义 div 模态,再引入原生 popover,popover 可能覆盖自定义模态。Adrian Roselli 用超时警告与说明浮层演示过这种混用风险;那是特定历史浏览器中的案例,适合借来设计重叠场景测试,不应把录制结果写成所有当前组件库都存在的固定 bug。Popover 与自定义 dialog 混用的案例

Ant Design 可以检查 Tooltip 的 getPopupContainer,Element Plus 可以检查 Tooltip 的 append-to / teleported。调整挂载位置只解决一部分问题,还要确认原有焦点锁不会和浏览器模态策略互相争夺焦点。

定位需要独立设计

基础 popover 的默认样式接近居中弹框,不会自动变成贴着触发器的气泡。可以使用 CSS Anchor Positioning,并配合 position-try-fallbacks 做溢出翻转;它们的支持范围要另外核对。CSS Anchor Positioning

本例为了让基础 API 和状态流程在更宽的支持范围内运行,只实现了固定定位、上下翻转、横向边界限制,以及滚动、resize 和尺寸变化后的更新。它没有覆盖完整 placement 集合、RTL、虚拟锚点、所有布局位移和移动键盘变化。

需要这些能力时,复用 Floating UI 的 Vue 绑定通常更划算。Popover API 可以管理顶层与轻量关闭,定位库处理几何,两者可以协作。Manuel Matuzović 的原生 popover 定位示例也适合对照阅读,但其中对浏览器现状的描述要结合发布日期理解。

inert、滚动和移动键盘各有职责

模态背景不可点击,不等于背景滚动已经在所有环境中完整锁住。滚轮在 backdrop 上移动、内部滚动到边界,都可能把滚动传给页面。示例为 dialog 与 backdrop 添加了滚动链约束,但不会把它描述成全终端滚动锁。Chrome 对模态下页面滚动的说明

正式项目要用自己的长内容、嵌套滚动区和目标手机验证。窗口需要明确的最大高度与内部滚动范围;dvh 可以帮助适应动态视口,软键盘、缩放和可视视口移动仍是独立条件。页面出现横向位移时,还要核对滚动条占位,而不是只反复调整弹窗宽度。

焦点落点要按任务选择

邮箱表单把 autofocus 放在输入框上很自然,但长协议、复杂说明或危险确认不能照搬。长内容可能需要让读者先从标题开始;破坏性操作则不适合一打开就把焦点放到危险按钮上。打开模态时焦点放在哪里

popover 内的 autofocus 也会在打开时触发焦点移动,它不是「以后获得焦点时的优先提示」。popover 的区别在于没有模态焦点限制。Tab 离开 popover 也不会默认将它关闭;需要失焦关闭的菜单或选择器,要按该组件的语义增加行为,不能把所有说明面板统一改成失焦即关。Scott O’Hara 对 popover 默认行为的解释

键盘操作通过,也不代表屏幕阅读器已经完整验收。可访问名称、复杂内容的描述方式、触发器与浮层的关系、关闭后触发器已被删除的情形,都需要真实使用路径。原生 dialog 的 Tab 导航会避免进入背景控件,但用户仍能进入浏览器自身界面;不要额外拦截所有键盘导航来制造封闭窗口。

焦点恢复也以真实的原焦点为前提。在这份示例的 Playwright WebKit 检查里,鼠标点击打开时和先聚焦按钮、用 Enter 打开时,关闭后的落点不同;后者才能明确验证回到触发按钮。产品需要统一到某个业务入口时,应先确认平台策略与实际任务,不能只用一次鼠标点击的结果判断原生恢复是否失效。

auto、manual 与多个浮层需要明确策略

无关的 auto popover 通常互斥,合法嵌套可以同时展开;manual 可以并存,但轻量关闭与 Esc 等策略需要另外负责。面板里再出现菜单、全局消息或确认框时,要先定义哪个应该留下,哪个应该关闭,不能只根据 z-index 数值建立栈。

如果只是需要一个永远同时存在的小提示,把 auto 改成 manual 可能满足显示需求,但也会改变用户预期的关闭方式。对普通通知,还应判断是否真的需要抢焦点、是否要通过 live region 宣告,以及信息是否会过早消失。Popover 的可访问性分工

Vue 生命周期不止挂载与卸载

组件默认保留插槽 DOM,隐藏不等于销毁,表单值也不会自动清空。是否每次新建草稿、关闭后重置校验、保存成功后更新页面,应由业务状态明确决定。

路由切换、KeepAlive 停用和父层条件移除也有区别。示例覆盖卸载清理,但没有把 KeepAlive 停用扩展成完整的页面浮层管理;被缓存页面需要在停用时主动关闭或交给统一管理者。多个 Vue 应用共享一个 document 时,也要检查 ID 命名空间,而不只确认单个组件里用了 useId。Vue KeepAlive 生命周期

显示样式不能破坏隐藏规则

给 [popover] 无条件写 display: grid 或给 dialog 无条件写 display: flex,可能覆盖浏览器用于关闭状态的隐藏样式。需要改变内部布局时,可以把布局放在内部 surface,或把 display 限定在真正打开的状态上。示例采用内部 surface,也让内容动画与外层原生显隐分工清楚。web.dev 的 dialog / popover 样式说明

点击遮罩关闭也要区分手势起点。用户在弹窗内拖动选区,最后松手到遮罩上,不应因此丢失表单。示例要求指针按下和抬起都在弹窗外,并且不是仅凭 event.target === dialog 判断;dialog 自身的空白 padding 也属于窗口内部。

与 Ant Design、Element Plus、Tippy 怎样取舍

原生 dialog 和 popover 可以被 CSS 做成同样精致的界面,原生标签不会强制使用某种操作系统弹窗皮肤。比较时更有价值的是交互能力、浏览器覆盖和维护职责。

项目需求 更合适的起点 仍要承担的工作
新项目中的简单确认框、资料表单 原生 dialog + 小型 Vue 封装 业务关闭流程、草稿、滚动与终端验证
简单点击说明面板 原生 auto popover 名称、语义、定位与动画降级
复杂 Tooltip、菜单、选择器 成熟组件,或定位 / 交互基础库 核对目标语义、键盘模型和可访问性
已经深度使用 Ant Design / Element Plus 的后台 先复用现有组件与配置 如需迁移,检查所有嵌套浮层与焦点策略
旧 iOS、老 WebView 为主要用户 先固定实际支持下限 回退方案、polyfill 限制、实机回归

Ant Design Modal 和 Element Plus Dialog提供的不只是遮罩和居中,还包括异步确认、关闭控制、加载状态、销毁策略与设计系统配合。项目已经付出这些依赖成本时,替换基础标签不必然减少总维护成本。

Tippy则主要对应 Tooltip / Popover,拥有触发方式、延迟、触摸和定位配置,不能直接和模态 dialog 对比。它的仓库已于 2024 年 11 月 9 日归档,新项目还要考虑后续维护。

原生方案能减少自行维护的底层行为,但这份示例没有做组件库包体或性能基准,不能从示例文件大小推导「比 Ant Design 快多少」。采用价值应回到项目真正省掉了哪些工作,以及又新增了哪些适配责任。

验证范围与资料入口

示例使用真实 Vue SFC 编译,浏览器测试覆盖模型与原生显隐、表单校验和返回值、焦点恢复、嵌套 Esc、遮罩关闭、退出中重开、迟到事件、自动关闭互斥、卸载、移动宽度和减少动态效果。实际浏览器版本、检查结果和未覆盖范围见浏览器验证记录弹窗。

兼容表用于确认平台支持起点;自动化测试用于确认这份实现的具体行为。二者不能互相替代,Playwright 的 WebKit 也不能写成真实 iPhone Safari 的验收。

继续查资料时,可以按当前问题选择入口:

想解决的问题 建议阅读
dialog 到底有哪些关闭路径 MDN dialog、Matuzović 的关闭请求整理
原生 popover 为什么没包办全部可访问性 Hidde de Vries / Scott O’Hara、Scott 的进一步解释
自定义模态与原生 popover 混用的风险 Adrian Roselli 的重叠交互案例
该把焦点放在哪里 Adrian Roselli 的场景分析
为什么退出需要多项 CSS 能力 Chrome 的进退场动画文章、MDN overlay
popover 如何贴住按钮并避让 MDN Anchor Positioning、Matuzović 的默认样式示例

按官方规范、MDN、框架文档与作者实践分类的完整阅读索引见分主题阅读索引弹窗。第三方文章里的历史版本判断保留其时间背景,当前支持情况以对应能力的数据和实际终端为准。

总结

原生 dialog 把模态、顶层绘制和一部分焦点行为交给浏览器;Popover API 把非模态浮层的显示、轻量关闭和顶层关系变成平台能力。在 Vue 中,通过必传 model、挂载后的 API 同步和原生事件回传,可以把这些命令式接口封装成声明式组件。

接入后的主要工作集中在状态与时序。关闭意图、原生关闭和视觉退出是不同阶段;组件协调动画需要处理取消和重开,原生 CSS 动画需要独立评估 display / overlay 的支持,auto popover 的浏览器关闭又不能用不可取消的 beforetoggle 强行延后。

对新项目的小型弹窗和说明面板,这些能力很值得采用;对成熟后台、复杂定位和旧 WebView,迁移决定仍取决于嵌套浮层、滚动、焦点与目标用户。先选一个边界明确的组件接入,把原生能力真正省掉的工作验证出来,再决定是否扩大使用范围。

Vue 3 原生浮层组件源码与运行方法

打开原文件 README.md

Vue 3 原生 dialog 与 popover

在当前目录运行:

pnpm install
pnpm dev

使用终端打印的本机地址。dev 只监听 127.0.0.1,不调用业务 API;表单内容只保存在内存。也可以直接打开本包的 playground.html,它由同一份 Vue SFC 构建,内联了运行时和样式,无需安装依赖。修改源码后运行 pnpm build:playground 更新快照。

文章 Code Lab 的源码区是只读阅读模式,预览消费该构建快照。在线代码区不承诺直接编辑 SFC 后即时编译,实际修改和热更新使用本地 pnpm dev。

pnpm build:playground 会写入当前目录的 dist、playground.html,并同步相邻的 native-overlays-preview 目录供文章嵌入。后者是只读派生产物,copy / examples 与当前源码逐文件校验一致,不作为第二份维护源。

本例固定 Vue 与 SFC 编译器为 3.5.44,使用普通 VDOM、defineModel、useTemplateRef 和 useId,因此组件要求 Vue 3.5+。这里没有使用 Vapor 或全局 DOM 查询来控制业务组件。

先操作什么

  1. 打开 dialog,留空提交,确认浏览器必填校验阻止关闭;点击取消,查看 returnValue="cancel"。
  2. 打开后按 Esc;再勾选「阻止 Esc」重试。cancel 表示关闭请求,close 表示原生对话框已经关闭。
  3. 点击「关闭后 60ms 重新打开」,确认旧动画不能关闭新窗口。
  4. 打开 dialog 内的说明,先按 Esc 关闭 popover,再按 Esc 关闭 dialog。
  5. 打开独立 popover,分别用内容按钮、Esc、点击外部关闭,比较事件顺序与动画。
  6. 切换纯 CSS 模式,观察 display / overlay 渐进增强;在系统减少动态效果开启时再次操作。
  7. 在窗口打开时卸载组件,检查模态与动画资源的清理。

事件列表按新到旧排列。面板将父层模型、真实 open / :popover-open 和动画阶段分别显示,避免把「发出关闭意图」当作「原生窗口已经关闭」。

迁移到项目

把 copy/components、copy/composables 和 copy/types.ts 迁入项目对应目录,再调整相对 import;组件自己的 scoped SCSS 跟随组件迁移。项目需要 Sass 编译器,本例使用 sass-embedded。examples 是演示页面,不属于组件必需依赖。

<script setup lang="ts">
import { ref } from 'vue';
import NativeDialog from './components/native-dialog/index.vue';
import NativePopover from './components/native-popover/index.vue';

const dialogOpen = ref(false);
const helpOpen = ref(false);
</script>

<template>
  <button @click="dialogOpen = true">编辑</button>
  <NativeDialog v-model="dialogOpen" title="编辑资料" @closed="console.log($event)">
    <template #default="{ close }">
      <button type="button" @click="close('button', 'cancel')">取消</button>
    </template>
  </NativeDialog>
  <NativePopover v-model="helpOpen" label="查看说明" title="保存说明">
    <p>这里是补充信息。</p>
  </NativePopover>
</template>

组件契约

两个组件的布尔 v-model 都是必传,由父组件初始化。模型表示调用方希望窗口打开还是关闭;managed 退出期间,模型已经为 false,DOM 仍可短暂保持打开。

NativeDialog 的 title 必传;motion 默认 managed,也可为 native;closeOnBackdrop 默认 true;busy 默认 false。busy 阻止关闭按钮、遮罩、Esc 和 method=dialog 提交,父组件直接把模型设为 false 仍可强制结束流程。默认插槽提供 close(reason?, returnValue?)。@cancel 接收原生事件,可以同步 preventDefault();@closed 返回 { reason, returnValue },其中 reason 为 button、cancel-request、backdrop、form、programmatic 或 native。cancel-request 表示原生 cancel 路径,事件自身不能可靠区分 Esc、系统返回或 requestClose。

NativePopover 的 label 和 title 必传,默认插槽提供 close(reason?),@closed 返回关闭原因。浮层表示非模态说明面板,所以使用 role="dialog" 和可访问名称;它不是完整菜单或 Tooltip。trigger 使用真正的 button,显式维护 aria-controls 与 aria-expanded,ID 由 useId 生成。

两个组件都提供 @trace 诊断事件和 @error 能力 / API 错误事件。defineExpose 暴露的 element / phase 仅供示例观察;普通业务通过模型和事件操作组件,不直接更改原生状态。

动画和关闭边界

managed 使用 Web Animations 播放表面动画,再调用 close() / hidePopover()。新操作使旧动画失效,取消后的 finished 拒绝已处理,卸载时取消动画。dialog 的 Esc 可取消默认动作,因此能统一到这条退出路径;method=dialog 的 submit 也能在通过浏览器校验后接入。

popover 保持 popover="auto"。组件按钮和父模型发起的关闭可以等待动画,Esc、点击外部或另一个 auto popover 引发的浏览器关闭可能直接消失。关闭时的 beforetoggle 不可取消,本例不会在其中重新调用开关 API,也不会改成 manual 后伪装成完整的原生 auto 行为。

native 直接关闭原生窗口,视觉退出由 CSS 的 @starting-style、display 与 overlay 过渡协调。它们的支持范围不同,不支持时允许动效降级。关闭事件表示原生显隐状态已关闭,不承诺纯 CSS 的像素动画也结束了。

正式接入前需要补什么

  • 示例定位只处理上下翻转、横向边界、滚动 / resize 与尺寸变化;不覆盖 RTL、多种 placement、虚拟锚点、所有布局位移、iframe 跨文档坐标和移动键盘。复杂定位考虑 Floating UI 或单独评估 CSS Anchor Positioning。
  • 示例包含滚动链约束,但不承诺所有 Safari / WebView 都完整锁住背景滚动。按目标设备验证,必要时接入项目已有的滚动管理。
  • slot 内容默认保留,关闭不会清空表单。异步保存、草稿丢弃确认、路由关闭与多层弹窗栈归业务管理。
  • 组件库的 Select / DatePicker 等浮层若挂到 body,可能落在原生模态 dialog 的背景;应调整挂载容器并实测键盘与屏幕阅读器。
  • SSR 阶段不调用浏览器 API,真正的打开从挂载后的 post watcher 开始;服务端不能靠 open 属性预先获得原生模态。
  • close()、method="dialog" 与组件自定义关闭入口并不都触发 cancel;通用异步 before-close 需要另外设计,不能仅监听 cancel。
  • 示例没有对不支持 dialog / popover 的内核实现 polyfill,会通过 error 事件明确报告。

验证

pnpm typecheck
pnpm build
pnpm exec playwright install chromium
pnpm test:browser

跨引擎检查可先安装 pnpm exec playwright install firefox webkit,再运行 pnpm test:browser --browser=firefox 或 pnpm test:browser --browser=webkit。测试在本地启动临时静态服务器,读取当前 dist;尚未构建时读取随包提供的 playground 快照。也可以通过 --url=http://127.0.0.1:5173 检查开发服务器,通过 --report=report.json 保存环境和行为记录。

Playwright WebKit 与 Firefox 的结果只说明对应自动化内核,不代替真实 iOS、Safari、App WebView 或辅助技术验收。测试脚本在 tests/browser.mjs,发布前的实际结果见文章中的验证范围。

来源与许可

组件与演示代码为此示例编写,按 MIT 许可提供。原生行为依据 HTML 标准、MDN 和 Vue 官方文档;文章入口为 /2026/11/01/vue-native-dialog-popover/。阅读索引和兼容数据在文章资源 data/ 中,完整文件索引见 FILES.json。

2026-11-01 兼容性核对节选

打开原文件 compatibility.json

{
  "checkedAt": "2026-11-01",
  "kind": "selected-fields-transcribed-from-mdn-bcd",
  "note": "这是核对时选取的字段,不是完整 BCD 仓库快照;preview 不表示正式版支持,mirror 按对应引擎解释。",
  "features": [
    {
      "name": "dialog.showModal",
      "chrome": "37",
      "firefox": "98",
      "safari": "15.4",
      "source": "https://raw.githubusercontent.com/mdn/browser-compat-data/main/api/HTMLDialogElement.json"
    },
    {
      "name": "popover",
      "chrome": "114",
      "firefox": "125",
      "safari": "17",
      "source": "https://raw.githubusercontent.com/mdn/browser-compat-data/main/html/global_attributes.json"
    },
    {
      "name": "command / commandfor",
      "chrome": "135",
      "firefox": "144",
      "safari": "26.2",
      "source": "https://raw.githubusercontent.com/mdn/browser-compat-data/main/html/elements/button.json"
    },
    {
      "name": "closedby",
      "chrome": "134",
      "firefox": "141",
      "safari": "preview",
      "source": "https://raw.githubusercontent.com/mdn/browser-compat-data/main/html/elements/dialog.json"
    },
    {
      "name": "@starting-style",
      "chrome": "117",
      "firefox": "129",
      "safari": "17.5",
      "source": "https://raw.githubusercontent.com/mdn/browser-compat-data/main/css/at-rules/starting-style.json"
    },
    {
      "name": "transition-behavior",
      "chrome": "117",
      "firefox": "129 (partial: no display transitions)",
      "safari": "17.4",
      "source": "https://raw.githubusercontent.com/mdn/browser-compat-data/main/css/properties/transition-behavior.json"
    },
    {
      "name": "display transition",
      "chrome": "117",
      "firefox": false,
      "safari": "18",
      "source": "https://raw.githubusercontent.com/mdn/browser-compat-data/main/css/properties/transition-behavior.json"
    },
    {
      "name": "overlay",
      "chrome": "117",
      "firefox": false,
      "safari": false,
      "source": "https://raw.githubusercontent.com/mdn/browser-compat-data/main/css/properties/overlay.json"
    }
  ]
}
原生浮层示例的实际验证范围

打开原文件 VALIDATION.md

实际验证范围

验证日期:2026-11-01。环境为 Windows,Node.js 26.12.0,pnpm 12.11.2,Vue 与 SFC 编译器 3.5.44,Vite 8.3.5,Playwright 1.64.1。测试消费本包真实 SFC 构建后的静态页面。

浏览器行为

自动化内核 版本 结果
Chromium 156.0.8078.4 25 项通过
Firefox 157.0 25 项通过
Playwright WebKit 27.2 25 项通过

检查覆盖初始 model / DOM、模态打开和初始焦点、Tab 不进入背景控件、required 校验、formnovalidate、返回值与重置、关闭后焦点恢复、cancel 取消、业务 preventDefault 后不关闭、窗口内拖到遮罩不误关、忙碌状态、退出中重开、嵌套 Esc、点击遮罩、auto popover 的 Esc / 外部点击 / 自动互斥、toggle 合并、迟到 close、两种动画模式的状态同步、打开时卸载、390px 宽度和减少动态效果。

焦点恢复用例先将触发按钮聚焦,再用 Enter 打开。鼠标点击是否让按钮获得焦点受平台策略影响,不能把上述用例外推成所有鼠标点击都回到按钮。Tab 检查允许用户转到浏览器自身界面,只要求不进入背景页面控件。

详细用例和版本记录位于文章资源 data/chromium-report.json、data/firefox-report.json 与 data/webkit-report.json,没有保留机器绝对路径或表单隐私数据。

工程与视觉

  • 在独立示例目录实际执行 pnpm install、pnpm typecheck、pnpm build 和 pnpm build:playground。
  • 静态 playground 与源码使用同一套 SFC 编译链;离线 HTML 内联 Vue runtime、JavaScript 与 CSS。
  • 文章 Code Lab 使用只读源码与构建快照预览;源码编辑和 SFC 热更新通过本地开发服务器进行。
  • native-overlays-preview 是脚本生成的静态预览投影,源码与维护包逐文件校验一致。
  • 本地 Hexo 文章实际加载了 Code Lab、带内容版本的 JS / CSS / MANIFEST / FILES;iframe 内检查了 SFC 样式、dialog 打开与表单返回 save、popover 的 Esc 关闭和模型同步,并检查 390px 文章布局。
  • 桌面截图为 1280×1000 CSS 像素、2 倍像素密度;移动页面检查使用 390×844。
  • 三张机制图保留文字 SVG 维护源,发布 SVG 内嵌字体子集与许可;在 Chromium 实际渲染,检查文字、箭头、边距与图示的事实条件。

这些检查不覆盖什么

没有使用真实 iPhone、iPad、Android WebView 或微信内核,也没有进行 VoiceOver / NVDA / TalkBack 的完整任务测试。Playwright WebKit 不是 Safari 产品或 iOS 实机。兼容表依据 BCD,不来自把新浏览器模拟成旧版本。

纯 CSS 模式检查了原生状态同步,不能据此承诺每个内核都有完整 display / overlay 视觉退场。跨浏览器背景滚动锁、软键盘、视觉视口、RTL、全 placement 定位、复杂布局位移、KeepAlive 停用和业务异步确认不属于本示例的完整实现范围。

没有做与 Ant Design、Element Plus 或 Tippy 的性能 / 包体对照,因此不提供这类量化收益结论。

dialog 与 popover 分主题阅读索引

打开原文件 READING.md

dialog 与 popover 分主题阅读索引

核对日期为 2026-11-01。规范与 API 文档用于确认当前契约,作者文章用于补充场景、动机和测试思路。旧文章中的「当前仅支持某浏览器」不能直接当作今天的兼容结论。

原生行为与事件

资料 适合解决的问题 阅读边界
HTML Standard:dialog 模态、关闭算法、返回值与原生焦点步骤 Living Standard 会更新,实际终端另测
HTML Standard:popover auto / manual / hint、嵌套、焦点和作者职责 规范能力不等于所有版本已实现
MDN dialog 元素 基础写法、closedby、表单校验与动画 不同子特性的兼容表分别查看
MDN showModal inert、Top Layer、iframe 作用范围与异常 show 与 showModal 不可混为同一路径
MDN close 显式关闭及 returnValue close 不会先触发 cancel
MDN requestClose 可取消的统一关闭请求 需要单独确认目标版本支持
MDN cancel 事件 Esc 请求与同步 preventDefault 不涵盖所有关闭入口
MDN beforetoggle 切换前初始化与是否可取消 打开可取消,关闭不可取消
MDN toggle 最终显隐通知及事件合并 不能假定一次 API 调用对应一次事件
MDN Popover API 使用指南 声明式触发、嵌套、定位与动画 触发器关联和语义仍按实际场景设计

绘制、定位与动画

资料 可以继续看的内容
MDN Top Layer 哪些元素进入顶层、普通 z-index 与顶层的关系
MDN CSS Anchor Positioning 锚点关联、position-area 和溢出备选位置
Floating UI Vue 将几何定位、尺寸变化和自动更新交给专门的定位库
Chrome:Four new CSS features for smooth entry and exit animations starting-style、display、allow-discrete、overlay 如何配合;这是特性解释,不代表其他引擎都已实现
MDN overlay 退出期间保留顶层绘制,作者不能直接设置顶层身份
MDN @property 为自定义变量声明可插值类型,适合颜色或渐变参数
MDN Animation.finished 等待动画与处理取消;Promise 完成和操作仍有效需要分别判断
MDN prefers-reduced-motion 为系统减少动态效果提供分支
Chrome:阻止模态下页面滚动 惯性与滚动链、backdrop、非溢出滚动容器的实现差异
web.dev:Popover and dialog 两类组件的 CSS 基线、打开状态选择器、动画和定位的组合

框架封装

作者实践与争议边界

作者 / 日期 文章 值得借来检查什么
Scott O’Hara,2023-01-26 Use the dialog element (reasonably) 原生 dialog 已经能减少大量可访问性基础工作,同时承认历史用户与迁移成本
Hidde de Vries / Scott O’Hara,2024 On popover accessibility: what the browser does and doesn’t do 平台默认映射与作者应补充的角色、键盘、焦点行为
Scott O’Hara,2025-03-14,更新于 03-16 Popping preconceived popover ponderings 为什么失焦不默认关闭、为什么 popover 非模态,以及不同浮层模式的职责
Adrian Roselli,2023-05-26,更新于 2025-12-05 Brief Note on Popovers with Dialogs 原生 popover 与自定义模态重叠时,显示层级和关闭操作可能错位;保留历史实验条件
Adrian Roselli,2025-06-06,更新于 2026-04-23 Where to Put Focus When Opening a Modal Dialog 长文本、危险操作和简单通知需要不同的初始焦点
Manuel Matuzović,2025-03-07 Close requests, close watchers, and the dialog element 除 Esc 外,系统返回、辅助技术与设备输入也可能发起关闭;部分输入明确没有验证
Manuel Matuzović,2026-01-16 Better defaults for popovers 从默认居中改为锚点定位,使用 feature query 与备选方向;兼容现状按今天的数据重查
大漠,2026-05-11 Web UI:用 dialog 和 popover 搭建分层界面 分层 UI、原生 API、backdrop 与现代动效的整体介绍;本示例的讨论起点

与组件库对照