CSS 自定义属性,也就是常说的 CSS 变量,是我这几年做前端项目时最依赖的特性之一。刚接触它的时候我也觉得无非就是 Sass 里的$变量搬到浏览器里,直到连续做了几个多主题、多端适配、需要外部定制的项目,我才真正体会到var()的价值:它把本来编译期就定死的样式值,变成了运行时可以被覆盖、被继承、被 JavaScript 动态修改的“活数据”。这篇文章是我在实际业务系统里把硬编码颜色、间距、字号全面改造为“定义属性 + var 引用 + 全局使用”的落地总结,适合已经具备基础 CSS 知识、想深入理解 var 用法的开发者,也适合正在为换肤和组件定制头疼的团队。如果你想快速抄一段换肤代码,可以直接翻到第 4 部分;想弄懂为什么 var 能做到全局生效、怎么组织才不乱,那建议从头读起。
1. CSS 变量到底解决了什么问题
1.1 为什么硬编码值会让项目“改不动”
先说我接手过的一套后台系统,样式表里#1e88e5这个主色值至少出现了 30 次,深浅不一的灰色散落在几十个文件里。刚开始改需求还好,等客户提出“品牌色从蓝色换到绿色”的时候,整个团队都麻了——改一个颜色要全局搜索替换,每次还总有漏网之鱼,最后页面里出现了好几种“新蓝”。
这不是个别现象。硬编码值的本质问题在于:把“值”和“用什么值的地方”紧紧绑在一起,导致同一个语义的颜色、间距、字号在代码库里反复复制。值一变,所有引用点都要跟着动,漏一处就产生视觉不一致。
var()最直接的价值就是解除这个绑定。定义一个--brand-color,所有用到主色的地方写var(--brand-color),改主题时只动定义源头,其余位置自动更新。这是“定义属性、全局使用”的第一层含义,也是最容易被感知到的收益。
但如果你只把它当作“省几个重复数值”的工具,就错过了更大的价值。因为 CSS 自定义属性的值不是在编译期解析的,而是在浏览器运行时解析的。这意味着变量可以被媒体查询覆盖、被某个 DOM 节点重新赋值、被 JavaScript 动态改写,甚至可以参与calc()计算。Sass 变量能做的,CSS 变量能做;Sass 变量做不到的运行时动态修改,CSS 变量也能做。
1.2 自定义属性的基础语法
标准写法很简单:
:root { --brand-color: #1e88e5; --space-md: 12px; } .card { border-color: var(--brand-color); padding: var(--space-md); }自定义属性名字必须以两个连字符--开头,后面可以跟字母、数字、连字符、下划线。取值时用var()函数包住完整的变量名。
有几个细节新手常常忽略。第一,自定义属性名区分大小写,--FontColor和--fontcolor是两个完全不同的变量,团队里如果不统一命名风格,后期排查会很痛苦。第二,值可以是一段几乎任意的 CSS token 序列,它可以是一个颜色值、一个长度、一段calc()表达式,甚至是一段普通字符串,能不能生效取决于使用它的属性是否接受这个值。第三,定义变量时浏览器不会立刻解析它的值,所以写一个非法值也不会报错,只有var()真正用到它的时候错误才暴露出来。
var()还有第二个参数:回退值。
color: var(--brand-color, #333);当--brand-color没有被定义或者值无效时,浏览器会使用#333。回退值也可以嵌套另一个变量:
color: var(--brand-color, var(--fallback-color, #333));这个兜底机制在封装第三方组件时特别好用,后面我会专门展开。
2. 为什么 var() 能实现“全局使用”
2.1 自定义属性天然继承
CSS 自定义属性是普通 CSS 属性,所以遵循 CSS 的层叠、继承规则。其中最关键的是继承:一个元素上定义或继承到的变量,它所有的后代元素都能读取。当某个后代元素自己没定义同名变量时,浏览器会沿着 DOM 树一路向上找,直到:root。
举个例子:
html { --text-color: #333; } header { color: var(--text-color); } article { color: var(--text-color); }--text-color定义在根元素上,但header和article都能读到它,因为浏览器会沿着继承链向上查找。这个机制跟普通 CSS 属性(比如color、font-size)继承非常像,只不过变量把“值”暴露出来,让任何位置都能引用。
我用一个父子组件传默认值的类比来帮助理解:父组件提供一个默认值,子组件不显式接收也能用;但如果某个中间节点覆盖了这个值,那么它内部的后代元素看到的都是覆盖后的新值。CSS 变量的继承链并不是一条单纯的“全局可见链”,而是一条可以被节点截断和重新赋值的动态链路。
正因如此,“全局使用”的本质是:把变量定义在文档根上,所有元素通过继承机制默认可见。它不是一种新的模块化方案,而是充分借助 CSS 自身的层叠与继承能力,让变量成为样式系统里的一等公民。
2.2 局部覆盖让全局不是“全局常量”
继承机制同时带来了一个非常重要的能力:局部覆盖。
:root { --score: 90; } .summary-card { --score: 75; } .summary-card .score-value::after { content: var(--score); /* 这里读到的是 75 */ }当我在.summary-card上重新定义--score后,这个子树内部引用到的就是新的 75,而页面其他区域依然是 90。这种运行时作用域覆盖,是 Sass 变量做不到的。Sass 变量在编译期就已经确定,而 CSS 变量可以在运行时根据 DOM 结构动态决定最终值。
新手容易把全局变量当成全局常量,这恰恰是最大的理解误区。定义在:root上的变量只是“默认值状态”,不是最终结果。任何层级的规则都可以重新定义同名变量,影响的是当前节点及其后代。理解这一点之后,你才能理解组件库换肤、局部主题覆盖这些进阶玩法。
3. 全局变量定义与组织方式
3.1 为什么推荐放在 :root 而不是 html
全局变量最稳妥的挂载点是:root伪类。很多人会顺手写在html {}里,效果上也行,但规范上有些差异。
html是元素选择器,特异性是 0-0-1;:root是伪类选择器,特异性是 0-1-0。虽然它们匹配的都是同一个 html 元素,但一旦你的代码里还用了html选择器去定义变量,:root里的定义会因为特异性更高而胜出。为了避免这种层叠顺序上的意外,直接用:root是更清晰、更推荐的做法。
更进一步的实践是:把全局变量统一抽到一个独立文件里,比如就叫tokens.css。这个文件专门存放所有设计令牌,不写任何组件样式。进入项目,想了解设计规范,先打开这个文件就够了,不需要在几十个组件文件里翻找某个色值。
:root { --color-primary: #1e88e5; --color-primary-hover: #1565c0; --color-text: #1a1a1a; --space-xs: 4px; --space-sm: 8px; --space-md: 16px; --radius-sm: 4px; --radius-md: 8px; }我见过不少项目把全局变量放在 bootstrap 或 reset 文件里,到最后想改一个间距都不知道该去哪改。独立文件 + 统一文件名的约定,是全局变量可维护性的第一道保障。
3.2 变量命名:按语义命名,不按具体值命名
变量多了之后,命名规范是最大的坑。有人喜欢定义--blue-500、--red-default,一开始很直观,等品牌色从蓝换成绿,这些名字就彻底名不副实了。
我强烈建议按“语义”命名,而不是按“视觉值”命名。主色就叫--color-primary,文本色就叫--color-text,间距层级就叫--space-md。换主题时,改的是变量的值,变量的名字始终保持稳定,这样业务代码里的var(--color-primary)才会一直成立。
常见的命名前缀可以参考这个表格:
| 前缀 | 语义 | 示例 |
|---|---|---|
| --color- | 颜色相关 | --color-primary、--color-danger |
| --space- | 间距 | --space-sm、--space-lg |
| --font- | 字号/字体 | --font-size-body、--font-weight-bold |
| --radius- | 圆角 | --radius-sm、--radius-full |
| --shadow- | 阴影 | --shadow-card、--shadow-modal |
| --z- | 层级 | --z-header、--z-modal |
变量数量超过二十个之后,建议额外维护一份“变量注册表”。这个注册表不只是一份清单,还要写明每个变量的适用范围和注意事项。比如某个颜色变量只用于错误提示,就不要把它顺手用在按钮主色上。没有这种纪律,全局变量很快就会变成另一种形式的混乱。
3.3 组件级变量与全局变量的分层
全局变量不是唯一的变量形态。实际项目里,我习惯把变量分成三层。
第一层是全局设计令牌,放在:root,对应设计规范里的色彩、间距、字号、阴影。
第二层是组件基础变量,放在组件根类上,组件内部统一引用。比如一个按钮组件可以这样定义:
.btn { --btn-radius: var(--radius-sm); --btn-bg: var(--color-primary); }第三层是实例级变量,由组件的使用方在具体场景里覆盖。一个更大的按钮只需要覆盖--btn-radius和--btn-bg,不需要重写一遍组件内部冗长的样式。
分层的好处是:组件内部只负责引用变量,不关心值具体是多少;外部通过覆盖变量来定制组件外观。这实际上把变量变成了组件的“配置接口”——外部不需要知道内部怎么实现,只要设置几个变量就能改变外观。很多成熟 UI 库的定制皮肤方案,核心思路就是这个。
4. 全局使用场景实例拆解
4.1 一键换肤与暗色模式
换肤是最能体现“定义属性、全局使用”价值的场景。思路很简单:默认主题就是:root上定义的那套变量,暗色主题通过一个属性选择器覆盖同名变量。
:root { --bg: #ffffff; --text: #1a1a1a; --primary: #1e88e5; } [data-theme="dark"] { --bg: #181818; --text: #f0f0f0; --primary: #64b5f6; } body { background: var(--bg); color: var(--text); }切换主题的 JavaScript 只需要一行:
document.documentElement.setAttribute('data-theme', 'dark');这条语句改了 html 上的>.btn { --btn-bg: var(--color-primary); --btn-text: #ffffff; --btn-radius: var(--radius-sm); background: var(--btn-bg); color: var(--btn-text); border-radius: var(--btn-radius); } .btn--danger { --btn-bg: var(--color-danger); }
组件内部只认--btn-bg、--btn-text、--btn-radius,不关心它们最终是什么值。使用方想要定制一个“圆角更圆、颜色是橙色”的按钮时,不需要去改组件源码,只需要在页面上覆盖这几个变量:
.hero-btn { --btn-radius: 24px; --btn-bg: #ff8800; }对于组件外层的全局使用,可以再加一层安全兜底:
.widget { background: var(--widget-bg, #f5f5f5); }这个写法保证了组件就算被拿到一个完全没有定义变量的环境里,依然有一个合理的默认表现。这就是var()第二个参数的核心用法——把第三方的定制入口和自身的默认值解耦。我曾经在某个内部组件库里全面采用这种方式后,后续的换主题、适配外部品牌色,都没有再改动组件内部一行代码。
4.3 响应式系统的变量化改造
使用 CSS 变量之后,响应式布局也不需要在每个组件里重复书写媒体查询。可以把断点对应的设计决策统一收敛到变量定义上:
:root { --space-unit: 4px; --gap: calc(var(--space-unit) * 2); } @media (min-width: 768px) { :root { --gap: calc(var(--space-unit) * 4); } }页面上所有用到var(--gap)的地方,在窄屏时自动是 8px,宽屏时自动是 16px。无论卡片间距、表单间距还是导航菜单的间隔,都跟着这个变量统一变化,不用逐一去改每个组件的媒体查询。
这种做法的好处是“设计决策集中化”。设计系统里最关键的元素间距、栅格宽度,只需要调整变量层,就能让整站布局发生全局变化。响应式改造从“每个组件都要兼顾多种断点”变成“变量层适配断点,组件层引用变量”,维护成本大幅下降。
如果还需要更细的容器响应,可以配合容器查询使用,原理也是一样的:在容器层覆盖变量,容器内部引用变量。
4.4 与 Sass 变量的安全配合
经常有人问 Sass 变量和 CSS 变量到底怎么选。我的答案始终是:两者不冲突,甚至可以配合使用。
Sass 变量在编译期做逻辑组织和复用,适合生成大型批量样式;CSS 变量在运行时做动态主题控制,适合需要切换和覆盖的场景。比如我用 Sass 的@each生成便捷类,同时让这些类引用 CSS 变量:
$sizes: 2, 4, 6; @each $size in $sizes { .gap-#{$size} { gap: calc(var(--space-unit) * #{$size}); } }这样既享受了 Sass 的循环能力,又保留了 CSS 变量的运行时灵活性。不过要特别注意:Sass 无法直接把 CSS 变量当作数字参与编译期运算。var(--space-unit) * 2这句话如果写在 Sass 环境里,是不成立的。正确的做法是在 CSS 中使用calc()完成计算,Sass 只负责输出数值和选择器。
4.5 全局变量在伪元素与动效中的应用
变量不仅能用于常规属性,也能用于::before和::after。由于伪元素本身从宿主元素继承变量,所以我们可以用同一个变量驱动伪元素的内容、位置、颜色:
.badge { --badge-content: "NEW"; } .badge::after { content: var(--badge-content); }这对“定义属性、全局使用”是一个很好的补充:变量不仅能被全局引用,而且能跨越元素甚至伪元素的边界,让一个状态值同时影响多个视觉表现。配合@property注册有类型的变量后,变量还可以参与 CSS 过渡动画。比如角度变量从 0 转 360 度,进度条颜色跟着渐变,这类用法在项目里很有想象空间,不过要使用时需要关注浏览器的兼容情况。
5. 常见问题与排查技巧实录
5.1 变量“没生效”的第一排查思路
var()没生效是提问区最高频的问题。遇到这种情况,按下面三条顺序排查,基本都能定位。
第一,看变量是否被更高优先级或更后定义的规则覆盖。因为自定义属性遵循层叠规则,:root里定义了一个值,某个祖先选择器里又定义了同名变量,后者的层叠顺序可能把前者压住。用浏览器 DevTools 选中对应元素,在 Styles 面板最下方看变量定义来源,会显示哪一行规则生效、哪一行被覆盖,非常直观。
第二,看变量名是否大小写一致。--LinkColor和--linkcolor在浏览器看来是两个完全不同的变量,任何不匹配都会导致找不到值。
第三,看值是否真的合法。前面说过,自定义属性的值不会在定义时解析,所以--bad-padding: 1px solid red;这种“值本身是杂糅 token”的写法并不会在定义时报错。真正到了padding: var(--bad-padding)的时候,padding 不接受 border 样式的值,整个属性就会被判定为无效。DevTools 的 Computed 面板能帮你看清楚这一点。
5.2 回退值存在的那些坑
var(--x, fallback)是很容易被搞错的一个语法点。回退值不是“浏览器不支持 var 时的降级方案”,而是“变量未定义或值无效时的兜底”。所以有三个细节要注意。
第一,--x: ;这种“值仅仅是一个空格”的自定义属性是存在的,var()会把它读取为空格字符串,而不是触发回退逻辑。有时候看到变量明明定义了,但样式表现怪异,可能就是值里混入了不该有的空格。
第二,回退值本身可以是一个列表,比如字体族:
font-family: var(--font-family, "PingFang SC", sans-serif);这里的逗号是字体列表的一部分,浏览器能正确处理。
第三,回退值嵌套层级越多越难排查,所以库代码里最好限制一层嵌套即可。给外部使用的组件,我通常会在一开始就设计好“没有外部变量时也能看的默认值”,而不是让外部用户去理解复杂的回退链。
5.3 变量参与计算的注意事项
变量参与数值运算靠的是calc():
:root { --space-unit: 4px; --gap: calc(var(--space-unit) * 2); }这里面最容易踩的坑是单位。变量必须带着合法单位参与运算。如果一个变量定义成了4px,它可以被calc()使用;如果定义成没有单位的数字4,那它不能直接和一个长度单位相乘。
无单位数字在 CSS 变量里也很常见,主要用于opacity、z-index这类接受数字的属性:
:root { --opacity-strong: 0.65; } .modal-mask { opacity: var(--opacity-strong); }如果要对这类无单位数字做倍数计算,不要指望在calc()里直接calc(var(--opacity-strong) * 2)能通——规范层面对混合单位的计算有严格要求。更稳的做法是提前在变量定义层算好值,业务代码里只负责引用,不堆复杂数学。
关于calc()的乘法除法:传统浏览器对乘除的支持并不一致,我的建议是生产环境优先用加减法组合或预计算值,不要在业务代码里写依赖最新语法的高阶数学表达式,否则会在低版本浏览器上踩到大坑。
5.4 JavaScript 动态修改变量的思路
让变量从“静态配置”变成“动态状态”,是 CSS 变量的高阶玩法。写入全局变量:
document.documentElement.style.setProperty('--brand-color', '#ff6600');读取全局变量:
getComputedStyle(document.documentElement) .getPropertyValue('--brand-color') .trim();修改某个局部容器的变量:
const card = document.querySelector('.card'); card.style.setProperty('--score', '88');动态改变量有几个性能问题需要关注。最典型的场景是滚动事件里不断更新坐标变量。如果每触发一次滚动就立刻调用setProperty,浏览器会高频触发样式重算,极端情况下页面会出现卡顿。正确的做法是用requestAnimationFrame合并写入:
let ticking = false; window.addEventListener('scroll', () => { if (!ticking) { window.requestAnimationFrame(() => { document.documentElement.style.setProperty('--scroll-y', window.scrollY); ticking = false; }); ticking = true; } });把写入操作合并到每一帧最多一次,视觉表现顺滑,样式计算压力也小很多。这个模式在做视差滚动、自定义光标、Canvas 联动等场景里很实用。
5.5 变量值覆盖时机与层叠顺序
变量覆盖的时机比很多人想象得隐蔽。比如在媒体查询里覆盖变量:
:root { --gap: 8px; } @media (min-width: 768px) { :root { --gap: 16px; } }这种写法没问题,但如果你同时在一个组件容器上又定义了--gap,那么容器内部的元素永远优先使用容器上的值,媒体查询的全局覆盖对它们不生效。这是很多“响应式组件为什么没跟着变”的常见原因。
排查时,最好先确认这个元素自己的 CSS 规则和祖先节点上是否存在同名变量。DevTools 中选中元素后,Elements 面板的 Styles 标签会显示自定义属性的继承链。遇到覆盖不生效,先看有没有更近的祖先重新定义过变量,再去看媒体查询条件是否真的命中了。
另外还要记住:在:root里定义的变量如果被一个更高特异性或更后面的规则覆盖,那覆盖范围是整个文档。比如在body上定义了同名变量,那么body内部所有后代读到的新值,body外部的元素不受影响。这既不是 bug,也不是全局失效,只是 CSS 变量作用域的固有逻辑。
6. 我在项目里的长期实践心得
变量用到后期,真正考验的不是能不能写出var(--x),而是变量体系的设计纪律。我给自己定了几条规则。
第一,新增变量之前先查一遍现有变量列表,能复用就不要新建。CSS 变量一旦多起来,最可怕的不是写错,而是同一个语义出现七八个名字。
第二,:root只放跨组件、跨场景共享的设计常量。组件内部的状态和逻辑优先放在组件根类上,不要让所有变量都涌向全局。全局变量并不是越多越好,它也需要“分层治理”。
第三,给每个第三方组件留好变量入口,并且提供安全的默认值。这样外部接入方不需要理解组件内部实现,只要覆盖几个变量就能换风格。定制入口本身就是组件 API 的一部分。
最后分享一个小技巧:我会在tokens.css的顶部写清楚变量命名规范和分组的注释,把这个文件当成设计系统的前端说明书。项目成员接手时,读完这份文件就能快速建立起全局认知。CSS 变量的价值在小型页面里也许不明显,但项目一旦复杂到需要换肤、需要多品牌线、需要组件库对外开放,你就会明白“定义属性、全局使用”这几个字的分量。