Teleport 弹窗尺寸为什么会丢(3):CSS v-bind 怎样编译与更新变量
在 Vue 单文件组件中写 width: v-bind(boxWidth),修改脚本里的宽度就能更新样式。但浏览器并不认识这里的 v-bind(),它也不会直接读取组件的 ref。SFC 编译器与运行时需要共同完成这项绑定。
这条路径分为两端:CSS 中留下静态的 var(--...) 引用,组件 setup 中登记动态取值函数;运行时把值写进元素,浏览器再完成样式计算。变量写到哪个根、后代怎样取得它,会直接影响 Teleport 中的表现。
示例与源码固定使用 Vue 3.5.44 的 VDOM runtime,源码链接锁定对应 tag。
简单功能:让 CSS 使用组件里的动态值
Vue 单文件组件的 <style> 可以用 v-bind(boxWidth) 读取脚本中的状态,例如 .box { width: v-bind(boxWidth) }。宽度状态改变后,样式也会更新。这是 Vue 的 SFC 编译语法,浏览器不能直接执行它;CSS v-bind() 官方说明给出了可用写法。
模板中的 :style 则是另一条路径:它让模板直接为当前元素绑定 style。两者最终都能改变宽度,但写值的位置、依赖收集和更新入口并不相同。
实际表现:根上出现变量,子元素通过继承取得值
下面的完整例子只保留普通 DOM,不含 Teleport 和 Transition。拖动宽度滑块或修改颜色,观察 .box 的 computed style,再对比组件根和 .box 自己的内联 style。
初始宽度为 240px。检查区会显示:组件根 <main> 上出现了 --...-boxWidth 等自定义属性,子元素 .box 自己没有内联 style,宽度仍然随滑块变化。变量名里的组件前缀由编译器生成,并不是直接叫 boxWidth。
这里继承的是自定义属性,.box 再用自己的 CSS 规则取值、计算宽度;width 属性本身不继承。Teleport 把元素移到另一个 DOM 祖先之下时,恰好会切断原先可以自然继承变量的路径。
源码实现:编译引用,运行时写值
为了看清产物,把上面的交互项目缩成下面这个单变量组件,并将宽度固定为 375px。以下 CSS 与 JavaScript 是用 3.5.44 的 compiler-sfc 实际编译得到的节选:
<script setup>
import { ref } from 'vue'
const boxWidth = ref('375px')
</script>
<template>
<div class="box"><span>内容</span></div>
</template>
<style scoped>
.box { width: v-bind(boxWidth); }
</style>
编译器生成 CSS 引用和 JavaScript 取值函数
compiler-sfc/src/style/cssVars.ts 中,parseCssVars() 从各个 style 块收集 v-bind() 表达式,去掉表达式外层引号并去重。这个例子收集到 ['boxWidth']。cssVarsPlugin 再把 CSS 声明值改成浏览器认识的 var()。
固定编译 id 为 data-v-demo、使用开发模式,实际 CSS 是:
.box[data-v-demo] { width: var(--demo-boxWidth);
}
其中 .box[data-v-demo] 的属性选择器由 scoped 样式转换负责;var(--demo-boxWidth) 由 CSS 变量转换负责。两项转换分别解决「规则匹配谁」和「属性值从哪里取」,并不是同一个功能。
compileScript.ts 发现 SFC 有 CSS 变量,调用 genCssVarsCode(),在组件 setup 中注入 useCssVars()。下面保留实际产物的 setup 结构,省略模板渲染辅助 import、静态提升和 render 返回体:
import { useCssVars as _useCssVars, unref as _unref } from 'vue'
// ... 省略模板渲染辅助 import 和静态提升
import { ref } from 'vue'
export default {
__name: 'CssVariableDemo',
setup(__props) {
_useCssVars(_ctx => ({
"demo-boxWidth": (boxWidth.value)
}))
const boxWidth = ref('375px')
// ... 省略返回模板 render 函数的代码
}
}
传给 _useCssVars 的是取值函数,登记时不会立刻读取 boxWidth.value,所以调用写在变量声明前也不会提前访问它。普通 <script> 的编译分支会采用不同的 setup 注入方式,但同样接到 useCssVars();v-bind() 也不要求 style 必须 scoped。
开发模式的变量名便于识别,由 SFC id 和表达式名组成;生产模式会生成更短的哈希名。CSS 和 JavaScript 使用同一套 genVarName(),两端的名字必须一致。这个名字对应组件文件和表达式,不是每个组件实例各生成一套;不同实例把各自的值写到自己的根元素即可共享同一份静态 CSS。完整开发、生产产物见编译记录弹窗。
useCssVars() 把当前值写进组件根
runtime-dom/src/helpers/useCssVars.ts 是通用的 SFC CSS 变量运行时。它在 setup 中取得当前组件实例,保存编译器生成的 getter,并安排初次写入、响应式更新及卸载清理。
instance.subTree 保存当前组件渲染结果的 VNode 树。VNode 是 Vue 对渲染结果的描述,可以关联真实 DOM;组件根还可能是另一个组件,或由 Fragment 表示的多个根,所以寻找实际元素时需要沿这些结构继续处理。
普通组件由它沿 instance.subTree 找实际根:组件根继续下钻,Fragment 根逐个处理,Suspense 看当前活动分支,静态节点范围也有专门处理。落到元素时,最终执行的是 setVarsOnNode() 中这几行:
const style = (el as HTMLElement).style
let cssText = ''
for (const key in vars) {
const value = normalizeCssVarValue(vars[key])
style.setProperty(`--${key}`, value)
cssText += `--${key}: ${value};`
}
;(style as any)[CSS_VAR_TEXT] = cssText
例如 getter 返回 { 'demo-boxWidth': '375px' },实际根就会得到 style="--demo-boxWidth: 375px;"。浏览器匹配静态的 .box[data-v-demo] 规则,再从当前元素或真实 DOM 祖先读取这个自定义属性,算出 width。
这段代码中的 style.setProperty() 完成写入,CSS_VAR_TEXT 则记录本次注入的 CSS 文本;两者都没有改写静态样式表。
普通元素根以下的后代通常靠 CSS 自定义属性继承取值,不需要 Vue 给每个后代逐一写变量。如果一个组件输出多个根,Vue 会处理各个根,不能指望它们之间互相继承。
值改变时更新 inline 变量,不重写样式表
useCssVars() 在 mounted 时启动以下监听,并观察组件根的父节点是否发生直接子节点变化:
watch(setVars, NOOP, { flush: 'post' })
const ob = new MutationObserver(setVars)
ob.observe(instance.subTree.el!.parentNode, { childList: true })
onUnmounted(() => ob.disconnect())
这里把 setVars 当作 watch 的取值函数。它运行时会调用 getter、读取响应式变量,同时完成写 DOM;后面的 NOOP 并不是负责写变量的回调。之后源值改变,setVars 会在 post 阶段重跑。组件自己的更新前钩子还会把 setVars 放进 post-flush 队列,以处理根发生变化的情况。
因此把 boxWidth.value 改成 450px,主要变化是元素上的自定义属性值;CSS 规则里的 var(--demo-boxWidth) 保持不变。变量写入与浏览器随后进行的布局计算也是两步,不能把 nextTick() 当作已经完成绘制的保证。
沿函数名回看这条链路
| 函数 | 谁调用 | 负责什么 |
|---|---|---|
parseCssVars() |
SFC 的 parse() |
收集 style 中的表达式,形成 descriptor.cssVars |
cssVarsPlugin |
compileStyle() 加入 PostCSS 流程 |
把 CSS 声明中的 v-bind() 改成 var(--...) |
genCssVarsCode() |
compileScript() 的浏览器编译分支 |
根据脚本绑定信息生成 getter 与 useCssVars() 调用 |
useCssVars() |
编译器注入的 setup 代码 | 取得当前组件实例,安排初次写入、源值变化后的更新与清理 |
setVars() |
watch、组件更新前安排的 post-flush 任务、根父节点的 MutationObserver | 调 getter 取得变量值,处理普通根,并调用 updateTeleports() |
setVarsOnVNode() |
setVars() 和函数自身递归 |
沿受支持的组件根结构找真实根,不把整个 DOM 子树逐个写一遍 |
setVarsOnNode() |
普通根处理与 updateTeleports() |
对元素执行 style.setProperty(),是两条路径共用的最终写入点 |
这里展开的是浏览器侧路径。SSR 不会在服务端调用 HTMLElement.style;compileTemplate.ts 有单独的 ssrCssVars 编译输入,交给 SSR 模板生成处理。不能把浏览器 helper 等到 mounted 才写值,理解成服务端完全没有 CSS 变量处理。
Teleport 中为什么还要补 owner
Teleport 可以把内容放到组件根以外的容器。它保存原位置与目标位置的锚点,具体挂载和移动过程见Teleport 的两处 DOM。变量写入还需要知道这些外置元素归哪个组件实例使用。
Teleport.ts 里的 updateCssVars() 是 Teleport 为传出去的内容补归属信息的私有协作函数。通用的变量写入机制是前面的 useCssVars(),普通组件不会因为没有调用这个私有函数,就缺少变量更新。
普通 DOM 继承为什么不够
假设组件根是 .panel,上面已经写了 --demo-boxWidth: 375px。普通 .box 仍在 .panel 里面,可以通过 DOM 继承拿到值。Teleport 把 .box 放到 #modals 后,两者不再是 DOM 祖先与后代。Vue 中的逻辑父子关系不能替代浏览器的 CSS 继承关系。
而 setVarsOnVNode() 也不是遍历整个 VNode 子树、把每个后代都写一遍变量。它处理组件的实际元素根、Fragment 和静态范围等结构,遇到普通元素就借助 DOM 继承;没有把 Teleport 的外置子节点当作普通元素后代继续遍历。因此 Vue 还需要找回这些独立的 DOM 根。
Teleport 标记范围,useCssVars() 负责写值
useCssVars() 给 CSS 变量 owner 的组件实例安装 instance.ut,指向内部的 updateTeleports():
const updateTeleports = (instance.ut = (vars = getter(instance.proxy)) => {
Array.from(
document.querySelectorAll(`[data-v-owner="${instance.uid}"]`),
).forEach(node => setVarsOnNode(node, vars))
})
它只按 owner 查节点,再复用同一个 setVarsOnNode()。它不会推测每个 target 中哪段 DOM 属于哪个 Teleport。这个范围信息恰好由 Teleport 的锚点维护,所以 Teleport 负责先补标记。下面是 Vue 3.5.44 正式版、应用候选补丁前的完整函数:
function updateCssVars(vnode: VNode, isDisabled: boolean) {
// presence of .ut method indicates owner component uses css vars.
// code path here can assume browser environment.
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()
}
}
ctx 是创建这个 Teleport VNode 的渲染 owner,通常是声明这些 CSS 变量的组件。循环只处理当前 Teleport 锚点范围的直接元素根;根下面的后代继续用 DOM 继承。disabled 时内容在原位置,改走原位置的锚点范围。
ctx.ut 不存在,表示这个 owner 没有安装 SFC CSS 变量 helper,函数直接结束;Teleport 仍然照常挂载、更新和移动。它不是一个所有样式、所有组件更新都必须经过的通用函数。
三种标识分别解决三个问题
| 标识 | 按什么区分 | 负责什么 |
|---|---|---|
data-v-demo |
编译出来的组件作用域 | 让 scoped CSS 选择器匹配元素 |
--demo-boxWidth |
编译 id 与 CSS 表达式;生产模式会使用哈希名 | 让静态 CSS 规则引用一个动态值 |
data-v-owner="7" |
运行时组件实例的 uid | 让变量更新器找回这个实例传出去的元素根 |
同一组件的多个实例可以共享 scoped 标记和变量名,却有各自的 owner uid 与变量值。缺少 owner 不等于 scoped 标记也丢了;选择器已经命中,也不等于它消费的变量存在。
这套协作还解释了模块位置:runtime-core 管 renderer 和 Teleport 范围,runtime-dom 管 document.querySelectorAll()、HTMLElement.style 等浏览器写入。由后者安装 ctx.ut,Teleport 在检测到它时调用,core 不需要反向导入 runtime-dom 才能完成回填。
总结
CSS v-bind() 在编译时生成变量引用与 getter,在运行时由 useCssVars() 写入元素的内联自定义属性。变量值改变后,更新的是元素上的值,静态样式表里的 var() 规则继续复用。普通后代通过真实 DOM 继承取得变量。
Teleport 的外置根不能依赖原组件根上的继承。Teleport 先在自己的范围内补 data-v-owner,变量更新器再按实例归属找回这些元素。scoped 标记、变量名与 owner 分别负责规则匹配、值引用和实例查找;缺少最后一项时,前两项即使存在也不足以完成动态绑定。
系列目录见Teleport 弹窗尺寸为什么会丢。上一篇:Teleport 弹窗尺寸为什么会丢(2):Transition 怎样协调创建和移除。下一篇:Teleport 弹窗尺寸为什么会丢(4):首次打开的变量漏填与修复。
CSS v-bind 的完整编译记录
打开原文件 css-v-bind-compiler-output.json
{
"base": "0ff253bfba3ee8d3d65448bd44ad890fd01b0ecd",
"compiler": "3.5.44",
"id": "data-v-demo",
"input": "<script setup>\nimport { ref } from 'vue'\nconst boxWidth = ref('375px')\n</script>\n<template><div class=\"box\"><span>内容</span></div></template>\n<style scoped>.box { width: v-bind(boxWidth); }</style>",
"cssVars": [
"boxWidth"
],
"development": {
"script": "import { useCssVars as _useCssVars, unref as _unref } from 'vue'\nimport { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from \"vue\"\n\nconst _hoisted_1 = { class: \"box\" }\n\nimport { ref } from 'vue'\n\nexport default {\n __name: 'CssVariableDemo',\n setup(__props) {\n\n_useCssVars(_ctx => ({\n \"demo-boxWidth\": (boxWidth.value)\n}))\n\nconst boxWidth = ref('375px')\n\nreturn (_ctx, _cache) => {\n return (_openBlock(), _createElementBlock(\"div\", _hoisted_1, [...(_cache[0] || (_cache[0] = [\n _createElementVNode(\"span\", null, \"内容\", -1 /* CACHED */)\n ]))]))\n}\n}\n\n}",
"css": ".box[data-v-demo] { width: var(--demo-boxWidth);\n}"
},
"production": {
"script": "import { useCssVars as _useCssVars, unref as _unref } from 'vue'\nimport { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from \"vue\"\n\nconst _hoisted_1 = { class: \"box\" }\n\nimport { ref } from 'vue'\n\nexport default {\n __name: 'CssVariableDemo',\n setup(__props) {\n\n_useCssVars(_ctx => ({\n \"v5b6e5c94\": (boxWidth.value)\n}))\n\nconst boxWidth = ref('375px')\n\nreturn (_ctx, _cache) => {\n return (_openBlock(), _createElementBlock(\"div\", _hoisted_1, [...(_cache[0] || (_cache[0] = [\n _createElementVNode(\"span\", null, \"内容\", -1 /* CACHED */)\n ]))]))\n}\n}\n\n}",
"css": ".box[data-v-demo] { width: var(--v5b6e5c94);\n}"
}
}