to="body"大部分人认识 <Teleport>,是从同一段代码开始的:

复制代码<Teleport to="body">
<div v-if="open" class="modal">...</div>
</Teleport>
把模态框传到 <body>,绕开父节点的 transform、overflow:hidden、z-index 堆叠上下文——这是 Teleport 最经典的用法,也是官方文档前 30% 的内容。
但翻到 Vue 官方文档 Teleport 章节 的下半段,你会看到三个被一笔带过的小节:
这三块内容文档一共写了不到 50 行,但真正落到业务里,每一项都对应一个或多个我们踩过的坑:响应式断点切换、多个 Modal 互相覆盖、Tooltip 找不到目标容器、SSR 水合错位……
今天这篇就来把这三块"边角料"拼起来——disabled 怎么用才优雅、多目标渲染顺序背后是什么逻辑、SSR 下的水合到底要注意什么、3.5 的 defer 是为了解决什么问题。
disabled:被当作"开关",其实是"渲染位置切换器"disabled 这个属性名,第一眼看上去像是"是否启用 Teleport"。但翻一下 runtime-core 里的 Teleport.ts 类型定义:
复制代码export interface TeleportProps {
to: string | RendererElement | null | undefined
disabled?: boolean
defer?: boolean
}const isTeleportDisabled = (props: VNode['props']): boolean =>
props && (props.disabled || props.disabled === '')
更准确的描述是:
它是个渲染位置开关,不是"功能开关"。理解这一点,才会发现它真正的价值——响应式地切换渲染位置。
这是文档给的官方例子:
复制代码<Teleport :disabled="isMobile">
<UserMenu />
</Teleport>
桌面端 isMobile = false,菜单飞到 <body> 下渲染成下拉浮层;移动端 isMobile = true,菜单留在按钮旁边作为 inline 块——这是响应式 UI 里非常自然的需求。
完整一点的实现,配合 matchMedia 可以做到真正的断点切换:
复制代码<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'const isMobile = ref(false)let mql: MediaQueryList | null = null
const handler = (e: MediaQueryListEvent) => (isMobile.value = e.matches)onMounted(() => {
mql = window.matchMedia('(max-width: 768px)')
isMobile.value = mql.matches
mql.addEventListener('change', handler)
})
onBeforeUnmount(() => {
mql?.removeEventListener('change', handler)
})
</script><template>
<button ref="btnRef" @click="open = !open">菜单</button> <Teleport to="body" :disabled="isMobile">
<div v-if="open" class="menu" :class="{ 'menu--inline': isMobile }">
<slot />
</div>
</Teleport>
</template>
这里有个值得注意的细节:disabled 由 true 切换到 false 时,已挂载的 DOM 节点是直接被 move 到目标容器,而不是销毁重建。这意味着:
<input> 的 IME 输入态不会被打断源码层面,这是 moveTeleport 函数的功劳(Teleport.ts in vuejs/core):
复制代码// 简化版伪代码
function moveTeleport(vnode, container, anchor, internals, moveType) {
// 不是销毁重建,而是 insert + move
if (moveType === TeleportMoveTypes.TARGET_CHANGE) {
insert(vnode.targetAnchor!, container, anchor)
}
// 遍历子节点逐个 move 到新容器
for (const child of vnode.children) {
move(child, container, anchor, MoveType.REORDER)
}
}
正因为是"移动"而非"重建",disabled 切换才能做得这么丝滑。这也是它比 v-if 双套写法(移动端 / 桌面端各写一份模板)优秀的地方。
disabled 做 SSR 兜底下面这个写法,在 SSR 项目里你会经常见到:
复制代码<script setup>
import { ref, onMounted } from 'vue'
const isMounted = ref(false)
onMounted(() => (isMounted.value = true))
</script><template>
<Teleport to="#modal-root" :disabled="!isMounted">
<div class="modal">...</div>
</Teleport>
</template>
它的意思是:服务端渲染时 disabled = true,内容就留在原位输出;客户端 mount 后才把 disabled 切回 false,触发那次"无副作用的 move"。这种写法可以绕开很多 SSR + Teleport 的水合陷阱(后面第四节会单独讲)。
单元测试里 Teleport 经常很烦——内容被传到 document.body,断言时找不到。@vue/test-utils 提供了 stub 方案:
复制代码import { mount } from '@vue/test-utils'
import Modal from './Modal.vue'const wrapper = mount(Modal, {
props: { open: true },
global: {
stubs: { teleport: true }, // 等价于 disabled=true
},
})expect(wrapper.find('.modal').exists()).toBe(true)
它实际就是把 Teleport stub 成 disabled 模式,让内容渲染在组件树里方便断言。来源:Fix: Vue Teleport Not Rendering。
官方文档 对多目标的描述只有两行:
直译:后挂载的排在先挂载的后面,全部 append 到目标容器。
听起来朴素,但落地业务时——你想用一个 #modals 容器同时塞 N 个 Modal,或者用一个 #toasts 容器叠 N 条 Toast,这个"挂载顺序"就开始有讲究了。
假设有这样的需求:右下角 toast,新的总在最下面、旧的在最上面:
复制代码<!-- ToastList.vue -->
<template>
<Teleport to="#toasts" v-for="toast in toasts" :key="toast.id">
<div class="toast">{{ toast.msg }}</div>
</Teleport>
</template>
第一次推三条 toast,结果如预期:
复制代码<div id="toasts">
<div class="toast">A</div>
<div class="toast">B</div>
<div class="toast">C</div>
</div>
但下一次只 push 一条新的 D,问题来了——D 被 append 在 C 后面没问题,但如果中间某条 B 被关掉,再 push 一条 E,DOM 顺序可能变成 A C D E,而不是你想的"按时间序"。
这里的关键约束是:
所以最稳的做法是:只用一个 Teleport,把列表数据放进去,让 Vue 的 keyed diff 来管顺序:
复制代码<!-- 推荐:一个 Teleport 管所有 Toast -->
<Teleport to="#toasts">
<TransitionGroup name="toast">
<div v-for="t in toasts" :key="t.id" class="toast">
{{ t.msg }}
</div>
</TransitionGroup>
</Teleport>
这样无论增删,顺序都由数组 toasts 决定,符合直觉。
另一个常见问题:业务里多个 Modal 同时打开,谁应该在上面?
答案分两层:
z-index 时后渲染的 DOM 在上面(HTML 文档流的天然规则)。这意味着,只要不手动指定 z-index,多 Modal 的层级会自然遵循"后开的盖在先开的上面"——这是符合用户心智的。
但如果你有多个独立写的 <Teleport to="#modals">,它们的挂载顺序取决于父组件的 setup / 渲染顺序。同级兄弟之间没问题,跨组件就不可控了。这种情况下,更好的方案是:
modalStack: ModalDescriptor[]<ModalRoot> 里用 v-for 渲染整个栈 复制代码<!-- ModalRoot.vue -->
<script setup>
import { useModalStore } from '@/stores/modal'
const store = useModalStore()
</script><template>
<Teleport to="body">
<component
v-for="(m, idx) in store.stack"
:key="m.id"
:is="m.component"
v-bind="m.props"
:style="{ zIndex: 1000 + idx }"
@close="store.pop(m.id)"
/>
</Teleport>
</template>
一个 Teleport + 一个数组 + 显式 z-index,比"散兵游勇式的多 Teleport"稳定得多。
文档没明说,但 Teleport 是支持嵌套的:
复制代码<Teleport to="#outer">
<div>外层</div>
<Teleport to="#inner">
<div>内层</div>
</Teleport>
</Teleport>
实际效果:外层 div 进 #outer,内层 div 进 #inner。逻辑上仍是父子关系(props/inject 都正常工作),DOM 上则是平行的两条传送线。
需要注意的是:嵌套场景在 SSR 下会被扁平化处理(参考 Vue Teleport 及其在 SSR 中的潜在问题 - CSDN),如果你做服务端渲染,最好避免这种结构。
要真正理解 disabled 切换、多目标渲染、defer 行为,最快的方式是看一眼 runtime-core 里的实现。下面是简化后的关键逻辑(基于 vuejs/core packages/runtime-core/src/components/Teleport.ts)。
Teleport 不是普通组件,而是一个特殊 shapeFlag 的 vnode:
复制代码// vnode 上挂着两个锚点
vnode.el // 占位锚点(在 Teleport 原本位置的注释节点)
vnode.targetAnchor // 目标容器内的锚点(决定 children 插入到哪儿)
vnode.target // 解析后的目标 DOM 元素
两个锚点的设计是关键:
el 留在源位置,方便 disabled 切回时把内容搬回来targetAnchor 在目标容器里,标记 Teleport 的内容应该插在哪里——这就是为什么多个 Teleport 指向同一目标时能保持顺序 复制代码process(n1, n2, container, anchor, ...) {
if (n1 == null) {
// mount
const placeholder = (n2.el = createComment('')) // 占位锚点
const mainAnchor = (n2.anchor = createComment('')) // 主锚点
insert(placeholder, container, anchor)
insert(mainAnchor, container, anchor) const target = (n2.target = resolveTarget(n2.props))
const targetAnchor = (n2.targetAnchor = createText(''))
if (target) insert(targetAnchor, target) const mount = (container) => {
mountChildren(n2.children, container, targetAnchor, ...)
} if (isTeleportDisabled(n2.props)) {
mount(container) // disabled:渲染在原位
} else {
mount(target) // 正常:渲染到目标
}
}
// ...
}
可以看到,mount 阶段就根据 disabled 决定子节点挂在 container(原位)还是 target(目标容器)。
更新分支里有这么一段(再次简化):
复制代码// update:disabled 状态变了
const wasDisabled = isTeleportDisabled(n1.props)
const isDisabled = isTeleportDisabled(n2.props)if (wasDisabled !== isDisabled) {
if (isDisabled) {
// 之前在 target,现在禁用 → 把内容搬回原位
moveTeleport(n2, container, mainAnchor, internals, MoveType.TOGGLE)
} else {
// 之前在原位,现在启用 → 把内容搬到 target
moveTeleport(n2, target, targetAnchor, internals, MoveType.TOGGLE)
}
}
moveTeleport 的核心:
复制代码function moveTeleport(vnode, container, anchor, internals, moveType) {
// 遍历子节点 move 到新容器
for (let i = 0; i < vnode.children.length; i++) {
move(vnode.children[i], container, anchor, MoveType.REORDER)
}
}
move 用的是 parentNode.insertBefore(node, anchor)——浏览器原生 API。把已存在的 DOM 节点 insertBefore 到新位置,不会重新创建节点,状态全保留。这就是 disabled 切换平滑的真正原因。
回到第二节那个问题。当多个 Teleport 指向 #modals:
复制代码// 每个 Teleport 在 mount 时执行
const targetAnchor = createText('')
insert(targetAnchor, target) // 把自己的锚点 append 到目标
mountChildren(children, target, targetAnchor)
每个 Teleport 都往目标容器里 append 一个 targetAnchor,然后把 children 插在自己的锚点之前。锚点的 append 顺序 = Teleport 的 mount 顺序——这就是为什么"后挂载的在后面"。
理解这一层之后,你会知道:想精确控制顺序,要么合并成一个 Teleport,要么自己控制 children 的 v-for 数组。
Vue 官方 SSR 文档 里有一段关键说明:
服务端没有真实 DOM,document.querySelector('#modal-root') 在 Node 里跑不起来。Vue 的处理方式是:把 Teleport 的内容单独输出到 ssr context,而不是塞进主 HTML 字符串里。
复制代码const ctx = {}
const html = await renderToString(app, ctx)console.log(ctx.teleports)
// { '#teleported': '<div class="modal">teleported content</div>' }
需要由你把 ctx.teleports['#teleported'] 注入到最终 HTML 的对应位置。如果忘了这一步,客户端水合时就会找不到节点 → hydration mismatch → 控制台一片红字。
整理几个真实业务里高频中招的:
① 服务端和客户端的目标容器 ID 不一致
复制代码<!-- 比如服务端模板里写的是 #modal-container -->
<!-- 客户端动态生成的是 #other-container -->
<Teleport :to="dynamicTarget">...</Teleport>
来源:Vue Teleport 及其在 SSR 中的潜在问题 - CSDN。结论:SSR 项目里,Teleport 的 to 应该是个稳定字符串,而不是基于 window、isMobile 等浏览器态算出的动态值。
② Teleport 直接传到 body
复制代码<Teleport to="body">...</Teleport>
Vue 官方文档 里明确建议:
<body> 里既有应用主内容,又混着 teleport 内容,水合时 Vue 找不到正确的起点。在 SSR 项目里,请用专门的容器,比如 <div id="teleported"></div>。
③ 客户端 onMounted 后才生成的内容
服务端渲染时输出 A,客户端 mount 后变成 B——必然 mismatch。这种情况要么:
v-if="isMounted" 包裹(让服务端啥都不渲染)<ClientOnly> 包裹data-allow-mismatch 属性显式标记某些节点允许不一致来源:Announcing Vue 3.5 - blog.vuejs.org。
综合下来,一个稳的 SSR Teleport 模板大概是这样:
复制代码<script setup>
import { ref, onMounted } from 'vue'const isMounted = ref(false)
onMounted(() => (isMounted.value = true))
</script><template>
<!-- 用专属容器,不用 body -->
<Teleport to="#modal-root" :disabled="!isMounted">
<div v-if="open" class="modal">...</div>
</Teleport>
</template>
复制代码<!-- index.html -->
<body>
<div id="app"></div>
<div id="modal-root"></div>
</body>
要点:
disabled = true,内容留在组件树内输出false,触发一次无副作用的 movedefer:解决"目标元素还没挂载"的老问题3.5 之前,下面这段代码会报错:
复制代码<template>
<Teleport to="#late-target">
<p>传送内容</p>
</Teleport> <!-- 目标容器在 Teleport 之后才被定义 -->
<div id="late-target"></div>
</template>
报错信息是 Invalid Teleport target on mount。原因很物理:Vue 按模板顺序渲染,挂载到 Teleport 时 #late-target 还没出现在 DOM 里。
3.5 之前的常见绕法是:
index.html 里nextTick + v-if 强行延后挂载都不优雅。
defer 是怎么做的Vue 3.5 的发布公告 里加了个 defer 属性:
复制代码<Teleport defer to="#late-target">
<p>传送内容</p>
</Teleport>
<div id="late-target"></div>
行为变化:Teleport 不在自己挂载的那一刻去找 target,而是等到当前渲染周期内的所有节点都挂载完,再去解析 target。文档原话:
源码里就是把 mount 推进 queuePostRenderEffect:
复制代码const isTeleportDeferred = (props) => props && (props.defer || props.defer === '')if (isTeleportDeferred(n2.props)) {
queuePostRenderEffect(() => {
mountToTarget()
n2.targetStart!.parentNode &&
moveAnchors()
}, parentSuspense)
} else {
mountToTarget()
}
简单说:defer 把 Teleport 的目标解析挂到了当前批次的"post effect"队列里——和 mounted 生命周期同时机。
最直接的两个场景:
① Teleport 到 Suspense 内部
3.5 之前,Teleport 不能指向 Suspense 内的容器(因为 Suspense 异步渲染)。3.5 之后:
复制代码<Suspense>
<Teleport defer to="#suspense-target">...</Teleport>
<div id="suspense-target"></div>
</Suspense>
来源:What's new in Vue 3.5? - blog.ninja-squad。
② 单文件组件内的"自包含 Teleport"
之前你必须在外部 HTML 里准备容器,现在可以直接写:
复制代码<template>
<Teleport defer to="#tooltip-layer">
<div v-if="show" class="tooltip">...</div>
</Teleport> <!-- 同一个组件内提供容器 -->
<div id="tooltip-layer" class="tooltip-layer-root"></div>
</template>
组件自带容器,不再依赖宿主页面提前布局。
defer 不是万能延迟。文档明确:
如果你的目标元素是另一个组件异步加载几秒后才渲染的,defer 也救不了你——它只在同一个 tick 内等待。这种异步场景该用的还是老方案:v-if="targetReady" 配合事件通知。
最后用一个略完整的例子收一下尾。它把上面几节都用上:
复制代码<!-- composables/useModal.ts -->
<script setup lang="ts">
import { ref, computed, watchEffect, onMounted, onBeforeUnmount } from 'vue'interface ModalEntry {
id: string
component: any
props?: Record<string, any>
}const stack = ref<ModalEntry[]>([])export function useModal() {
const push = (entry: ModalEntry) => stack.value.push(entry)
const pop = (id: string) =>
(stack.value = stack.value.filter((m) => m.id !== id))
return { stack, push, pop }
}
</script>
复制代码<!-- ModalRoot.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useModal } from '@/composables/useModal'const { stack, pop } = useModal()
const isMounted = ref(false)
onMounted(() => (isMounted.value = true))
</script><template>
<!--
1. defer:保证 #modal-root 即便在同一组件树后方也能解析到
2. disabled:SSR 阶段保持原位渲染,避免水合错位
3. 一个 Teleport + v-for:保证多 modal 顺序和 z-index 可控
-->
<Teleport defer to="#modal-root" :disabled="!isMounted">
<TransitionGroup name="modal">
<component
v-for="(m, idx) in stack"
:key="m.id"
:is="m.component"
v-bind="m.props"
:style="{ zIndex: 1000 + idx }"
@close="pop(m.id)"
/>
</TransitionGroup>
</Teleport> <!-- 同组件内提供容器,依赖 defer -->
<div id="modal-root" />
</template>
这套写法的好处:
:disabled="isMobile || !isMounted" 即可stack 数组而不是多 Teleportdisabled="!isMounted" 让首次水合时内容留在原位defer 让 #modal-root 可以放在同一个组件里,去掉了对 index.html 的依赖回到标题,文档里被一笔带过的这几块,对应的关键点是:
| 特性 | 容易被忽视的点 | 真正的价值 |
|---|---|---|
disabled | 不是"启停开关",是"渲染位置切换器" | 响应式断点切换、SSR 兜底、单元测试 stub |
| 多目标 | "后挂载在后" 这个隐性约定 | 多 Modal / Toast 系统的顺序模型 |
defer(3.5+) | 仅作用于"同一渲染 tick" | 让 Teleport 真正自包含、能进 Suspense |
| SSR | 不只是 <ClientOnly> 一句话 | 容器隔离、不要传 body、避免动态 target |
更深一层,理解 targetAnchor 这个设计——Teleport 在源位置和目标容器各留一个锚点——能解释清楚为什么 disabled 切换不会丢状态、多目标顺序由挂载时机决定、defer 只是把锚点 insert 推进了 post effect。所有"看起来奇怪"的行为,都能在源码里找到一个具体的位置回答。
Teleport 不是个高频写的 API,但每次写都有可能踩到某个文档没展开讲的细节。把这些细节理清楚,Modal、Toast、Tooltip、Drawer 这类组件就不会再有"为什么有时候 z-index 不对"或者"为什么 SSR 一开就白屏"的偶发问题。
<Teleport defer> — vuejstips.com