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

这个顶层属于当前 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。

需要迁入项目时,把源码包中的 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 保留视觉效果。两种时序都可以成立,业务需要先决定自己关心的是关闭意图、原生交互状态,还是像素动画结束。
动画中的快速重开尤其容易出错。假设旧关闭动画还有 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 原生浮层组件源码与运行方法
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 查询来控制业务组件。
先操作什么
- 打开 dialog,留空提交,确认浏览器必填校验阻止关闭;点击取消,查看
returnValue="cancel"。 - 打开后按 Esc;再勾选「阻止 Esc」重试。
cancel表示关闭请求,close表示原生对话框已经关闭。 - 点击「关闭后 60ms 重新打开」,确认旧动画不能关闭新窗口。
- 打开 dialog 内的说明,先按 Esc 关闭 popover,再按 Esc 关闭 dialog。
- 打开独立 popover,分别用内容按钮、Esc、点击外部关闭,比较事件顺序与动画。
- 切换纯 CSS 模式,观察
display/overlay渐进增强;在系统减少动态效果开启时再次操作。 - 在窗口打开时卸载组件,检查模态与动画资源的清理。
事件列表按新到旧排列。面板将父层模型、真实 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 兼容性核对节选
{
"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"
}
]
}原生浮层示例的实际验证范围
实际验证范围
验证日期: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 分主题阅读索引
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 基线、打开状态选择器、动画和定位的组合 |
框架封装
- Vue 组件 v-model:defineModel、默认值与父子契约。
- Vue 模板引用:挂载后取得原生元素,空 ref 和组件公开面。
- Vue watcher 的调度时机:post watcher 和副作用清理。
- Vue KeepAlive:停用与卸载不同,缓存页面中的浮层需要自己的生命周期策略。
- React 与外部控件同步:用 ref 和 Effect 包装原生命令式接口;普通业务仍可使用声明式 prop。
作者实践与争议边界
| 作者 / 日期 | 文章 | 值得借来检查什么 |
|---|---|---|
| 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 与现代动效的整体介绍;本示例的讨论起点 |
与组件库对照
- Ant Design Modal:异步确认、加载、焦点、滚动、挂载与销毁策略。
- Ant Design Tooltip:触发方式、延迟、placement 和 getPopupContainer。
- Element Plus Dialog:before-close、lock-scroll、append-to、拖拽与销毁。
- Element Plus Tooltip:Popper 定位、fallback、teleported 和触发行为。
- Tippy 配置:成熟 Tooltip 的交互范围;同时检查仓库归档状态。
- dialog-polyfill 限制:旧内核的 API 模拟不能完全复刻真实 Top Layer。