跳到正文

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 的目标也可以是其他已存在的元素。

亲手切换 Teleport 的 DOM 位置正在加载代码工作区...

先点击「打开弹窗」,弹窗会出现在原容器之外,#modals 的检查结果里也会出现它。输入一段文字,再勾选 disabled:同一个弹窗元素会回到 .origin,被容器裁剪,文字仍在,mounted 计数也不会增加。取消勾选,它又移回 #modals。

关闭后再打开则不同:例子使用 v-if 销毁弹窗子组件,因此输入会清空,mounted / unmounted 计数随之变化。切换 DOM 位置与销毁组件,是两种操作。

Vue 挂载前 app 与 modals 都为空;挂载后原位置留下锚点,打开时只在目标范围内创建弹窗

图中是开发构建:原位置的两个注释标记始终保留,目标范围两端是空文本节点。关闭时,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 锚点是空文本节点;原位置在开发构建里使用注释节点,生产构建里使用空文本节点。

Teleport 原位置与目标容器分别保留一组锚点,真实内容位于目标范围内

这样同一个 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 自己进入处理流程时执行的工作:

Teleport 首次挂载、自身更新和 hydration 后调用 updateCssVars,卸载则清理内容与锚点

核心函数由谁调用,各负责哪一步

源码中的函数分成两类: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 标记需要配合的地方。

App 与 DialogBody 的组件父子关系保留,但弹窗元素的真实 DOM 父级变成 modals

总结

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
      }
    }
  ]
}