Teleport 弹窗尺寸为什么会丢

这次问题表面上像是一个业务弹窗变窄了:右上角头像点开的账号弹窗还是 375px,点进「基本信息」后,右侧编辑弹窗却窄得像不到 200px。

最初几个怀疑点都合理:是不是 ProfileEditor 写坏了宽度,是不是 Teleport 把 scoped style 挪丢了,是不是 postcss-pxtorem375px 转成 rem 后受浏览器字号影响。真正查到 Vue runtime 后,根因比这些都更窄:<style scoped> 里的 CSS v-bind() 遇到 Teleport + Transition + 初始 v-if=false 后再插入 这组结构时,后插入的 teleported 节点可能拿不到 Vue 用来回填 CSS 变量的 owner 标记。

所以这不是「Teleport 一定不能配 CSS v-bind()」,也不是「只要初始不渲染就会丢变量」。最小复现里,普通 Teleport + v-if 是正常的,Teleport + v-show 也是正常的,失败的是外面再包一层 Transition 的后插入节点。

先把现象缩小

业务弹窗的公共外壳大致是这个结构:

<ClientOnly>
  <Teleport to="body">
    <Transition>
      <div v-if="open || keepMounted" v-show="open" class="common-modal">
        <div class="common-modal__dialog">
          <slot />
        </div>
      </div>
    </Transition>
  </Teleport>
</ClientOnly>

右侧编辑面板的宽度应该归 CommonModal 管,业务内容只负责填满外壳。调用方传的是 max-width="375px"placement="right",公共弹窗最终要保证可见的 .common-modal__dialog 是 375px。

出问题的版本曾经尝试把尺寸从模板 :style 挪到 scoped CSS v-bind()

.common-modal {
  padding:
    v-bind(framePaddingTop)
    v-bind(framePaddingRight)
    v-bind(framePaddingBottom)
    v-bind(framePaddingLeft);

  &__dialog {
    width: v-bind(dialogWidth);
    min-width: v-bind(dialogMinWidth);
    max-width: v-bind(dialogMaxWidth);
    height: v-bind(dialogHeight);
    max-height: v-bind(dialogMaxHeight);
  }
}

这段代码语义上很漂亮:样式值属于样式层,模板不用塞一堆 :style。问题在于,它把弹窗尺寸交给了 Vue 的 SFC CSS 变量注入机制,而这个机制在上面的 Teleport + Transition + v-if 组合里有边界问题。

v-bind() 在 Vue 里到底生成什么

Vue 官方 SFC CSS 文档说明,v-bind() 会被编译成带 hash 的 CSS custom property。也就是说,下面这段:

.box {
  width: v-bind(boxWidth);
  color: v-bind(boxColor);
}

不会变成运行时动态 CSS 字符串,而是类似这样:

.box[data-v-xxxx] {
  width: var(--xxxx-boxWidth);
  color: var(--xxxx-boxColor);
}

运行时再把变量值写到组件渲染出来的 DOM 上:

<div
  class="box"
  data-v-xxxx
  style="--xxxx-boxWidth: 375px; --xxxx-boxColor: rgb(255, 0, 0);"
>
</div>

所以 v-bind() 生效需要两件事同时成立:

  • CSS 规则已经把属性改写成 var(--xxxx-...)
  • 运行时把 --xxxx-... 这些变量写到了当前元素或它的祖先元素上。

当前问题卡在第二步。失败节点仍然有 scoped style 的 data-v-xxxx,说明 CSS 选择器本身能命中;它缺的是 data-v-owner 和对应 inline CSS 变量。

Vue 怎么处理 Teleport 里的 CSS 变量

Vue 不是完全没处理 Teleport。当前项目用的是 Vue 3.5.35,我又用最新 stable 3.5.38 复测了一遍,两版的核心逻辑一致。

在 Vue 3.5.38useCssVars.ts 里,useCssVars() 会给当前组件实例挂一个 instance.ut 方法。这个方法会查找所有带 data-v-owner="<当前组件 uid>" 的节点,然后把 CSS 变量写上去:

// vuejs/core packages/runtime-dom/src/helpers/useCssVars.ts
const updateTeleports = (instance.ut = (vars = getter(instance.proxy)) => {
  Array.from(
    document.querySelectorAll(`[data-v-owner="${instance.uid}"]`),
  ).forEach(node => setVarsOnNode(node, vars))
})

const setVars = () => {
  const vars = getter(instance.proxy)
  setVarsOnVNode(instance.subTree, vars)
  updateTeleports(vars)
}

Teleport 这一侧也有配合。Vue 3.5.38Teleport.ts 里,updateCssVars() 会遍历 Teleport target 中 targetStarttargetAnchor 之间的节点,给元素节点补上 data-v-owner,再调用前面的 ctx.ut()

// vuejs/core packages/runtime-core/src/components/Teleport.ts
function updateCssVars(vnode: VNode, isDisabled: boolean) {
  const ctx = vnode.ctx
  if (ctx && ctx.ut) {
    let node, anchor
    if (isDisabled) {
      node = vnode.el
      anchor = vnode.anchor
    } else {
      node = vnode.targetStart
      anchor = vnode.targetAnchor
    }
    while (node && node !== anchor) {
      if (node.nodeType === 1) node.setAttribute('data-v-owner', ctx.uid)
      node = node.nextSibling
    }
    ctx.ut()
  }
}

这也是为什么「普通 Teleport」没有问题:Teleport 被 patch / hydrate 时会把 owner 标记补到 target 里的真实元素上,useCssVars() 随后能通过 [data-v-owner] 找回这些节点。

最小复现

我做了一个只保留三种结构的最小页面:

<script setup lang="ts">
import { nextTick, ref } from 'vue'

const openPlain = ref(false)
const openTransition = ref(false)
const openShow = ref(false)
const boxWidth = ref('375px')
const boxColor = ref('rgb(255, 0, 0)')

async function openAll() {
  openPlain.value = true
  openTransition.value = true
  openShow.value = true
  await nextTick()
}

Object.assign(window, { openAll })
</script>

<template>
  <Teleport to="body">
    <div v-if="openPlain" class="repro-box repro-box--plain">plain teleport v-if</div>
  </Teleport>

  <Teleport to="body">
    <Transition>
      <div v-if="openTransition" class="repro-box repro-box--transition">
        teleport transition v-if
      </div>
    </Transition>
  </Teleport>

  <Teleport to="body">
    <div v-show="openShow" class="repro-box repro-box--show">teleport v-show</div>
  </Teleport>
</template>

<style scoped>
.repro-box {
  width: v-bind(boxWidth);
  color: v-bind(boxColor);
  border: 1px solid currentcolor;
}
</style>

在浏览器 runtime 里打开页面后调用 window.openAll(),结果很直观:

Vue 版本 结构 computed width 颜色 data-v-owner inline CSS vars
3.5.35 Teleport + v-if 375px 红色
3.5.35 Teleport + Transition+v-if 1246px 黑色
3.5.35 Teleport + v-show 375px 红色
3.5.38 Teleport + v-if 375px 红色
3.5.38 Teleport + Transition+v-if 1246px 黑色
3.5.38 Teleport + v-show 375px 红色

这里的 1246px 只是页面可用宽度,不是一个特殊魔法值。关键是失败节点没有 data-v-owner,也没有 --xxxx-boxWidth--xxxx-boxColor,于是 width: var(--xxxx-boxWidth) 变成无有效值,元素回到默认布局;color 也回到黑色。

这个结果排除了两个误判:

  • 不是 Teleport + 初始 v-if=false 本身必坏。普通 Teleport + v-if 能正常拿到变量。
  • 不是 v-if 本身必坏。没有 Transition 时正常,改成 v-show 也正常。

更准确的触发条件是:Teleport 里包了 Transition,真实元素初始不存在,后续由 v-if 插入。

官方 issue 的状态

Vue 这边已经有对应 issue:vuejs/core#7312 的标题就是 v-bind style not working in some edge cases (teleport + transition, slots)。截至 2026-06-23,这个 issue 仍然是 open,标签里包含 bugscope: teleporthas workaround

这个问题和更早的 vuejs/core#4605 有关系。#4605 说的是 Teleport 中 CSS v-bind() 失效,后来由提交 42239cf 修掉。这个提交做的事正是上面那套 owner 标记机制:Teleport 更新时给 target 节点写 data-v-owneruseCssVars() 再通过这个 owner 找回传送出去的 DOM。

问题是 #4605 修的是普通 Teleport 路径。#7312 下面有维护者评论指出,新的边界和 Teleport 没有走到某些 patch 路径有关。换成源码语言就是:后插入的 DOM 进入了 Transition / renderer 的插入路径,但 Teleport 自己的 updateCssVars() 没有在那个时机重新跑,所以新节点没有 owner 标记。

我也看了 Vue 3.5.38useCssVars.spec.ts。里面已经覆盖了 with teleportwith teleport in child slotwith teleport(change subTree)with teleport(disabled),但没有覆盖 Teleport + Transition + v-if。这解释了为什么普通 Teleport 回归已经守住,组合路径仍然能漏。

为什么手动 :style 能绕开

业务项目最后没有继续用 scoped CSS v-bind() 承载弹窗尺寸,而是把关键尺寸写成稳定 CSS 变量,并通过模板 :style 挂到实际 DOM 节点上:

<div class="common-modal" :style="modalFrameStyle">
  <div class="common-modal__dialog" :style="dialogStyle">
    <slot />
  </div>
</div>
const dialogStyle = computed(() => ({
  '--common-modal-dialog-width': dialogWidth.value,
  '--common-modal-dialog-min-width': dialogMinWidth.value,
  '--common-modal-dialog-max-width': dialogMaxWidth.value,
  '--common-modal-dialog-height': dialogHeight.value,
  '--common-modal-dialog-max-height': dialogMaxHeight,
}))
.common-modal__dialog {
  width: var(--common-modal-dialog-width);
  min-width: var(--common-modal-dialog-min-width);
  max-width: var(--common-modal-dialog-max-width);
  height: var(--common-modal-dialog-height);
  max-height: var(--common-modal-dialog-max-height);
}

这里看起来也在用 CSS 变量,但变量来源已经不同了。v-bind() 的变量要靠 useCssVars() 找 owner 节点后注入;模板 :style 是 Vue patch 当前 vnode 时直接写到这个元素的 style 属性上,不依赖 data-v-owner,也不依赖 Teleport 后续再扫描。

换句话说,v-bind() 的链路是:

SFC compiler 生成 var(--hash-name)
    ↓
useCssVars() 计算变量
    ↓
Teleport.updateCssVars() 给 target 节点补 data-v-owner
    ↓
useCssVars() 用 [data-v-owner] 找节点并写变量

手动 :style 的链路是:

computed 产出 style object
    ↓
Vue patch 当前元素
    ↓
变量直接出现在这个元素的 style attribute 上

第一条链路在 Teleport + Transition + v-if 后插入时会丢 owner;第二条链路不需要 owner,所以能避开这个 Vue 边界。

如果一定要继续用 v-bind() 怎么办

能绕,但要先接受代价。

v-showkeepMounted 能让元素初始就存在,Teleport mount 时有机会给它补 owner,后面只切显示隐藏。这对图片预加载、避免重型内容反复销毁是合理手段,但它会改变组件生命周期:子组件不会随关闭卸载,请求、定时器、表单草稿和焦点状态都要额外管理。

其他可选方案包括在 Transition 外加额外 wrapper,或者把变量提升到 :root / <head>。这些方案适合少量局部场景,不适合公共弹窗尺寸这类基础契约:wrapper 会改变 DOM 层级和样式命中;全局变量要处理命名唯一性、多实例覆盖和卸载清理。

应用层不要调用 Vue 内部的 instance.ut() 或自己仿造 data-v-owner。这些都是 runtime 私有细节,版本升级后没有兼容保证。业务代码应该选择稳定的公共能力:保留 v-bind() 给普通颜色、渐变、背景图、轻量展示状态;把弹窗宽度、高度、遮罩内边距、首屏固定定位这类会导致布局坍塌的关键几何值放回显式 :style 或稳定 class。

如果要从 Vue 源码上修

上游修复不应该只在应用层补丁里解决,因为出问题的是运行时 patch 时机。

一个合理方向是给 Vue core 增加失败用例:useCssVars() 的测试里补 Teleport + Transition + 初始 v-if=false 后再插入,断言后插入节点同时具备 data-v-owner 和 CSS vars。这个测试会先失败。

真正修 runtime 时,需要保证 Transition 把元素插入 Teleport target 后,Teleport 的 CSS vars owner 标记逻辑能重新覆盖这段 target 子节点。可能的落点有两个:

  • 在 Teleport 的 patch / move / target children 更新路径里,确保后插入元素经过 updateCssVars()
  • 在 Transition 或 renderer 插入路径里,保留「当前插入发生在某个 Teleport vnode 的 target 范围内」这个上下文,并在 enter/mount 后触发 owner 标记。

从职责上看,第二种不能让 Transition 知道太多 Teleport 私有细节;第一种更接近 #4605 的修复方向。无论怎么改,都应该先补测试,再看有没有影响 disabled Teleport、slot、Suspense、hydration 和 move target 这些已覆盖路径。

这次工程上怎么防

公共弹窗最后选择绕开这个坑:关键尺寸不用 scoped CSS v-bind(),而是把 CSS vars 通过 :style 挂到实际 .common-modal.common-modal__dialog 上。这样既保留样式层消费变量的可读性,也避开 Vue owner 标记丢失。

测试也不能只断言「style attribute 里有变量」。这次问题恰恰说明,变量存在不等于布局契约生效。更稳的保护有两类:

  • 组件单测:初始 open=false 后再打开,检查实际弹窗节点上有 --common-modal-dialog-min-width--common-modal-dialog-max-width,并确认这些变量是通过模板 :style 挂到真实节点上的。
  • 架构单测:读取 CommonModal.vue 源码,禁止在这个公共弹窗的 scoped style 里重新使用 v-bind(,同时断言模板仍然保留 :style="modalFrameStyle":style="dialogStyle"

E2E 则适合守最终用户结果:打开真实「基本信息」弹窗后,取可见 .common-modal__dialogDOMRect.width,断言它是 375px 或符合当前设计约束。不要选隐藏节点,也不要只看 class 和 inline variable。

最后的判断

这算 Vue 的一个运行时边界 bug。官方 issue 仍然 open,最新 stable 也能复现。更准确地说,触发条件集中在 Teleport + Transition + 后插入元素 这一条组合路径:teleported 节点没有被重新接回 useCssVars() 的 owner 机制,所以 scoped CSS v-bind() 生成的变量没有落到真实渲染节点上。

对业务项目来说,最稳的做法不是等待上游修掉,也不是在应用层碰 Vue 私有 runtime。基础弹窗、浮层、首屏固定区、裁剪面板这类关键布局,不要把几何尺寸交给 scoped CSS v-bind()。如果一个样式值丢了会让页面塌宽、错位或白屏,就把它写成显式 :style、稳定 class 或普通 CSS 变量,并补一个低成本测试守住。