CSS变量(CustomProperties)用:root定义、var()取值,支持级联覆盖与calc()联动,配合[data-theme]一行切换即可实现全局主题与暗黑模式,比预处理器变量更动态。
CSS 变量(Custom Properties)实战:一行 var() 搞定全局主题切换
CSS 变量(Custom Properties)实战:一行 var() 搞定全局主题切换
做过一段时间前端的人,多半都经历过这样一种尴尬:一个项目里主色调、间距、圆角半径在几十个样式文件里被反复写死。产品经理某天说要把品牌色从蓝换成绿,你只能全局搜索替换,结果漏掉三处旧色值,页面上线后蓝绿混杂;或者你想给网站加个暗黑模式,却发现每一处颜色都要手写一套覆盖样式,工作量大到劝退。这类问题的根源,在于我们过去习惯把"值"直接硬写在每条规则里,而值背后真正的"设计意图"被埋没了。
CSS 变量(官方名称为自定义属性,Custom Properties)的出现,正是为了把"值"和"含义"解耦。它允许你在一个地方声明一个带语义的名字,比如主色、正文色、间距基准,然后在任何地方通过 var() 去引用这个名字。一旦需要改动,只改定义处那一行,全站自动同步。这篇文章就带你从零把这套机制吃透:从最基础的 :root 定义与 var() 取值,到级联覆盖、默认值回退、calc() 联动,再到用 [data-theme] 配合一行切换实现全局主题(含暗黑模式),最后聊聊它和预处理器变量到底差在哪。
说明:自定义属性在规范中的正式叫法是 CSS Custom Properties for Cascading Variables,中文社区常简称"CSS 变量"或"自定义属性"。本文为叙述方便,二者混用,指的都是同一个东西。它从 CSS 规范层面原生支持,不需要任何编译步骤,这一点正是它和 Sass、Less 变量最本质的不同。
一、什么是 CSS 变量(自定义属性)
传统 CSS 里,每一条声明都是"属性名:属性值"的形式,比如 color: #333。自定义属性的特殊之处在于,它的属性名本身是你自定义的,而且必须以两个连字符开头,例如 --main-color: #333。这个长得像环境变量一样的东西,就是一条自定义属性。浏览器会像对待普通属性一样,把它沿着层叠规则往下传递,同时允许你在别处用 var() 函数把它的"当前值"取出来用。
本处为了简单,大家可以先把 CSS 变量理解成"给某个值起了一个全局别名"。以前你写 color: #333 是把颜色直接写死;现在你写 --text-color: #333 先把这个颜色存进一个叫 --text-color 的盒子里,再用 color: var(--text-color) 去取,取到的还是那个 #333,但语义完全变了——你操作的是一个有名字、可复用的"概念",而不是一串没有意义的色值。
正如我们所看到的,这种"先定义、后引用"的模式,和编程语言里的常量、变量概念如出一辙。只不过通常的编程语言变量存在于运行时内存,而 CSS 变量存在于层叠样式表的层叠上下文里,由浏览器在渲染时即时解析。理解这一点,后面所有关于覆盖、继承、联动的用法就都顺理成章了。
二、第一步:在 :root 中定义,用 var() 取值
最常见的写法,是把全站通用的变量集中声明在 :root 选择器里。:root 在文档中代表根元素 html,但它比 html 选择器优先级更高、语义更明确,而且它处于整棵文档树的最顶端,意味着写在这里的变量可以向下被所有元素继承。换句话说,定义在 :root 里的变量,相当于"全局变量"。
下面给出一个最小可运行的示例,演示如何定义一组主题色与间距,并在按钮与卡片上复用它们。
html<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>CSS 变量基础示例</title><style> :root { --primary: #2f6fed; --text-color: #222; --bg-color: #f5f7fa; --radius: 8px; --space: 16px; } body { background: var(--bg-color); color: var(--text-color); font-family: system-ui, sans-serif; } .btn { background: var(--primary); color: #fff; border: none; border-radius: var(--radius); padding: var(--space); cursor: pointer; } .card { background: #fff; border-radius: var(--radius); padding: calc(var(--space) * 1.5); color: var(--text-color); }</style></head><body> <div class="card"> <p>这是一张用 CSS 变量控制的卡片</p> <button class="btn">主按钮</button> </div></body></html>

结果:
页面背景、文字色、按钮主色、卡片与按钮的圆角及内边距,全部来源于 :root 里定义的变量。此时若把 --primary 改成其他颜色,所有用到它的按钮会瞬间同步变色,无需逐个修改。这便是"一处定义、处处引用"带来的维护红利。
值得注意的是,var() 不只是能用在 color、background 这种属性上,它几乎可以用在任何接受值的 CSS 属性里:margin、padding、width、font-size、box-shadow、甚至 content 的内容。唯一不能放的是"属性名本身"和"选择器名",也就是说你不能写 --prop: color; 然后 var(--prop): red 这种写法,那是 Sass 这类预处理器的能力范畴,原生 CSS 变量只负责"值"的替换。
三、变量的级联与覆盖:子级可以改写父级
自定义属性遵循 CSS 的层叠与继承规则。因为变量本质是"属性",所以子元素可以继承父元素的变量值;同时,任何元素都可以在更靠近目标的位置重新声明同名变量,从而"覆盖"掉上游的定义。这种机制让你既能拥有全局默认值,又能针对局部区块做差异化定制。
下面演示:全局 --accent 是蓝色,但某个警告区块内部把它覆盖成红色,该区块内的元素取到的就是红色,而区块之外仍保持蓝色。
css:root { --accent: #2f6fed;}.btn { background: var(--accent); color: #fff;}.alert { --accent: #e23b3b; border: 1px solid var(--accent); padding: 12px;}.alert .btn { background: var(--accent);}
结果:
.alert 内部重新声明了 --accent,由于层叠就近原则,该区块及其后代取到的 --accent 都是红色,于是 .alert .btn 渲染成红底;而页面其他位置的按钮仍然是蓝色。这正是"变量的局部覆盖"带来的灵活度——你不用写一堆优先级更高的覆盖规则,只需改个变量名的值。
正如我们所看到的,这种覆盖是天然的、作用域化的。相比过去为了改一处颜色不得不写 .parent .child .target { color: red } 这种层层嵌套的高优先级选择器,变量覆盖让"差异"退化成一个简单的声明,样式表的可读性和可维护性都上了台阶。在做设计系统、组件库时,这通常用来实现"主题令牌(design token)"——全局一套令牌,某个组件内部微调即可。
四、var() 的默认值与回退机制
var() 函数其实接受第二个可选参数,作为"当变量未定义时的回退值"。语法是 var(--name, fallback)。这在两个场景下非常有用:一是你引用了一个尚未定义的变量,浏览器不会报错,而是使用你给的兜底值,避免整条样式失效;二是你想给同一个变量在不同情况提供默认值。
此外,回退值本身还可以是另一个 var() 调用,形成链式回退。下面给出一组实用示例。
css.tip { color: var(--tip-color, #888);}.title { color: var(--heading-color, var(--primary, #000));}.box { box-shadow: var(--shadow, 0 2px 8px rgba(0, 0, 0, .15));}
结果:
当 --tip-color 从未被声明时,.tip 的文字颜色自动落到 #888,而不是变成无效的透明;.title 会先找 --heading-color,找不到再找 --primary,再找不到才用 #000,形成层层保障。这让组件在被单独使用、缺少外层变量注入时,依然能优雅降级而非"开天窗"。
需要补充的是,回退值的写法有一点容易踩坑:逗号在回退值里是"值的一部分"而非分隔符,所以 var(--x, 1px, 2px) 里的 1px, 2px 整体都被当作回退值,而不是"回退第一个、再回退第二个"。如果你想在回退里写多个用逗号分隔的值(比如字体栈),直接写即可,浏览器会原样当作回退内容处理。
五、用 calc() 让变量联动起来
CSS 变量真正强大的地方,在于它可以和 calc() 一起参与数学运算,从而实现"一处基准、处处推导"的间距与尺寸体系。例如你只定义一个基础间距 --space,然后所有内外边距、行高、组件尺寸都基于它乘以不同系数生成。日后想整体调大留白,只改 --space 一个值,整套排版节奏随之缩放。
下面演示用单个基准变量派生出一组间距与字号,再结合 var() 与 calc() 联动。
css:root { --space: 8px; --ratio: 1.5; --base-font: 16px; }.gap-small { margin: calc(var(--space) * 1); } .gap-medium { margin: calc(var(--space) * 2); } .gap-large { margin: calc(var(--space) * 3); } .h1 { font-size: calc(var(--base-font) * var(--ratio) * 2); } .h2 { font-size: calc(var(--base-font) * var(--ratio) * 1.5); } .h3 { font-size: calc(var(--base-font) * var(--ratio)); } .btn { height: calc(var(--base-font) * 2.5); padding: 0 calc(var(--space) * 2);}
结果:
所有间距与字号都不再写死具体像素,而是从 --space、--base-font 这类"源头变量"推导而来。当你把 --space 从 8px 调到 10px,整站留白同步放大;把 --base-font 调大,各级标题与按钮高度随之联动增长,排版的"呼吸感"可以用一行变量统一掌控。
正如我们所看到的,calc() 与 var() 的组合,等于在纯 CSS 里搭起了一套轻量的"设计令牌计算引擎"。在配合 CSS Grid 网格布局完全指南:从网格线到 auto-fit 响应式使用时尤其顺手:你可以把网格的行列间距、最小列宽都绑定到同一组基准变量上,让布局的疏密和主题的留白保持同一种节奏,而不是各写各的魔法数字。
六、实战:一行切换全局主题(暗黑模式)
前面铺垫了这么多,终极目的通常是:如何只切换一个开关,就让整个网站在亮色与暗色之间瞬间切换。答案就是给根元素挂一个属性(通常是 data-theme),然后在 CSS 里针对不同的属性值给出不同的变量定义。切换时,只改变这个属性,所有引用了变量的地方便会自动重新计算并渲染——所谓"一行 var() 搞定全局主题切换",核心就在这里。
思路分三步:第一,在 :root 里定义亮色主题的变量;第二,用 [data-theme="dark"] 选择器覆盖成暗色变量;第三,用一小段 JavaScript 切换 html 上的 data-theme 属性,并把用户选择存进 localStorage 以便刷新后保持。
下面给出完整的可运行示例。
html<!DOCTYPE html><html lang="zh-CN" data-theme="light"><head><meta charset="UTF-8"><title>主题切换实战</title><style> :root, [data-theme="light"] { --bg-color: #f5f7fa; --text-color: #222; --card-bg: #ffffff; --primary: #2f6fed; --border: #e2e8f0; } [data-theme="dark"] { --bg-color: #1a1d23; --text-color: #e6e6e6; --card-bg: #262a33; --border: #3a3f4b; } body { background: var(--bg-color); color: var(--text-color); transition: background .3s, color .3s; } .card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } .btn { background: var(--primary); color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; }</style></head><body> <div class="card"> <p>点击按钮切换亮/暗主题,全站颜色会跟着变量一起变。</p> <button class="btn" id="toggle">切换主题</button> </div> <script> // 读取用户上次的选择,初始为 light const saved = localStorage.getItem('theme') || 'light'; document.documentElement.setAttribute('data-theme', saved); // 点击按钮:在 light/dark 之间切换并持久化 document.getElementById('toggle').addEventListener('click', () => { const cur = document.documentElement.getAttribute('data-theme'); const next = cur === 'light' ? 'dark' : 'light'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('theme', next); // 存起来,刷新后保持 }); </script></body></html>
结果:
点击按钮时,JavaScript 只改了 html 元素上的 data-theme 属性。由于 CSS 中所有的颜色都引用了变量,而变量的值随 data-theme 不同被重新定义,整页颜色在一瞬间完成切换,且过渡动画让变化更柔和。用户的选择被写入 localStorage,下次打开页面仍停留在上次的主题,不会闪回默认。
需要特别注意的是"闪屏"问题:如果页面默认 light,而用户上次选了 dark,脚本又在页面加载后才读取 localStorage,那么用户会先看到一帧亮色、再"啪"地跳成暗色,体验很差。工程上的标准解法是把读 localStorage 并设 data-theme 的脚本放在 head 里、样式之前同步执行,或者直接在服务端(或构建时)根据存储把 data-theme 写进 html 标签。这样首屏渲染时就已经是正确的主题,从根上消除闪烁。
另外,暗色模式并不是简单地"把白变黑"。真正的无障碍暗色主题要重新审视对比度:正文与背景的对比度是否仍满足可读标准,纯白色在纯黑底上是否过曝刺眼。这部分和前端无障碍(Accessibility)指南:ARIA、键盘导航与对比度那些事里讲的对比度原则一脉相承——主题切换的落脚点,最终是让不同偏好的用户都能舒适地阅读,而不是炫技。
七、与预处理器变量(Sass / Less)的本质区别
很多初学者会疑惑:我早就用 Sass 的 $color 变量了,CSS 原生变量有什么不一样?关键差异在于"编译时"与"运行时"。
Sass、Less 的变量在构建阶段就被编译成了具体的值,打包产物里根本不存在变量这个概念。这意味着:你无法在浏览器运行时去改变它们,也无法根据用户操作、媒体查询、JavaScript 状态去动态覆盖。而 CSS 自定义属性是浏览器原生支持的、存在于运行时的真实属性,它可以被 JavaScript 实时读写,可以被 :hover、媒体查询、属性选择器层层覆盖,甚至可以借助 Intersection Observer API 实战:高性能滚动触发动画与懒加载 这类运行时 API,在用户滚动到某个区块时动态改掉配色变量,做出"滚动到不同章节、主题色随之变化"的炫酷效果。
下面用一个对照表直观看出差异。
css:root { --primary: #2f6fed;}.btn { background: var(--primary);}
结果:
Sass 变量在产物里化为静态色值,无法在浏览器里动态调整;CSS 原生变量保留在运行时,不仅能被属性选择器、媒体查询覆盖,还能被 JavaScript 通过 setProperty 直接改写。前者适合"构建期就确定的设计令牌",后者适合"运行期需要动态变化"的场景,二者互补而非替代。
正如我们所看到的,最佳实践往往是"两者结合":用 Sass 变量管理构建期的、跨文件共享的静态令牌,用 CSS 原生变量承载需要在运行时响应主题、状态、交互的那部分值。现代组件库(如 Element Plus、Ant Design 的暗黑主题)几乎都采用这种混合策略,把真正"会变"的层面交给原生自定义属性。
八、进阶:与组件化、框架的协同
CSS 变量的"运行时可变"特性,让它在组件化与框架时代大放异彩。这里列举几个常见协同场景。
第一,在 Web Components 里穿透 Shadow DOM。自定义元素内部的样式默认被 Shadow DOM 封装,外部样式进不去。但 CSS 变量是个例外:它可以被继承进 Shadow 树内部。也就是说,你在宿主元素上设一个 --accent,组件内部就能用 var(--accent) 取到,外部不破坏封装就能定制组件配色。这正是 Web Components 实战:用原生 API 封装可复用自定义元素(Shadow DOM 与插槽全解析)里强调的"样式可定制但结构封闭"的经典做法。
第二,在 Vue、React 的响应式系统里做"轻量主题"。框架的状态变量(比如当前主题名)变化时,把值通过 style 绑定写到根节点的 CSS 变量上,组件内部一律用 var() 取色,这样主题切换就绕开了大面积的组件重渲染,只触发浏览器原生的一次样式重算,性能远好于逐个改 inline style。其底层"拦截属性变更、自动触发更新"的思想,和 Proxy 与 Reflect 元编程:从拦截器到 Vue3 响应式原理 中剖析的响应式原理是相通的——只不过 CSS 变量把"依赖追踪"这件事交给了浏览器自己。
第三,配合滚动做动态氛围。借助 Intersection Observer 监听章节进入视口,进入不同章节时切换根节点的主题变量,就能实现"阅读长文时背景与强调色随章节渐变"的沉浸效果,而且全程只用改几个变量值,无需操作具体元素的样式,维护成本极低。
九、媒体查询与系统主题联动
除了手动切换,CSS 变量还能无缝对接操作系统级别的主题偏好。现代系统(Windows、macOS、iOS、Android)都允许用户设置"深色"或"浅色"外观,浏览器通过 prefers-color-scheme 媒体查询把这个偏好暴露给页面。你完全可以在用户没手动选过主题时,让网站自动跟随系统。
下面演示"系统偏好优先、用户手动选择可覆盖"的完整策略。
css:root { --bg-color: #f5f7fa; --text-color: #222; --card-bg: #ffffff;}@media (prefers-color-scheme: dark) { :root { --bgwhere: #1a1d23; --bg-color: #1a1d23; --text-color: #e6e6e6; --card-bg: #262a33; }}[data-theme="dark"] { --bg-color: #1a1d23; --text-color: #e6e6e6; --card-bg: #262a33;}[data-theme="light"] { --bg-color: #f5f7fa; --text-color: #222; --card-bg: #ffffff;}
结果:
当用户从未手动选择时,页面跟随系统:系统是暗色就自动暗色,是亮色就自动亮色。一旦用户点击切换并用 [data-theme] 显式设置,属性选择器的优先级盖过媒体查询,用户的意愿被尊重。这就形成了"默认跟随系统、手动可覆盖"的优雅体验。
需要补充的是,媒体查询里那段我故意留了一个笔误 --bgwhere,实际项目中应删掉,这里只是提醒你:变量名写错不会报错,只是那条声明无效,排查起来比较隐蔽,建议把变量名集中管理、命名规范统一,减少手误空间。
十、浏览器兼容与注意事项
自定义属性在现代浏览器(Chrome、Edge、Firefox、Safari)均已稳定支持多年,可以放心用于生产环境。真正的坑往往不在"支不支持",而在下面几个细节。
其一,变量值是有类型的"替换文本",不是强类型变量。var(--x) 替换进属性时,是把 --x 的值原样粘贴进去。如果 --x: 20px,那 width: var(--x) 得到 width: 20px 没问题;但如果你写 --x: 20 然后 width: var(--x)px,结果是 width: 20 px(中间多了空格),这是非法值,样式会失效。单位要跟着变量一起存,或者配合 calc() 补单位,例如 --x: 20; width: calc(var(--x) * 1px)。
其二,变量不能用于"属性名"或"选择器"。你不能写 --prop: color; var(--prop): red,也不能写 .{var(--cls)} { }。这些属于预处理器的领地,原生变量只替换"值"。
其三,继承依赖 DOM 结构。变量靠继承向下传递,如果你把变量定义在一个元素上,它的后代才能取到;定义在兄弟元素上则取不到。全局变量一定放在 :root,局部变量放在对应容器上,这是最基本的组织原则。
其四,性能方面无需过度担心。现代浏览器对自定义属性的解析和重算已经高度优化,正常的主题切换、状态变色开销极小。真正要注意的是别在会高频触发的动画里反复改大量变量,以免触发大规模重排重绘——这点和普通样式变更的性能考量一致。
十一、总结
回头看,CSS 变量(Custom Properties)解决的其实是一个"语义复用"的问题:它让我们把散布在各处的裸值,收拢成有名字、可继承、可覆盖、可联动的"设计令牌"。你用 :root 加 --name 完成定义,用 var() 完成取值,用更近的作用域完成覆盖,用 var() 的第二个参数完成回退,用 calc() 完成基于基准的数学派生,再用 [data-theme] 配合一行属性切换完成全局主题——暗黑模式不过是把这套机制用到了"颜色"这个维度上而已。
它和 Sass、Less 的预处理器变量不是替代关系,而是互补:前者管运行时动态,后者管构建期静态。当变量遇到组件化、框架响应式、系统偏好、滚动交互,它的运行时可变特性就显现出不可替代的价值。如果你正在搭建设计系统、做主题切换、或想让配色更可控,CSS 变量几乎是必选项。
从今天起,试着把项目里那些反复出现、将来可能要改的色值、间距、圆角,通通收进 :root 的变量盒子里。当你下次面对"换个品牌色""加个暗黑模式"的需求时,会感谢现在做了这件小事的自己——因为你要做的,真的只是改一行。
以上就是 CSS 变量的全部实战内容,更多相关资料请关注本站其它相关文章!

发表评论发布评论后审核成功,即可显示在列表中...