← 首页 / var() 的解析、陷阱与妙用 待审核
custom properties · var()

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 是谁。可一旦引用链兜回自己 (aba--a \to --b \to --a) 形成 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。谁赢?切换 !importantcolor: 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:120px 始终是两个独立 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 这些非「属性值」的位置。需要「数 + 单位」用 calc(1unit)calc(\dots * 1unit);需要「带 url 的值」就把整个 url()url(\dots ) 存进变量再 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),就能同时拨动好几条 var(raised,)var(--raised, \dots ) 声明。点开关,看一个变量带动 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 @propertysyntax / inherits / initial-value 描述符,注册后变量的类型检查与可动画行为。
  • CSS.registerProperty() developer.mozilla.org JS 侧等价物:运行时用 CSS.registerProperty() 注册带类型的自定义属性。

空格 hack

规范总参考