Teleport 弹窗尺寸为什么会丢(1):Teleport 怎样管理两个 DOM 位置
弹窗经常写在页面组件内部,却需要显示在页面容器之外。原容器的裁剪、定位包含块和层叠上下文,都可能限制它的可见区域。Teleport 允许应用把这部分真实 DOM 放到 body 或单独的弹窗容器,同时保留原来的组件父子关系。
DOM 放到了另一处,Vue 怎样找到它?切换 disabled 会不会重建组件?打开前的空占位与卸载后的清理又由谁负责?这些问题都与 Teleport 保存的两组位置有关。
示例与源码固定使用 Vue 3.5.44 的 VDOM runtime,源码链接锁定对应 tag。
简单功能:把内容放到指定容器
<Teleport to="#modals"> 会把自己的子内容渲染到 #modals。它不会生成一个 <teleport> 包裹元素,也不会改变业务组件之间的父子关系。弹窗经常用它把内容放到 body 或独立的弹窗容器;官方文档也特别区分了真实 DOM 位置与组件逻辑层级。
例如,父容器的 overflow: hidden 可以裁剪后代,transform 可以让原本固定定位的后代改以这个祖先作为包含块。把弹窗传到容器外,能避开这两项祖先约束。至于最终尺寸和层叠顺序,仍然由弹窗自己的 CSS 决定。
实际表现:打开、移动,再关闭弹窗
下面是完整的 Vue 单文件组件项目,可以直接修改代码并在下方预览。public/index.html 预先提供两个相邻的空容器,src/main.js 只把应用挂到其中一个:
<div id="app"></div>
<div id="modals"></div>
App.vue 包含控制按钮、被裁剪的原容器和 Teleport;DialogBody.vue 是有自己输入状态的弹窗子组件。检查区读取真实 childNodes,因此能显示 innerHTML 不容易看出来的空文本锚点。
两个容器在本例中是 body 下的兄弟,这是宿主 HTML 的安排;Teleport 的目标也可以是其他已存在的元素。
先点击「打开弹窗」,弹窗会出现在原容器之外,#modals 的检查结果里也会出现它。输入一段文字,再勾选 disabled:同一个弹窗元素会回到 .origin,被容器裁剪,文字仍在,mounted 计数也不会增加。取消勾选,它又移回 #modals。
关闭后再打开则不同:例子使用 v-if 销毁弹窗子组件,因此输入会清空,mounted / unmounted 计数随之变化。切换 DOM 位置与销毁组件,是两种操作。
图中是开发构建:原位置的两个注释标记始终保留,目标范围两端是空文本节点。关闭时,v-if 还会留下一个表示当前空分支的注释;打开时,它被真实弹窗根替换。因此,「页面上看不见弹窗」并不等于两个容器里都没有节点。
额外的位置切换实验还检查了更换 to 时的元素身份和生命周期,记录见DOM 与生命周期数据弹窗。这些位置操作最终都落到下面同一套 renderer 方法。
源码实现:在保留组件归属的同时选择 DOM 容器
VNode 是 Vue 用来描述一次渲染结果的 JavaScript 对象,不是浏览器 DOM。组件实例保存状态、生命周期和渲染 effect;renderer 负责把 VNode 变成宿主节点,或对比新旧 VNode 修改节点。源码中的 patch 就是这个对比与更新过程。
渲染 effect 会执行组件的 render 和同步 patch,并追踪执行期间读取的响应式状态。这些状态成为它的依赖,后续变化时可以触发这项任务重新执行。
它是 renderer 识别的特殊 VNode
Teleport 虽然在模板里像组件,但运行时不按普通组件的 setup / render 流程创建一个 Teleport 实例。vnode.ts 根据类型上的 __isTeleport 把它标为 ShapeFlags.TELEPORT;renderer.ts 的 patch() 看到这个标记,就把新旧 VNode 和 renderer 内部方法交给 TeleportImpl.process()。
Teleport.ts 因此能使用同一套 mountChildren、patchChildren、move、unmount,只是改变这些方法操作的 DOM 容器。它没有另建一棵组件树,也不需要另外 createApp()。
两组锚点分别守住原位置和目标范围
Teleport 的 VNode 保存了下面这些位置:
| 字段 | 指向哪里 | 作用 |
|---|---|---|
el、anchor |
模板原位置的起止锚点 | 在原父容器中占位,供重排和禁用时定位 |
target |
to 解析出的目标容器 |
子节点启用传送时的真实 DOM 父节点 |
targetStart、targetAnchor |
目标容器中的起止锚点 | 划定这一个 Teleport 管理的内容范围 |
锚点是实际存在的 DOM 节点。它不承担弹窗布局,而是给 renderer 提供插入位置和范围边界。客户端创建的 target 锚点是空文本节点;原位置在开发构建里使用注释节点,生产构建里使用空文本节点。
这样同一个 body 或 #modals 里可以放多个 Teleport。每个 Teleport 都有自己的边界,更新、插入或卸载时不必把整个目标容器当成自己的内容。
首次渲染直接挂到 target
process() 在 n1 == null 时走首次挂载,先在模板原位置插入 el 和 anchor。正常启用时,mountToTarget() 解析 to,在 target 中准备锚点,然后执行:
if (!disabled) {
mount(vnode, target, targetAnchor)
updateCssVars(vnode, false)
}
这里的 updateCssVars() 是 CSS 变量协作入口:内容 owner 使用了 SFC CSS 变量时,给当前范围的元素补归属标记并请求写值。它不负责创建或移动 DOM,具体写值机制见CSS v-bind 的编译与运行时。
这里不是先在原位置渲染一份,再复制到 body。子节点第一次挂载时接收的 DOM 容器就是 target。mount() 内部调用原有的 renderer 方法:
mountChildren(
vnode.children as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
变化的是 container 和 anchor,parentComponent 仍然沿用原来的组件父级。这就是 DOM 已经在 body 下,组件的 props、emit、依赖注入和生命周期仍然接在原组件树上的原因。
resolveTarget() 支持选择器字符串和直接传入的目标节点。字符串通过 renderer 提供的 querySelector 解析;runtime-dom 才把这些操作接到浏览器 DOM,因此主体实现放在 runtime-core。
更新先 patch 旧位置,再按需要移动
更新时不能只看新的 disabled 值。比如这轮准备从启用变成禁用,旧 DOM 仍在 target 中,必须先在那里 patch。源码先根据旧状态选择当前位置:
const wasDisabled = isTeleportDisabled(n1.props)
const currentContainer = wasDisabled ? container : target
const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
随后通过 patchBlockChildren() 更新编译器收集的动态子节点,或通过 patchChildren() 做普通子节点更新。block 是编译器记录动态 VNode 的优化结构,让 renderer 可以优先检查这批节点。完成后,再根据新状态决定是否移动内容,最后调用 updateCssVars(n2, disabled)。
moveTeleport() 区分三类移动:
| 移动类型 | 触发条件 | 移动内容 |
|---|---|---|
TARGET_CHANGE |
to 改变 |
将 target 锚点和子节点移到新容器,继续保证内容位于两个锚点之间 |
TOGGLE |
disabled 切换 |
在原位置和 target 之间移动现有子节点 |
REORDER |
Teleport 在原组件树中重排 | 移动原位置锚点;启用时,已经传出去的子节点留在 target;禁用时,子节点跟随原位置移动 |
浏览器端的底层插入来自 nodeOps.ts:
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
},
对已有节点执行 insertBefore() 会移动该节点,不会复制它。单纯切换 target 或 disabled 因而不需要重建子组件实例;子树本身的 v-if 或 key 变化则仍按普通 diff 规则创建、卸载节点。
这也意味着,原位置的列表重排不等于 target 中的内容会自动按新顺序重排。多个 Teleport 初次挂到同一目标时按挂载顺序追加,后面的移动规则再由上面的类型决定。
defer、卸载和 hydration 仍然围绕这些位置
SSR 在服务端生成 HTML,hydration 是客户端接管这些已有节点的过程;Suspense 则协调异步内容与等待期间的分支。这里关注它们怎样影响挂载时机和节点范围。
<Teleport defer> 会把 target 解析和内容挂载排到渲染后,适合 target 由同一轮渲染中较后面的节点提供。它不等待任意未来时刻出现的容器;官方的 defer 约束仍然要求 target 在同一个挂载或更新 tick 内出现。
在 Vue 3.5.44 中,defer 或 pending Suspense 的挂载任务放进 pendingMounts。挂载前如果又更新,旧任务作废,换成最新 VNode;如果先卸载,则取消待挂载任务,避免回调稍后把已卸载的内容插回页面。
remove() 要同时处理原位置和 target。即使原父元素整块移除,启用状态下的子节点也在另一处 DOM 容器中,仍需要单独卸载和移除。反过来,禁用时内容在原位置,可以配合父级的移除流程。两种情况都需要执行已挂载子组件的卸载逻辑,不能只清掉锚点。
SSR 也保留两处位置。ssrRenderTeleport.ts 在原位置输出占位注释,把启用的 Teleport 内容写入按 target 分组的 buffer,由服务端集成放到页面的目标位置。客户端 hydrateTeleport() 再找到服务端的 target 锚点,接管现有节点;同一 target 下有多个 Teleport 时,使用 _lpa 记录前一个实例处理到的位置。
首次挂载、Teleport 自身更新和 hydration,都会在处理完节点后刷新 CSS 变量。卸载则负责清理。这些是 Teleport 自己进入处理流程时执行的工作:
核心函数由谁调用,各负责哪一步
源码中的函数分成两类:TeleportImpl 向 renderer 暴露的入口,以及这些入口内部使用的私有函数。mount、mountToTarget、queuePendingMount 是 process() 内的局部函数,不能把它们当作整个 Vue 的公共挂载 API。
| 函数 | 谁调用它 | 做什么,以及为什么需要 |
|---|---|---|
TeleportImpl.process() |
renderer 的 patch() 遇到 Teleport VNode |
初次挂载或更新的总入口;必须同时知道原容器和 target,才能把内容 patch 到实际所在的位置 |
resolveTarget() |
mountToTarget()、target 变更分支、hydrateTeleport() |
把 to 变成宿主节点;字符串需要查询,直接传节点则直接使用,找不到目标时不能假装挂载成功 |
prepareAnchor() |
mountToTarget(),以及 hydration 缺少 target 锚点的兜底分支 |
为目标范围建立边界;多个 Teleport 共用一个容器时,后续插入和遍历不能混入别人的内容 |
mount() |
首次 disabled 分支、mountToTarget()、延后挂载任务 |
调 renderer 的 mountChildren();把 DOM 容器与锚点换成正确位置,同时保留原 parentComponent |
mountToTarget() |
正常首次挂载或延后挂载任务 | 解析 target、建立目标锚点、处理 SVG / MathML 命名空间,再把启用的内容直接挂到目标范围 |
queuePendingMount() |
首次 defer / pending Suspense,以及挂载完成前的再次更新 | 把真正挂载安排到渲染后;保留最新任务,避免旧 VNode 的排队任务稍后写回页面 |
moveTeleport() |
process() 中切 target / disabled 的分支;renderer 的 move() |
移动现有节点与必要锚点;区分内容换容器和原位置重排,避免不该移动的 target 内容被一起移动 |
TeleportImpl.remove() |
renderer 的 unmount() |
取消待挂载任务,卸载已挂载子组件并处理锚点;目标内容在原父容器之外,不能只依赖父元素被移除 |
hydrateTeleport() |
hydration.ts 识别 Teleport 后,通过 TeleportImpl.hydrate 调用 |
接管服务端已经放好的节点,恢复 target 范围和继续查找的位置,而不是重新创建一份内容 |
updateCssVars() |
上面的挂载、更新和 hydration 路径 | 在内容范围内补 CSS 变量 owner,再请求写值;它只协作处理 SFC CSS 变量,不承担一般 DOM diff |
这些函数并不是每次都顺序调用一遍。process() 首次挂载时通常经过 mountToTarget();已有内容更新时先 patch,再按条件移动;renderer 卸载和 hydration 则分别走自己的入口。上面的流程图可以用来判断当前操作属于哪一路。
prepareAnchor() 还有一项容易漏看的协作。它创建 target 两端的空文本节点,并在开始节点上记录结束节点:
const targetStart = (vnode.targetStart = createText(''))
const targetAnchor = (vnode.targetAnchor = createText(''))
targetStart[TeleportEndKey] = targetAnchor
if (target) {
insert(targetStart, target, anchor)
insert(targetAnchor, target, anchor)
}
return targetAnchor
renderer.ts 的 getNextHostNode() 在寻找下一个宿主节点时,会识别 TeleportEndKey 并跳过这段目标内容。锚点不只是为了 CSS 变量而存在:它们首先服务于插入、移动、范围定位,以及 renderer 在混合节点之间继续查找。
组件归属不变,DOM 的继承和冒泡会变
组件的 emit 仍调用原组件 VNode 上的监听器;浏览器原生事件会从弹窗元素沿真实 DOM 祖先冒泡,经过的是 target 一侧的父节点。DOM 位置还决定 CSS 继承、祖先选择器和布局包含块:传到 body 后,元素不能再依赖原祖先上声明的自定义属性自然继承过来。
scoped CSS 的标记仍可以写在元素本身,因此 .modal[data-v-xxxx] 这样的选择器可以继续命中;如果选择器还要求原来的祖先结构,则要重新检查。SFC CSS v-bind() 更进一步:它既要有匹配规则,也要把动态变量注入传出去的根节点。这正是 useCssVars() 与 Teleport owner 标记需要配合的地方。
总结
Teleport 在原位置和目标容器分别保留边界,复用 renderer 的挂载、patch、移动和卸载方法,把内容放到实际所在的位置。DOM 容器与插入锚点变了,组件的 parentComponent 沿用原值,因此 props、emit、依赖注入和生命周期仍属于原组件树。
移动现有节点可以保留输入与组件状态;v-if、key 或子树自身变化仍可能创建和销毁节点。原生事件、CSS 继承与布局约束则服从真实 DOM 祖先,不能用组件逻辑归属替代。
系列目录见Teleport 弹窗尺寸为什么会丢。下一篇:Teleport 弹窗尺寸为什么会丢(2):Transition 怎样协调创建和移除。
DOM 与生命周期数据
打开原文件 teleport-dom-lifecycle.json
{
"base": "0ff253bfba3ee8d3d65448bd44ad890fd01b0ecd",
"runtime": "upstream production global",
"note": "innerHTML omits empty text anchor nodes; node identity and mounted/unmounted callbacks are measured separately.",
"states": [
{
"name": "enabled",
"source": "<section class=\"panel\"><button>切换</button></section>",
"target": "<div class=\"modal\">弹窗</div>",
"other": "",
"sameElement": true,
"calls": {
"mounted": 1,
"unmounted": 0
}
},
{
"name": "disabled",
"source": "<section class=\"panel\"><button>切换</button><div class=\"modal\">弹窗</div></section>",
"target": "",
"other": "",
"sameElement": true,
"calls": {
"mounted": 1,
"unmounted": 0
}
},
{
"name": "new-target",
"source": "<section class=\"panel\"><button>切换</button></section>",
"target": "",
"other": "<div class=\"modal\">弹窗</div>",
"sameElement": true,
"calls": {
"mounted": 1,
"unmounted": 0
}
},
{
"name": "unmounted",
"source": "",
"target": "",
"other": "",
"sameElement": false,
"calls": {
"mounted": 1,
"unmounted": 1
}
}
]
}