跳到正文

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。

CSS 变量写在组件根 main 上,子元素继承变量,再由 width 的 var 规则计算宽度

这里继承的是自定义属性,.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。完整开发、生产产物见编译记录弹窗。

SFC CSS v-bind 分别编译为静态 CSS var 引用和 setup 取值函数,运行时把值写入 DOM,浏览器再计算样式

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 仍然照常挂载、更新和移动。它不是一个所有样式、所有组件更新都必须经过的通用函数。

普通组件通过 useCssVars 写根并继承,Teleport 先标记外置根,再通过 ctx.ut 回到通用写入函数

三种标识分别解决三个问题

标识 按什么区分 负责什么
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}"
  }
}