Vue 基础体系 · 第 35/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue Teleport:层级、事件、SSR、可访问性和弹窗架构
<Teleport> 是 Vue 3 提供的内置组件,用于把一段组件模板渲染到当前组件 DOM 位置之外的目标节点,同时保留原有的 Vue 组件关系。
这句话包含两个容易混淆的“层级”:
- 组件层级:组件之间仍然按照 Vue 模板中的父子关系组织,
props、emit、provide/inject和生命周期关系不变。 - DOM 层级:被 Teleport 的节点会出现在
to指定的 DOM 容器中,不再是当前组件原本的 DOM 子树。
弹窗、下拉菜单、上下文菜单和全局通知经常需要脱离祖先节点的 overflow: hidden、transform 或层叠上下文,因此 Teleport 主要解决的是 渲染位置问题,而不是状态管理、焦点管理或可访问性问题。
一、先建立一个可运行的 Teleport 场景
一个使用 Vite 的 Vue 应用通常可以在入口 HTML 中准备独立的挂载目标:
<!-- index.html -->
<body>
<div id="app"></div>
<div id="modal-root"></div>
</body>
然后在组件中使用:
<script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<button type="button" @click="open = true">
打开弹窗
</button>
<Teleport to="#modal-root">
<div v-if="open" class="modal">
<h2>设置</h2>
<button type="button" @click="open = false">
关闭
</button>
</div>
</Teleport>
</template>
当 open 为 true 时,组件逻辑上仍然属于当前页面组件,但 DOM 结构类似于:
<div id="app">
<button>打开弹窗</button>
</div>
<div id="modal-root">
<div class="modal">
<h2>设置</h2>
<button>关闭</button>
</div>
</div>
to 可以是 CSS 选择器,也可以是 DOM 元素:
<Teleport to="#modal-root">
<!-- 内容 -->
</Teleport>
<script setup lang="ts">
import { ref } from 'vue'
const target = ref<HTMLElement | null>(null)
</script>
<template>
<Teleport v-if="target" :to="target">
<div>被传送的内容</div>
</Teleport>
</template>
实际项目中,选择器通常更清晰,因为目标节点由应用壳层统一创建。to 指向的节点必须在 Teleport 尝试挂载时存在,否则会看到类似“无法找到 Teleport 目标”的运行时警告,内容也不会按预期挂载。
二、Teleport 改变了什么:DOM 层级与组件层级分离
2.1 组件关系没有改变
假设组件树是:
App
└── UserPanel
└── UserDialog
UserDialog 内部使用:
<Teleport to="#modal-root">
<div class="dialog">...</div>
</Teleport>
组件树仍然是:
App
└── UserPanel
└── UserDialog
因此以下机制仍然沿用组件树:
UserPanel可以向UserDialog传递props。UserDialog可以通过emit通知UserPanel。provide/inject仍然按照组件祖先关系查找。onMounted、onUnmounted等生命周期仍然属于对应组件实例。- Vue 的响应式更新仍然由组件状态驱动。
Teleport 不是 iframe,也不是把组件复制到另一个 Vue 应用中。它只改变 DOM 插入位置。
2.2 DOM 关系发生了改变
如果没有 Teleport,以下模板:
<div class="page">
<UserDialog />
</div>
最终可能产生:
<div class="page">
<div class="dialog">...</div>
</div>
使用 Teleport 后则可能变为:
<div class="page"></div>
<div id="modal-root">
<div class="dialog">...</div>
</div>
这会直接影响:
- CSS 后代选择器;
- DOM 原生事件冒泡路径;
position: absolute和position: fixed的参考环境;overflow裁剪;z-index和 stacking context;- 脚本通过
parentElement、closest()查找祖先的结果。
因此,“组件上仍然是子组件”不能推导出“DOM 上仍然是子元素”。
三、为什么弹窗经常需要 Teleport:定位、裁剪和层叠上下文
3.1 overflow 会裁剪普通后代
.panel {
position: relative;
overflow: hidden;
}
如果弹窗 DOM 留在 .panel 内部,弹窗超出 .panel 边界的部分可能被裁剪。将其 Teleport 到页面级容器,可以脱离这个裁剪祖先。
但这不是绝对保证。Teleport 的目标节点本身仍然可能位于某个裁剪容器内,因此目标通常放在应用根节点附近,而不是放进业务卡片内部。
3.2 z-index 不能跨越所有 stacking context
许多 CSS 属性会创建新的 stacking context,例如:
transform非none;opacity小于1;filter;- 某些
position与z-index组合; isolation: isolate;contain的部分取值。
下面的代码中,子元素的 z-index: 999999 也不能跳出 .card 的 stacking context:
.card {
position: relative;
z-index: 1;
transform: translateZ(0);
}
.dialog {
position: fixed;
z-index: 999999;
}
如果 .card 所处的层叠上下文整体低于另一个兄弟上下文,.dialog 仍然无法覆盖后者。Teleport 到顶层容器通常能减少这种问题,但最终仍取决于 Teleport 目标的祖先和页面的层叠结构。
3.3 position: fixed 的参考系也可能受祖先影响
通常 position: fixed 以视口为参考,但某些祖先属性,尤其是 transform,会让固定定位元素表现得像相对于该祖先定位。Teleport 到没有这类影响的页面级容器,能使全屏遮罩更接近预期。
这解释了为什么下面的结构常见:
<body>
<div id="app"></div>
<div id="modal-root"></div>
</body>
而不是:
<div id="app">
<main>
<div id="modal-root"></div>
</main>
</div>
后者仍可能受到应用内部布局容器的裁剪和层叠影响。
四、Teleport 与 CSS:样式作用域不会改变 DOM 选择器语义
Vue 的 <style scoped> 会给组件渲染出的元素添加类似 data-v-xxxxxxx 的属性。例如:
<style scoped>
.page .dialog {
color: red;
}
</style>
编译后选择器大致相当于:
.page .dialog[data-v-xxxxxxx] {
color: red;
}
如果 .dialog 被 Teleport 到 .page 之外,.page .dialog 的后代关系不成立,样式就不会匹配。
因此,Teleport 组件的样式应尽量依赖自身稳定的类名,而不是依赖原始组件位置:
<style scoped>
.dialog {
width: min(90vw, 480px);
background: white;
}
</style>
如果确实需要全局的遮罩层基础样式,可以放到全局 CSS:
#modal-root {
position: relative;
z-index: 1000;
}
.modal-backdrop {
position: fixed;
inset: 0;
}
这不是 Teleport 的特殊 bug,而是 CSS 后代选择器对真实 DOM 关系的正常判断。
五、事件:组件事件仍沿组件树,原生事件沿真实 DOM 树
事件是 Teleport 最容易产生误解的部分。需要把两类事件分开。
5.1 Vue 组件事件不依赖 DOM 冒泡
子组件可以定义并触发自定义事件:
<!-- Dialog.vue -->
<script setup lang="ts">
const emit = defineEmits<{
close: []
}>()
</script>
<template>
<button type="button" @click="emit('close')">
关闭
</button>
</template>
父组件仍然可以监听:
<Dialog @close="open = false" />
即使 Dialog 的内容通过 Teleport 出现在 #modal-root,close 也会传给逻辑上的父组件。这里发生的是:
- 原生
click在按钮上发生; Dialog的处理函数调用emit('close');- Vue 直接调用父组件注册的
close监听器; - 父组件更新
open; - Teleport 内容被卸载或更新。
这条链路不是浏览器的 DOM 冒泡。
5.2 原生事件冒泡只沿实际 DOM 树传播
考虑:
<template>
<div class="page" @click="onPageClick">
<Teleport to="#modal-root">
<button type="button">传送后的按钮</button>
</Teleport>
</div>
</template>
按钮的实际 DOM 祖先是:
button
└── #modal-root
└── body
它不是 .page 的 DOM 后代。因此按钮上的原生 click 不会冒泡到 .page 的 @click。
这与组件事件可以同时成立:
@click作为 DOM 原生事件,遵循真实 DOM 路径;@close作为 Vue 自定义事件,遵循组件监听关系。
5.3 Teleport 对事件路径的完整推导
假设页面是:
组件树:
Page
└── Dialog
DOM 树:
body
├── #app
└── #modal-root
└── .dialog
└── button
点击 button 时,浏览器的事件路径近似是:
button → .dialog → #modal-root → body → document → window
不会经过:
#app → Page 对应的 DOM 节点
但 Dialog 仍然可以在按钮监听器中:
emit('close')
让 Page 收到 close。这两条路径不能互相替代。
5.4 点击遮罩关闭应该使用 .self
典型弹窗结构:
<div class="backdrop" @click.self="emit('close')">
<section class="dialog">
<!-- 内容 -->
</section>
</div>
.self 表示只有事件目标本身就是 .backdrop 时才关闭。点击 .dialog 内容时,事件虽然会冒泡到 .backdrop,但事件目标是内部元素,不满足 .self。
如果不使用 .self:
<div class="backdrop" @click="emit('close')">
点击弹窗内任意按钮、输入框或文本区域都可能触发关闭,除非继续调用 stopPropagation()。.self 通常表达得更准确,也不需要阻断内部事件。
六、Teleport 的核心 API 和边界
6.1 to
<Teleport to="#modal-root">
<MyDialog />
</Teleport>
to 支持:
- CSS 选择器字符串;
HTMLElement;- 响应式地变化的目标。
目标节点应该是稳定的页面基础设施,而不是每次渲染都会销毁的业务节点。
6.2 disabled
disabled 为真时,Teleport 不再传送内容,而是在当前位置渲染:
<Teleport to="#modal-root" :disabled="isMobileInline">
<div class="dialog">...</div>
</Teleport>
这会改变 DOM 层级,因此也会改变:
- CSS 选择器匹配;
- 原生事件冒泡路径;
- 定位和层叠上下文;
- SSR 与客户端首次渲染的结构。
如果 isMobileInline 依赖浏览器宽度,不能直接让服务端和客户端首次结果不同,否则可能出现 hydration mismatch。应该在客户端挂载后再读取窗口状态,或者使用 CSS 媒体查询解决纯视觉差异。
6.3 多个 Teleport 指向同一个目标
多个 Teleport 可以共享目标:
<Teleport to="#modal-root">
<ToastList />
</Teleport>
<Teleport to="#modal-root">
<ConfirmDialog />
</Teleport>
Vue 会将它们的内容追加到同一个目标中。依赖自然追加顺序管理复杂弹窗层级并不稳妥;如果业务需要明确的 z-index、遮罩和关闭策略,应由统一的 overlay manager 管理,而不是让各组件隐式竞争 DOM 顺序。
6.4 defer:版本敏感能力
较新的 Vue 3 版本提供 defer,用于允许 Teleport 等待同一轮挂载过程中稍后出现的目标:
<Teleport defer to="#late-target">
<div>内容</div>
</Teleport>
它解决的是“目标在同一渲染周期的后续位置才出现”的问题,不等价于等待任意异步请求、定时器或下一次不相关的页面更新。
由于 defer 属于版本敏感能力,使用前应确认项目的 Vue 版本和官方 API 文档。对于应用级弹窗,直接在 index.html 中声明稳定的 #modal-root 通常比依赖延迟查找更容易验证。
七、SSR:服务端输出与客户端 hydration 必须共享同一个目标结构
7.1 Teleport 在 SSR 中不是简单地把内容丢弃
服务端渲染时,Teleport 的内容不会像普通模板一样直接混入应用根 HTML。Vue SSR 会把 Teleport 内容收集到 SSR 上下文的 teleports 中。
下面是一个最小示例:
// entry-server.ts
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import App from './App.vue'
export async function render() {
const app = createSSRApp(App)
const context: Record<string, unknown> = {}
const appHtml = await renderToString(app, context)
return {
appHtml,
teleports: context.teleports,
}
}
概念上,返回结果可能包含:
{
appHtml: '<div id="app-content">...</div>',
teleports: {
'#modal-root': '<div class="dialog">...</div>'
}
}
服务端框架需要把对应的 Teleport 输出插入最终 HTML 中。例如最终文档应具有类似结构:
<div id="app">
<div id="app-content">...</div>
</div>
<div id="modal-root">
<div class="dialog">...</div>
</div>
具体 SSR 框架会负责如何读取和拼接上下文;不能假设 renderToString() 返回的主字符串已经包含所有 Teleport 内容。
7.2 hydration 的必要条件
客户端 hydration 需要同时满足:
- 服务端已经输出了 Teleport 的目标节点;
- 客户端首次渲染使用相同的
to; - 服务端和客户端的
open状态一致; - Teleport 内容结构一致;
- 目标节点没有被其他脚本提前改写。
如果服务端认为弹窗打开:
open = true
而客户端首次运行时认为:
open = false
Vue 必须修正服务端 DOM,可能出现 hydration 警告或闪烁。弹窗初始状态通常应由同一份服务端可用状态决定,不能只在客户端无条件打开或关闭。
7.3 为什么不建议直接传送到 body
将内容传送到 body 在浏览器中可以工作:
<Teleport to="body">
<div class="dialog">...</div>
</Teleport>
但 SSR hydration 时,body 还包含应用根节点、脚本和服务端注入的其他内容。Vue 很难仅凭整个 body 的结构判断哪些节点属于某个 Teleport。
更稳定的方案是使用专用目标:
<body>
<div id="app"></div>
<div id="modal-root"></div>
</body>
然后始终:
<Teleport to="#modal-root">
专用目标也便于设置统一的层级、测试选择器和 overlay 生命周期。
八、可访问性:Teleport 不会自动把普通 div 变成可用弹窗
视觉上出现在屏幕中央,只说明 CSS 生效,不说明辅助技术理解了页面状态。
一个可访问的模态弹窗至少要处理以下问题:
- 语义:使用
role="dialog",并提供名称。 - 模态关系:使用
aria-modal="true"表达背景内容不可交互。 - 标题关联:通过
aria-labelledby指向可见标题。 - 打开焦点:弹窗出现后把焦点放入弹窗。
- 焦点陷阱:Tab 不能跑到被遮罩的页面内容。
- 关闭焦点:关闭后焦点回到打开弹窗的控件。
- 键盘关闭:通常响应 Escape。
- 背景隔离:阻止鼠标、键盘和辅助技术继续访问背景。
- 滚动控制:模态弹窗打开时通常禁止页面背景滚动。
- 错误反馈:表单错误应与输入控件建立可访问关联。
aria-modal="true" 是语义声明,不是浏览器自动实现的焦点陷阱,也不会自动阻止背景点击。因此还必须配合 DOM 和键盘行为。
九、一个可运行的 TypeScript 弹窗组件
下面组件演示:
- Teleport 到
#modal-root; role="dialog"、aria-modal和标题关联;v-model控制打开状态;- 打开时聚焦关闭按钮;
- Tab 在弹窗内部循环;
- Escape 关闭;
- 点击遮罩空白处关闭;
- 关闭后恢复触发元素焦点。
9.1 ModalDialog.vue
<script setup lang="ts">
import { nextTick, onBeforeUnmount, watch } from 'vue'
const props = withDefaults(
defineProps<{
open: boolean
title: string
returnFocusEl?: HTMLElement | null
}>(),
{
returnFocusEl: null,
},
)
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const titleId = 'modal-dialog-title'
function close() {
emit('update:open', false)
}
function getFocusableElements(container: HTMLElement): HTMLElement[] {
const selector = [
'a[href]',
'area[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
].join(',')
return Array.from(container.querySelectorAll<HTMLElement>(selector))
.filter((element) => {
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden'
})
}
function onKeydown(event: KeyboardEvent) {
const dialog = event.currentTarget as HTMLElement
if (event.key === 'Escape') {
event.preventDefault()
close()
return
}
if (event.key !== 'Tab') {
return
}
const focusable = getFocusableElements(dialog)
if (focusable.length === 0) {
event.preventDefault()
dialog.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
const active = document.activeElement
if (event.shiftKey && active === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && active === last) {
event.preventDefault()
first.focus()
}
}
watch(
() => props.open,
async (open) => {
if (open) {
await nextTick()
const dialog = document.querySelector<HTMLElement>(
'[data-modal-dialog]',
)
dialog?.focus()
} else {
await nextTick()
props.returnFocusEl?.focus()
}
},
)
onBeforeUnmount(() => {
if (!props.open) {
props.returnFocusEl?.focus()
}
})
</script>
<template>
<Teleport to="#modal-root">
<div
v-if="open"
class="modal-backdrop"
@click.self="close"
>
<section
data-modal-dialog
class="modal-dialog"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
tabindex="-1"
@keydown="onKeydown"
>
<header class="modal-header">
<h2 :id="titleId">{{ title }}</h2>
<button
type="button"
aria-label="关闭弹窗"
@click="close"
>
×
</button>
</header>
<div class="modal-body">
<slot />
</div>
</section>
</div>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(0 0 0 / 50%);
}
.modal-dialog {
width: min(100%, 32rem);
max-height: calc(100vh - 2rem);
overflow: auto;
border-radius: 0.5rem;
background: white;
color: #222;
box-shadow: 0 1rem 3rem rgb(0 0 0 / 30%);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid #ddd;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
}
.modal-body {
padding: 1rem;
}
</style>
这个示例中的 querySelector('[data-modal-dialog]') 假设页面同时只有一个该弹窗。如果应用支持多个并发弹窗,应将 dialog 用模板 ref 传入,而不是查询全局文档。
9.2 父组件管理状态和触发焦点
<script setup lang="ts">
import { ref } from 'vue'
import ModalDialog from './ModalDialog.vue'
const open = ref(false)
const opener = ref<HTMLElement | null>(null)
function openDialog(event: MouseEvent) {
opener.value = event.currentTarget as HTMLElement
open.value = true
}
</script>
<template>
<div id="page-content" :inert="open ? '' : undefined">
<button type="button" @click="openDialog">
打开设置
</button>
<p>
弹窗打开时,这部分页面内容不应继续被键盘和鼠标操作。
</p>
</div>
<ModalDialog
v-model:open="open"
title="设置"
:return-focus-el="opener"
>
<p>这是一个通过 Teleport 渲染到页面级容器的弹窗。</p>
<label>
显示名称
<input type="text" />
</label>
</ModalDialog>
</template>
这里的 inert 作用在 #page-content,而 Teleport 目标 #modal-root 位于其外部,因此不会把弹窗本身一并禁用。
需要注意:
inert主要负责让背景子树不可聚焦、不可交互。- 旧浏览器对
inert的支持可能需要兼容方案或 polyfill。 aria-hidden="true"不能简单替代inert:它主要影响辅助技术树,不一定阻止鼠标和键盘操作。- 如果弹窗嵌套在另一个 overlay 中,不能无条件给整个
#app设置inert,否则可能误伤仍需交互的外层容器。
十、焦点管理的状态变化
一个模态弹窗的焦点生命周期可以表示为:
stateDiagram-v2
[*] --> Closed
Closed --> Opening: 用户激活触发按钮
Opening --> Open: DOM 挂载并完成 nextTick
Open --> Open: Tab / Shift+Tab 在弹窗内循环
Open --> Closing: 点击关闭、遮罩或 Escape
Closing --> Closed: 更新 open=false 并卸载
Closed --> Closed: 焦点恢复到触发按钮
关键顺序不是“设置状态后立刻查询 DOM”,而是:
- 触发按钮保存到
opener; - 设置
open = true; - Vue 创建 Teleport 内容;
nextTick()等待当前 DOM 更新完成;- 查询或使用模板 ref 获取弹窗;
- 调用
.focus(); - 关闭时设置
open = false; - DOM 卸载完成后恢复触发按钮焦点。
如果在第 2 步后立即执行:
open.value = true
document.querySelector('.modal-dialog')?.focus()
通常查询不到元素,因为 Vue 的 DOM 更新尚未完成。
生产实现还应处理这些边界:
- 弹窗关闭过程中有
<Transition>时,焦点恢复时机应与实际卸载时机一致; - 触发按钮可能已经被卸载,此时不能强制调用
.focus(); - 弹窗内部可能有异步加载的首个输入控件,应在内容可用后决定焦点;
- 多个弹窗同时存在时,焦点陷阱必须只作用于当前最上层弹窗。
十一、滚动锁和弹窗状态不能分散管理
打开弹窗时经常需要阻止页面背景滚动:
watch(
() => open.value,
(value) => {
document.body.style.overflow = value ? 'hidden' : ''
},
)
这个简单实现只适合页面永远只有一个弹窗。如果两个组件都修改 body.style.overflow:
- 弹窗 A 打开,设置
hidden; - 弹窗 B 打开,设置
hidden; - 弹窗 A 关闭,恢复空字符串;
- 弹窗 B 仍然打开,但背景滚动被错误恢复。
因此滚动锁需要引用计数或统一管理器:
let lockCount = 0
let previousOverflow = ''
export function acquireScrollLock() {
if (lockCount === 0) {
previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
lockCount += 1
return () => {
lockCount = Math.max(0, lockCount - 1)
if (lockCount === 0) {
document.body.style.overflow = previousOverflow
}
}
}
组件在打开时获取释放函数,在卸载或关闭时执行释放函数。这样关闭一个弹窗不会破坏其他弹窗的锁定状态。
移动端还可能出现滚动条消失导致页面横向跳动、视觉视口变化和触摸滚动穿透等问题。Teleport 只负责节点位置,不负责这些平台行为。
十二、弹窗架构:状态、内容、层级和关闭原因要分开
一个可维护的弹窗系统通常包含四个概念。
12.1 状态所有权
页面或 overlay manager 应拥有“哪个弹窗打开”的状态:
type DialogState =
| { kind: 'none' }
| { kind: 'delete-user'; userId: string }
| { kind: 'settings' }
而不是让每个弹窗通过全局 DOM 查询决定自己的显示状态。状态单向流动:
用户操作
↓
父组件或 manager 更新状态
↓
Dialog 接收 props
↓
Dialog emit close / confirm
↓
父组件更新状态
12.2 内容与壳层分离
弹窗壳层负责:
- Teleport;
- 遮罩;
- 焦点;
- Escape;
- 滚动锁;
- 层级。
业务内容负责:
- 表单字段;
- 异步提交;
- 校验;
- 成功和失败状态。
这样“删除用户确认框”和“编辑设置弹窗”可以复用同一个可访问的壳层,而不重复实现焦点和关闭逻辑。
12.3 关闭原因应可区分
不要只用一个无参数的 close,如果业务需要审计或不同交互策略,可以定义关闭原因:
type CloseReason = 'confirm' | 'cancel' | 'escape' | 'backdrop'
const emit = defineEmits<{
close: [reason: CloseReason]
}>()
调用方可以区分:
function onClose(reason: CloseReason) {
if (reason === 'escape') {
// 例如只关闭最上层,而不提交草稿
}
open.value = false
}
12.4 最上层规则
当多个 overlay 并发出现时,需要明确:
- 谁拥有最高 z-index;
- Escape 关闭哪一个;
- 背景点击作用于哪一层;
- 哪一层拥有焦点;
- 是否允许弹窗打开弹窗;
- 关闭外层时如何处理内层。
Teleports 都指向同一个 #modal-root 并不能自动解决这些规则。常见做法是由 manager 维护一个栈:
type OverlayEntry = {
id: string
kind: 'dialog' | 'popover' | 'toast'
close: () => void
}
Escape 到来时只关闭栈顶的可关闭 overlay,而不是让每个组件都在 window 上注册一个独立监听器。
十三、<dialog> 与 Teleport 的关系
现代浏览器提供原生 <dialog>:
<dialog open>
<h2>原生对话框</h2>
</dialog>
它可以配合 showModal() 进入浏览器的 modal 行为和 top layer。原生 <dialog> 在焦点、Escape 和可访问性方面可能比普通 div 更接近平台语义,但浏览器兼容性、样式、动画、表单行为和项目封装方式仍需验证。
Teleport 与 <dialog> 可以组合:
<Teleport to="#modal-root">
<dialog ref="dialogEl">
...
</dialog>
</Teleport>
这时:
- Teleport 决定
<dialog>在哪个 DOM 容器中创建; <dialog>API 决定它是否进入浏览器 top layer;- 两者不是互相替代的关系。
也不能因为使用了 <dialog> 就完全跳过关闭原因、业务状态和 SSR 校验。是否使用原生 <dialog> 应根据目标浏览器和交互要求决定。
十四、常见失败表现与诊断方法
14.1 找不到 Teleport 目标
表现:
Failed to locate Teleport target with selector "#modal-root"
检查顺序:
index.html是否真的包含#modal-root;- 目标 ID 是否拼写一致;
- Teleport 是否在目标创建之前执行;
- 是否在测试环境中忘记挂载目标;
- 是否使用了动态目标但目标已被卸载。
单元测试中可以显式准备目标:
beforeEach(() => {
const target = document.createElement('div')
target.id = 'modal-root'
document.body.appendChild(target)
})
afterEach(() => {
document.querySelector('#modal-root')?.remove()
})
14.2 点击弹窗没有触发页面上的 @click
这通常不是 Vue 事件丢失,而是原生事件没有沿原来的 DOM 祖先冒泡。应确认:
- 监听器是在组件上,还是 DOM 元素上;
- 子组件是否通过
emit转发; - 页面监听器是否依赖 Teleport 前的 DOM 后代关系。
如果需要业务通知,使用自定义事件:
emit('confirm')
如果需要监听所有文档级点击,使用 document 或 window 监听,并在逻辑中判断目标,但要注意清理监听器和事件竞态。
14.3 弹窗被遮住
检查实际 DOM,而不是只看组件模板:
document.querySelector('.modal-dialog')?.parentElement
然后检查:
- Teleport 目标是否位于低层叠上下文;
- 目标或祖先是否设置了
transform、filter、opacity; - 是否存在更高 z-index 的兄弟 stacking context;
- 是否有
overflow裁剪; - 是否有其他 overlay 使用了浏览器 top layer。
提高 z-index 只能解决同一个 stacking context 内的排序,不能突破父级 stacking context。
14.4 SSR hydration 警告
重点检查服务端和客户端首次渲染是否一致:
服务端:open = true
客户端:open = false
或:
服务端目标:#modal-root
客户端目标:body
还要确认 SSR 输出阶段把 context.teleports 插入了最终 HTML,而不是只返回主应用字符串。
14.5 弹窗打开后键盘仍能操作背景
这说明仅设置了遮罩或 aria-modal,但没有实现交互隔离。验证方法包括:
- 打开弹窗;
- 连续按 Tab;
- 观察焦点是否离开弹窗;
- 尝试点击背景页面按钮;
- 使用屏幕阅读器检查背景是否仍可访问。
解决方案通常需要 inert、焦点循环、正确的关闭时机,以及对旧浏览器的兼容处理。
十五、性能、并发和生命周期边界
Teleport 本身不会创建新的 Vue 应用,也不会天然产生跨线程或跨进程并发。它仍然在同一个响应式更新和 JavaScript 事件循环中执行。
但弹窗系统会遇到异步并发问题。例如:
- 用户打开确认框;
- 点击确认,开始异步删除;
- 用户快速按 Escape;
- 删除请求尚未完成,但弹窗已经关闭;
- 请求返回后,旧回调又尝试更新已关闭弹窗。
因此异步操作应拥有明确的状态:
const submitting = ref(false)
async function confirm() {
if (submitting.value) {
return
}
submitting.value = true
try {
await deleteUser()
emit('close', 'confirm')
} catch (error) {
// 保持弹窗打开,显示错误
console.error('删除失败', error)
} finally {
submitting.value = false
}
}
关闭按钮和 Escape 是否在提交期间可用,应由业务规则决定。危险操作通常不能因为弹窗卸载就取消服务器请求;如果需要取消,应使用 AbortController,并在组件生命周期中明确释放。
同时,任何全局监听器都必须在生命周期结束时移除:
import { onMounted, onUnmounted } from 'vue'
function onGlobalKeydown(event: KeyboardEvent) {
// ...
}
onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onGlobalKeydown)
})
否则每次打开弹窗都可能增加一个监听器,最终表现为一次 Escape 触发多次关闭逻辑。
十六、何时不应该使用 Teleport
Teleport 适合“组件逻辑归属于当前位置,但 DOM 需要位于其他层级”的场景。以下情况不一定需要它:
- 普通表单区域,不存在裁剪或层叠问题;
- 只需要响应式显示隐藏,不需要改变 DOM 位置;
- 依赖严格的 DOM 后代选择器和原生事件冒泡;
- 目标节点不稳定,且没有 SSR 方案;
- 只是想让元素显示在最上面,但真正问题是错误的 stacking context;
- 需要浏览器 top layer 行为,但没有评估原生
<dialog>。
Teleport 不是“全局组件”的同义词,也不是状态管理器。一个组件可以 Teleport,但仍然应该由清晰的父组件或 manager 控制它的生命周期和业务状态。
十七、最终模型
可以用下面的模型理解 Teleport:
Vue 组件树
├── props / emits
├── provide / inject
├── 生命周期
└── 响应式更新
│
│ Teleport 只改变 DOM 插入位置
▼
真实 DOM 树
├── CSS 后代关系
├── 原生事件冒泡
├── 定位参考系
├── overflow 裁剪
└── stacking context
因此,使用 Teleport 构建弹窗时应分别回答五个问题:
- 层级:目标节点是否真正脱离了裁剪和不利的 stacking context?
- 事件:业务通知使用的是 Vue
emit,还是依赖原生 DOM 冒泡? - SSR:服务端是否输出了 Teleport 目标和对应内容,客户端首次状态是否一致?
- 可访问性:焦点、键盘、背景隔离、语义和关闭后的焦点是否完整?
- 架构:状态所有权、overlay 栈、滚动锁、异步请求和关闭原因是否统一管理?
只解决第一项,得到的只是“视觉上出现在顶层”的节点;同时处理其余四项,才是一个可用于生产环境的弹窗架构。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue KeepAlive 深入:缓存键、include、生命周期和内存治理
- 下一篇:Vue 与 Web Components:Custom Element、属性事件、样式和互操作
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论