Teleport disabled与多目标传送:被文档一笔带过的进阶用法
Teleport的disabled属性并非功能开关,而是响应式切换渲染位置,切换时DOM节点移动而非重建,可保留状态。多个Teleport指向同一目标时,渲染顺序由挂载先后决定,建议合并为一个Teleport并用数组控制顺序。SSR场景下需注意水合错位,可通过禁用Teleport做兜底。
前言:Teleport 不只是 to="body"
大多数人了解 ,都是从同一段代码开始的:

复制代码
...
将模态框传送到 但如果继续阅读 Vue 官方文档 Teleport 章节的后半部分,你会看到三个被一笔带过的小节: 这三部分内容在文档中一共不到 50 行,但在实际业务中,每一项都对应我们踩过的多个坑:响应式断点切换、多个 Modal 互相覆盖、Tooltip 找不到目标容器、SSR 水合错位等等。 今天这篇文章就来把这些“边角料”拼接起来—— 更准确的描述是:它是一个渲染位置开关,而不是“功能开关”。理解这一点,才会发现它的真正价值——响应式地切换渲染位置。 这是文档给出的官方示例: 桌面端 更完整的实现,配合 这里有一个值得注意的细节: 源码层面,这是 正因为是“移动”而非“重建”, 下面的写法,在 SSR 项目中经常见到: 它的含义是:服务端渲染时 单元测试中 Teleport 经常带来不便——内容被传到 实际上,它就是把 Teleport stub 成 官方文档对多目标的描述只有两行:后挂载的排在先挂载的后面,全部 append 到目标容器。听起来简单,但在实际业务中——你想用一个 假设有以下需求:右下角 toast,新的总在最下面,旧的在最上面: 第一次连续推入三条 toast,结果符合预期: 但下一次只 push 一条新的 D,问题来了——D 被 append 在 C 后面没有问题,但如果中间某条 B 被关闭,再 push 一条 E,DOM 顺序可能变成 这里的关键约束是: 所以最稳妥的做法是:只用一个 Teleport,把列表数据放进去,让 Vue 的 keyed diff 来管理顺序: 这样无论增删,顺序都由数组 另一个常见问题:业务中多个 Modal 同时打开时,谁应该显示在上面?答案分为两层: 这意味着,只要不手动指定 但如果你有多个独立编写的 一个 Teleport + 一个数组 + 显式 文档没有明说,但 Teleport 支持嵌套: 实际效果:外层 div 进入 需要注意的是:嵌套场景在 SSR 下会被扁平化处理(参考 Vue Teleport 及其在 SSR 中的潜在问题 - CSDN),如果做服务端渲染,最好避免这种结构。 要真正理解 Teleport 不是普通组件,而是一个特殊 shapeFlag 的 vnode: 两个锚点的设计很关键: 可以看到,mount 阶段就根据 更新分支里有这样一段(再次简化): 回到第二节的问题。当多个 Teleport 指向 每个 Teleport 都向目标容器 append 一个 理解这一层之后,你会明白:要精确控制顺序,要么合并成一个 Teleport,要么自己控制 children 的 v-for 数组。 Vue 官方 SSR 文档中有一段关键说明:服务端没有真实 DOM, 需要你手动将 以下是真实业务中高频出现的问题: ① 服务端和客户端的目标容器 ID 不一致 来源:Vue Teleport 及其在 SSR 中的潜在问题 - CSDN。结论:SSR 项目中,Teleport 的 ② Teleport 直接传送到 Vue 官方文档明确建议: ③ 客户端 onMounted 后才生成的内容 服务端渲染时输出 A,客户端 mount 后变成 B——必然造成 mismatch。这种情况要么: 来源:Announcing Vue 3.5 - blog.vuejs.org。 综合来看,一个稳妥的 SSR Teleport 模板大致如下: 要点: 3.5 之前,下面的代码会报错: 传送内容 报错信息是 3.5 之前的常见绕法: 都不够优雅。 Vue 3.5 的发布公告中添加了 传送内容 行为变化:Teleport 不在自己挂载的那一刻寻找 target,而是等到当前渲染周期内的所有节点都挂载完毕,再去解析 target。文档原话是:内容将在组件渲染完成后再挂载。源码中就是把 mount 推进 简单来说: 最直接的两个场景: ① Teleport 到 Suspense 内部 3.5 之前,Teleport 不能指向 Suspense 内的容器(因为 Suspense 异步渲染)。3.5 之后: 来源:What's new in Vue 3.5 - blog.ninja-squad。 ② 单文件组件内的“自包含 Teleport” 之前你必须在外部 HTML 中预先准备容器,现在可以直接写: 组件自带容器,不再依赖宿主页面提前布局。 最后用一个稍完整的例子收尾,它综合运用了以上各节的内容: 这套写法的好处: 回到标题,文档中一笔带过的这几个部分,对应的关键点如下: 更深一层,理解 Teleport 不是一个高频编写的 API,但每次使用都有可能踩到某个文档没有展开的细节。把这些问题梳理清楚,Modal、Toast、Tooltip、Drawer 这类组件就不会再出现“为什么有时候 z-index 不对”或者“为什么 SSR 一开就白屏”之类的问题。,避开父节点的 transform、overflow:hidden、z-index 堆叠上下文——这是 Teleport 最经典的用法,也是官方文档前 30% 的内容。
disabled 如何用得优雅、多目标渲染顺序背后的逻辑、SSR 下需要关注的水合问题、以及 3.5 新增的 defer 解决了什么痛点。
一、
disabled:被当作“开关”,其实是“渲染位置切换器”1.1 它做的不是“启用/关闭”
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 === '')
1.2 经典场景:桌面端弹层 / 移动端 inline
复制代码
isMobile = false 时,菜单飞到 下渲染为下拉浮层;移动端 isMobile = true 时,菜单留在按钮旁边作为 inline 块——这是响应式 UI 中非常自然的需求。matchMedia 可以实现真正的断点切换: 复制代码
disabled 从 true 切换到 false 时,已挂载的 DOM 节点是直接移动到目标容器,而不是销毁重建。这意味着:
的 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 双套写法(移动端/桌面端各一份模板)更优秀的地方。1.3 一个反直觉的用法:用
disabled 做 SSR 兜底 复制代码
disabled = true,内容留在原位输出;客户端 mount 后才把 disabled 切回 false,触发一次“无副作用的 move”。这种写法可以绕开很多 SSR + Teleport 的水合陷阱(后面第四节会单独讲解)。1.4 测试场景下的 stub
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)
disabled 模式,让内容渲染在组件树中,方便断言。来源:Fix: Vue Teleport Not Rendering。
二、多个 Teleport 指向同一个目标:渲染顺序的隐性约定
2.1 文档里的一句话
#modals 容器同时塞 N 个 Modal,或者用一个 #toasts 容器叠 N 条 Toast,这个“挂载顺序”就会变得很有讲究。2.2 一个真实踩过的坑:Toast 顺序
复制代码
复制代码
<div id="toasts">
<div class="toast">Adiv>
<div class="toast">Bdiv>
<div class="toast">Cdiv>
div>
A C D E,而不是设想中的“按时间序”。
复制代码
toasts 决定,符合直觉。2.3 多个 Modal 叠加:z-index 还是 DOM 顺序?
z-index 时后渲染的 DOM 在上面(HTML 文档流的天然规则)。z-index,多 Modal 的层级会自然遵循“后开的盖在先开的上面”——这符合用户的心智模型。,它们的挂载顺序取决于父组件的 setup/渲染顺序。同级兄弟之间没有问题,跨组件则不可控。这种情况下,更好的方案是:
modalStack: ModalDescriptor[] 中使用 v-for 渲染整个栈 复制代码
z-index,比“散兵游勇式的多 Teleport”稳定得多。2.4 嵌套 Teleport:可以但要谨慎
复制代码
#outer,内层 div 进入 #inner。逻辑上仍然是父子关系(props/inject 都正常工作),DOM 上则是平行的两条传送线。
三、源码视角:Teleport 是如何把节点“搬”过去的
disabled 切换、多目标渲染、defer 行为,最快的方式是查看 runtime-core 中的实现。下面是简化后的关键逻辑(基于 vuejs/core packages/runtime-core/src/components/Teleport.ts)。3.1 Teleport 的 vnode 长这样
复制代码
// vnode 上挂着两个锚点
vnode.el // 占位锚点(在 Teleport 原本位置的注释节点)
vnode.targetAnchor // 目标容器内的锚点(决定 children 插入到哪儿)
vnode.target // 解析后的目标 DOM 元素
el 留在源位置,方便 disabled 切回时把内容搬回来targetAnchor 在目标容器里,标记 Teleport 的内容应该插在哪里——这就是为什么多个 Teleport 指向同一目标时能保持顺序3.2 mount 阶段:disabled 的两条分支
复制代码
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) // 正常:渲染到目标
}
}
// ...
}
disabled 决定子节点挂在 container(原位)还是 target(目标容器)。3.3 update 阶段:disabled 切换 = move
复制代码
// 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 切换平滑的真正原因。3.4 多目标的顺序由谁保证
#modals: 复制代码
// 每个 Teleport 在 mount 时执行
const targetAnchor = createText('')
insert(targetAnchor, target) // 把自己的锚点 append 到目标
mountChildren(children, target, targetAnchor)
targetAnchor,然后把 children 插在自己的锚点之前。锚点的 append 顺序 = Teleport 的 mount 顺序——这就是“后挂载的在后面”的原因。
四、SSR 场景:Teleport 真正的难点
4.1 服务端渲染时 Teleport 不会“传送”
document.querySelector('#modal-root') 在 Node 环境中无法运行。Vue 的处理方式是:把 Teleport 的内容单独输出到 ssr context,而不是塞进主 HTML 字符串中。 复制代码
const ctx = {}
const html = await renderToString(app, ctx)console.log(ctx.teleports)
// { '#teleported': 'ctx.teleports['#teleported'] 注入到最终 HTML 的对应位置。如果遗漏这一步,客户端水合时就会找不到节点 → hydration mismatch → 控制台一片红字。4.2 hydration mismatch 的几个常见诱因
复制代码
to 应该是一个稳定字符串,而不是基于 window、isMobile 等浏览器状态计算出的动态值。body 复制代码
中既包含应用主内容,又混合 teleport 内容,水合时 Vue 找不到正确的起点。在 SSR 项目中,请使用专门的容器,例如 。
v-if="isMounted" 包裹(让服务端什么都不渲染) 包裹data-allow-mismatch 属性显式标记某些节点允许不一致4.3 SSR-friendly 的 Teleport 写法
复制代码
复制代码
<body>
<div id="app">div>
<div id="modal-root">div>
body>
disabled = true,内容留在组件树内输出false,触发一次无副作用的 move
五、Vue 3.5+ 的
defer:解决“目标元素还没挂载”的老问题5.1 一个老 Bug:目标在 Teleport 之后
复制代码
Invalid Teleport target on mount。原因很直接:Vue 按照模板顺序渲染,挂载 Teleport 时 #late-target 还没有出现在 DOM 中。
index.html 中nextTick + v-if 强制延后挂载5.2
defer 是怎么做的defer 属性: 复制代码
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 生命周期同时执行。5.3 它能做什么
复制代码
复制代码
5.4 它的边界
defer 并不是万能延迟。文档明确指出:Teleport.defer 仅在同一个渲染 tick 内有效。如果你的目标元素是另一个组件异步加载几秒后才渲染的,defer 也帮不上忙——它只在同一个 tick 内等待。这种异步场景仍然需要传统的方案:v-if="targetReady" 配合事件通知。
六、把它们串起来:一个真实业务里的 Modal 系统
复制代码
复制代码
:disabled="isMobile || !isMounted" 即可stack 数组而不是多个 Teleportdisabled="!isMounted" 使得首次水合时内容留在原位defer 让 #modal-root 可以放在同一个组件中,消除对 index.html 的依赖
七、小结:被低估的三件事
特性 容易被忽视的点 真正的价值 disabled不是“启停开关”,而是“渲染位置切换器” 响应式断点切换、SSR 兜底、单元测试 stub 多目标 “后挂载在后”这个隐性约定 多 Modal/Toast 系统的顺序模型 defer(3.5+)仅作用于“同一渲染 tick” 让 Teleport 真正自包含、能进入 Suspense SSR 不仅仅是 一句话容器隔离、不要传 body、避免动态 target targetAnchor 这个设计——Teleport 在源位置和目标容器各留一个锚点——可以解释清楚为什么 disabled 切换不会丢失状态、多目标顺序由挂载时机决定、defer 只是把锚点 insert 推进了 post effect。所有“看起来奇怪”的行为,都能在源码中找到具体的位置来解释。
参考资料
— vuejstips.com
