Teleport 弹窗尺寸为什么会丢
这次问题表面上像是一个业务弹窗变窄了:右上角头像点开的账号弹窗还是 375px,点进「基本信息」后,右侧编辑弹窗却窄得像不到 200px。
最初几个怀疑点都合理:是不是 ProfileEditor 写坏了宽度,是不是 Teleport 把 scoped style 挪丢了,是不是 postcss-pxtorem 把 375px 转成 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.38 的 useCssVars.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.38 的 Teleport.ts 里,updateCssVars() 会遍历 Teleport target 中 targetStart 到 targetAnchor 之间的节点,给元素节点补上 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,标签里包含 bug、scope: teleport 和 has workaround。
这个问题和更早的 vuejs/core#4605 有关系。#4605 说的是 Teleport 中 CSS v-bind() 失效,后来由提交 42239cf 修掉。这个提交做的事正是上面那套 owner 标记机制:Teleport 更新时给 target 节点写 data-v-owner,useCssVars() 再通过这个 owner 找回传送出去的 DOM。
问题是 #4605 修的是普通 Teleport 路径。#7312 下面有维护者评论指出,新的边界和 Teleport 没有走到某些 patch 路径有关。换成源码语言就是:后插入的 DOM 进入了 Transition / renderer 的插入路径,但 Teleport 自己的 updateCssVars() 没有在那个时机重新跑,所以新节点没有 owner 标记。
我也看了 Vue 3.5.38 的 useCssVars.spec.ts。里面已经覆盖了 with teleport、with teleport in child slot、with 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-show 或 keepMounted 能让元素初始就存在,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__dialog 的 DOMRect.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 变量,并补一个低成本测试守住。