var() 的解析、陷阱与妙用
关键心智模型:自定义属性是一个代换值 (substitution value),不是普通属性那样「写进去就解析成具体的值」。浏览器只检查它是不是合法的 token 流(几乎什么都收),把这串 token 原样存着;直到某个 var() 在 computed value time 把这串 token
贴回到目标属性里,才真正去解析。
1 · var() 是怎么算出来的
1.1 · fallback 只在「变量未定义」时才救场
var(--size, 80px) 的第二个参数是 fallback。但它的触发条件很窄:仅当 --size 没被定义,或被显式设成 initial(自定义属性的 guaranteed-invalid value)时才用 fallback。只要 --size 有任何「内容」,fallback
就不会出场——哪怕那内容代换后是非法的(那是 IACVT 一节的事)。点下面四个值,看同一句 width: var(--size, 80px) 取到多少:
反直觉点: --size: initial 和「完全不定义」效果一样——都触发 fallback。因为把自定义属性设成 initial 不是「设成某个初始值」,而是把它打回 guaranteed-invalid value(一个「保证非法」的特殊空值),var()
看见它就当作没定义,转而吃 fallback。空格 hack 一节正是玩这一点。
fallback 是「第一个逗号之后的全部」: var() 只把第一个逗号当分隔符,其后即便还有逗号也整段算作 fallback。所以 var(--family, "PingFang SC", sans-serif) 的 fallback 是
"PingFang SC", sans-serif 一整串(对 font-family 这类本就接受逗号列表的属性正合适);若误以为这里有两个 fallback「依次尝试」,就理解错了。特例:var(--x, )(逗号后留空)语法合法,表示 fallback 是空 token 流——和
空格 hack 里用到的「空值」是同一类东西。
1.2 · 另一个陷阱::root 里的 calc 会被「冻结」
自定义属性会继承,而且它继承下来的是已经代换完的 computed 值。所以当你在 :root 写 --double: calc(var(--base) * 2),这个 var(--base) 在 :root 当场就用 :root 的
--base 算掉、冻成结果往下传。子元素后来把 --base 改大,也带不动那个早已算定的 --double。若想让它随子元素变化,就不要在 :root 提前计算,把 calc 留到使用它的那一层。
1.3 · 循环引用 (dependency cycle):环要到 computed 阶段才暴露
自定义属性可以互相引用,--a: var(--b) 在流程图第 1 格「收集声明」里完全合法——它不过是一串合法 token,浏览器并不在这一步追查 --b 是谁。可一旦引用链兜回自己 () 形成 dependency cycle,问题要拖到第 4 格 computed value time 才暴露:浏览器展开 var() 时检测到环,按规范把环里涉及的每一个自定义属性一起判为 invalid,统统打回 guaranteed-invalid value。后果正好复用前两节的规则——取它的 var() 若带 fallback
就走 fallback(等同未定义);若没有 fallback、或代换进普通属性后非法,则该属性 IACVT。点下面切换「成环 / 断开」,看同一句 width: var(--a, 80px) 取到多少:
和 IACVT 区分: 环不是「语法错误」——--a: var(--b) 照样能过第 1 格、照样能赢下 cascade,失败和 IACVT 一样都发生在代换那一刻,而非解析期。差别在于:IACVT
是「代换出的值对目标属性非法」,环则是「代换根本无从下手——引用兜回了自己」,于是环里的变量被集体判 invalid。
小结: 自定义属性 =「先把原始 token 存着,等 var() 在 computed 阶段才代换并解析」。把握这条,fallback 为何那样触发、为什么 :root 的 calc 会冻结、循环引用为何要到 computed 阶段才崩,就都能解释清楚。!important 一节会接着看流程图第 2 格「cascade 决胜」里 !important 的反直觉行为。
2 · !important 作用在「声明」上,不在「值」里
自定义属性几乎什么字符都收——--whats-up: (👍ᴗ_ᴗ)👍 都是合法的值。正因为太宽松,很容易以为 --color: red !important 里的 !important 也被当成值的一部分存了进去。并不是。
!important 是声明的旗标,在解析时就被剥离,真正存进变量的值只有 red。
旗标和值分家,带来两个反直觉的结论,正是这一节要实测的:
- 在自定义属性自身的 cascade 里,带
!important的声明压过更高 specificity 的声明——这一步就是 var() 一节流程图的第 2 格(cascade 决胜)。 -
但这个 important 不会顺着
var()传递给「用到它」的那条声明——一个常被忽略、规范却写得很清楚的细节。
2.1 · 剥离旗标:存进变量的值里没有!important
给真实元素注入 --color: red !important,再用 getComputedStyle(el).getPropertyValue('--color') 读回来——看它到底存了什么:
两种写法存进去的值完全一样,都是 red。 唯一的区别是「这条声明重不重要」——那是声明级的旗标,根本不进值里。所以你无法把字面的 !important 写入一个自定义属性的值;它总会被解析器当旗标剥离。
2.2 · cascade:!important 压过 specificity
这是 Frontend Masters 那篇的核心例子。低 specificity 的 p 选择器用 !important 设 --color: red;紧挨着用变量的 .greeting(specificity 更高)设 --color: blue。谁赢?切换 !important 看 color: var(--color) 的真实结果:
关键:specificity 只在「都不带!important」时才说了算。 一旦低 specificity 那条挂上 !important,它就反超。注意 important 在这里决定的是「变量取哪个值」,与 color 这条声明是否重要无关——那是下面「important 传不过
var()」一节讨论的内容。
2.3 · important 传不过 var():用变量的那条声明,重要性归它自己
「cascade」一节里 --color 靠 !important 赢下了「变量的 cascade」,稳定解析成 red。但用它的 color: var(--color) 这条声明本身重不重要,跟变量的 important 毫无关系——它只看自己写没写
!important。所以下面再来一条更高 specificity 的普通 color: green,就能把它压下去:
心智模型:重要性分两层,各自独立,从不互相传递。 第一层是 --x: … 这条声明——它的 important 决定变量最终持有哪个值;第二层是 prop: var(--x) 这条声明——它的 important 决定这条 prop 在同属性里赢不赢。变量是 important
的,绝不等于「凡是用了它的声明都变 important」。
3 · IACVT:代换之后才发现非法
IACVT = Invalid At Computed Value Time(在「计算值」这一步才变非法)。它和你熟悉的「写错了被忽略」是两种失败模式,差别全在什么时候失败:
- 解析期报错——
color: 20px一进门就因类型不符被丢弃,这条声明当没写过,自然回退到上一条合法声明。 -
IACVT——
color: var(--bad)语法完全合法,顺利赢下 cascade(把同属性的其他声明都挤掉了);直到 computed 阶段把--bad代换成20px,才发现对color非法。此时已经回不去上一条了——整条声明变成unset。
3.1 · 同一段文字,两种写法,结局完全不同
父元素 color: teal;子元素都先写 color: blue,再用两种方式叠一个「非法的颜色」。切换看子元素 getComputedStyle().color 的真实结果:
这是最容易误判的一点: 直觉上以为 var() 失败会「优雅降级」回退到上一条 color: blue——实际不会。它早在 cascade 阶段就把 blue 挤掉了,等代换失败时只剩 unset 一条路径。所以结果既不是 blue、也不是黑,而是从父元素继承来的 teal。
3.2 · unset 落到哪:看属性可不可继承
IACVT 把声明变成 unset,而 unset 是「见机行事」:可继承属性(如 color)→ 同 inherit,取父值;不可继承属性(如 background-color)→ 同
initial,回属性初始值(transparent)。左右两块用同一个非法变量 --bad: 20px,看落点不同:
3.3 · shorthand 里中一个 var(),整条 shorthand「全军覆没」
IACVT 作废的是整条声明。若这条声明是 font / margin / background 这类 shorthand,它展开的所有 longhand 会一起 unset——哪怕只有一个子值来自非法的
var(),那些写死的、本来没问题的子值也跟着陪葬。(对比上一节:字面写错的 shorthand 在解析期就整条被丢,那是另一回事;这里 shorthand 含 var(),语法合法、赢下 cascade,失败发生在代换之后。)下面两块都想要 italic 700 22px,区别只在
font-family 是写进 font shorthand、还是拆成独立 longhand。把 --family 切成非法值,看谁全垮、谁只丢字体:
结论: 给 shorthand 喂一个可能失败的 var(),押上的是整条 shorthand。想缩小爆炸半径,就把易错的那个值单独写成 longhand——失败时只有它一个 IACVT 回退,其余写死的子值毫发无伤。
3.4 · 现代 CSS 里的隐形版本
这个问题在「用 var() 给新特性做回退」时最隐蔽。下面想用 5cqi(容器查询单位)做流体字号、给老浏览器留 var(... , 1rem + 1.5vw) 当 fallback。问题是:在支持 var() 但不支持 cqi 的浏览器里,第二条声明语法合法、赢下
cascade,代换后 5cqi 非法 → 整个 clamp() IACVT → font-size 回 initial(medium),标题层级直接塌掉,fallback 根本没机会上场。
注意区分: var() 的 fallback 只处理「变量未定义」,无法处理「代换后的值对这个属性非法」。要应对「浏览器不识别这个值 / 单位」,需要用 @supports。
4 · @property:给变量加类型与初始值
前面 var() 与 IACVT 两节里的变量都是「未注册的」(unregistered):无类型、无初始值、默认继承、不可动画。@property
给变量补上三件元信息——syntax(类型)、inherits(是否继承)、initial-value(初始值),它就「升级」成 registered custom property,行为更可控:
4.1 · 类型检查:写入非法值 → 直接回 initial-value
最大的不同:registered 变量有类型校验。给它一个不符合 syntax 的值,这次赋值整个无效,变量当场落回 initial-value,不会把非法内容存下来。而 unregistered 变量来者不拒,非法值照存,等到 var() 被使用时才以 IACVT 暴露问题(回 unset)。两块都做 background: var(--tint),给同样的值,看结果:
所以 @property 给变量提供了一道保护: 即便有人传错值,组件也只会退到你预设的 initial-value(一个你能控制的安全色),而不是整块透明 / 继承一个意外的值。这比未注册变量的 IACVT 行为可预测得多。
4.2 · 注册之后,var() 的 fallback 几乎「失效」了
还有一个容易忽略的连锁反应。fallback 只在变量是 guaranteed-invalid(未定义 / 显式 = initial)时才上场;可一旦用 @property 注册并给了 initial-value,这个变量就再也回不到 guaranteed-invalid——没赋值时它是 initial-value,赋 initial 也只是退回 initial-value。于是 var(--c, fallback) 里的 fallback 形同虚设,真正起回退作用的是 initial-value。两块都写 background: var(--c, hotpink),左边的
--c 注册过(<color>,initial-value: gold)、右边没注册,切换 --c 的取值看差别:
记法: 想给注册过的变量设「默认值」,改 initial-value,别再依赖 var() 的 fallback——后者只为未注册变量(可能 guaranteed-invalid)而存在。注册带来可预测性的同时,也悄悄改写了 fallback 的触发条件。
4.3 · 另一项能力:registered 变量能做动画
未注册变量不可插值——对它做 transition,它只会在中途跳变一次。一旦用 @property 声明了类型(如 <angle> / <color> / <number>),浏览器就知道怎么在两值间平滑插值。下面两块都
transition: --angle .8s 并旋转一个 conic-gradient,点按钮看区别:
5 · var() 是 token 流代换,不是字符串拼接
最常被误用的一点:把 var() 想成「字符串替换」。它在 computed value time 做的是 token 流代换——把变量存着的那串 token 原样贴进属性值,不会重新分词,也不会和相邻字符黏成新 token。所以
var(--n)px 不会拼出 120px:120 和 px 始终是两个独立 token。先看这个最高频的「数字接单位」陷阱:
为什么不拼: 源码里写 120px 是一个 dimension token;而 var(--n)px 代换后得到的是 <number 120> 紧跟 <ident px> 两个 token——代换阶段不会把它们重新合并成一个 dimension。要「数 → 长度」,得在
calc() 里做 var(--n) * 1px 这种类型乘法。
5.1 · 同源的几个限制:var() 只能站在「属性值」位置
同样因为「token 流代换、不参与拼接」,var() 还有一组容易踩的边界——它无法用来拼属性名、嵌进选择器或 media query,也不会钻进 url() 内部去展开。下面几条都不成立,右侧给出对应正解:
一句话总结: var() 把整段 token 原样贴进属性值,既不拼字符串、不和相邻字符黏成新 token,也不能出现在属性名 / 选择器 / media query 这些非「属性值」的位置。需要「数 + 单位」用
;需要「带 url 的值」就把整个
存进变量再 var() 出来。
6 · 空格 hack:一个变量开关多处值
Lea Verou 的经典技巧,把 var() 一节的 guaranteed-invalid 与 fallback 两个特性组合成一个「CSS 布尔开关」。在 if() 普及之前,这是用纯 CSS 做条件样式最接近的写法。它只用到两个特殊值:
--ON: initial——把变量打成 guaranteed-invalid value。被var(--x, 用这个)引用时,浏览器当它没定义,于是吃 fallback → 那处样式打开。-
--OFF: ⎵——值是一个空格。空白是合法的**「空值」,在任何能放var()的地方都合法、且不贡献任何内容。被var(--x, fallback)引用时,变量「有值(空)」→ 跳过 fallback、代换成空 → 那处样式关闭**。
于是一个变量 --raised 设成 var(--ON) 还是 var(--OFF),就能同时拨动好几条
声明。点开关,看一个变量带动 box-shadow + transform + outline 三处一起亮灭:
为什么是「空格」而不是空字符串? 自定义属性的值不能为真正的空(那是语法错误),但可以是纯空白。一个空格就是合法的「空值」:它在 var() 代换处不留下任何字符,却又让变量算作「已定义」,从而优先于 fallback。这正是
--OFF 能「关闭」的原理——用一个不可见的空格替代 fallback。
6.1 · 另一种用法:append(追加)
上面的开关让整条声明在「fallback 内容」与「空」之间切。Lea 原文的形态是追加:把 var(--toggle, 额外层) 拼在一段固定值前面,开 = 多一层、关 = 只剩固定值。比如给背景可选地叠一层高光渐变:
局限(原作者也强调): 这套 hack 只擅长「加 / 不加」——追加一层或整条开关。它不擅长「在 A 值和 B 值之间二选一」(比如红底 vs 白底)。真要做完整的三元条件,等 if() / @when 这些原生条件语法普及。在那之前,空格 hack
仍是覆盖面最广、兼容性最好的纯 CSS 条件写法。
相关链接
var() 与 fallback
- How Custom Property Values are Computed moderncss.dev 把「substitution value / computed value time / fallback 何时生效 / :root 固化」讲清楚的那篇。
-
CSS · var()
developer.mozilla.org
var(--x, fallback)的语法、fallback 触发条件与 guaranteed-invalid value。
!important
-
!important and CSS Custom Properties
frontendmasters.com
点明
!important是声明的旗标而非值的一部分,以及它在变量 cascade 里如何压过 specificity。 -
CSS · !important
developer.mozilla.org
!important如何改变 cascade 优先级,以及它作用于「声明」而非「值」的定位。
IACVT
- CSS: What is IACVT? bram.us Bramus 用 CSS value processing 的六个阶段定位 IACVT 发生在哪一步,以及它和「解析期报错」的本质区别。
-
Using CSS custom properties · Invalid values
developer.mozilla.org
代换后非法时
unset的落点 —— 可继承属性取父值、不可继承属性回 initial。
token 流代换
- CSS · calc() developer.mozilla.org 「无单位数 × 1unit」完成数值到长度 / 角度的类型转换 —— var() 接单位的正解。
@property
-
CSS · @property
developer.mozilla.org
@property的syntax/inherits/initial-value描述符,注册后变量的类型检查与可动画行为。 -
CSS.registerProperty()
developer.mozilla.org
JS 侧等价物:运行时用
CSS.registerProperty()注册带类型的自定义属性。
空格 hack
-
The var() space toggle hack
lea verou
Lea Verou 提出用空格 /
initial两个值,让一个自定义属性当多路开关用。
规范总参考
-
CSS Custom Properties for Cascading Variables Module Level 1
drafts.csswg.org
规范本体:把 custom property 定义为「被所有属性接受的代换值」,并给出
var()的代换语义、guaranteed-invalid value、fallback、循环引用与 IACVT 的规范条文。 -
MDN · Using CSS custom properties & @property
developer.mozilla.org
custom property 的继承、
var()语法、@property的syntax/inherits/initial-value与CSS.registerProperty()。 -
Can I use · CSS Variables (Custom Properties)
caniuse.com
实时浏览器支持矩阵:
--*自定义属性与var()的全局可用性。 -
Can I use · CSS @property
caniuse.com
实时浏览器支持矩阵:
@propertyat-rule 与CSS.registerProperty()的全局可用性。