CSS 变量 + 主题切换:从 CSS-in-JS 回归原生方案的实践之路

15 阅读1分钟

一、故事的开头:一次构建耗时让我开始反思

事情是这样的。项目用了 styled-components 做主题系统,功能没问题,暗色模式切换丝滑得很。直到有一天,项目膨胀到 600+ 组件,dev server 启动要 40 秒,HMR 改个颜色值要等 3 秒。

打开 Chrome DevTools 的 Performance 面板一看——主题切换时,JS 执行时间 200ms+,整棵组件树在重新渲染。

就为了换个颜色?

颜色本来就是 CSS 的事,为什么要绕一圈让 JS 来管?

二、CSS-in-JS 做主题:到底贵在哪?

先搞清楚运行时成本。以 styled-components 为例:

// ❌ CSS-in-JS 方案:主题切换触发全量 re-render

const lightTheme = { bg: '#fff', text: '#333', primary: '#1890ff' }
const darkTheme = { bg: '#141414', text: '#ffffffd9', primary: '#177ddc' }

// ThemeProvider 本质是个 Context
<ThemeProvider theme={isDark ? darkTheme : lightTheme}>
  <App />
</ThemeProvider>

// 每个组件通过 Context 消费主题
const Card = styled.div`
  background: ${props => props.theme.bg};    // 运行时求值
  color: ${props => props.theme.text};       // 主题变 → 函数重新执行 → CSS 字符串重新生成
`
// 切换主题时:
// 1. Context value 变了 → 所有消费 theme 的组件触发 re-render
// 2. 每个组件重新执行模板函数,生成新的 CSS 字符串
// 3. styled-components 做 hash 比对,更新 <style> 标签
// 一个颜色变了,600 个组件跟着抖一遍

这就像你改了公司 Wi-Fi 密码,结果每个员工的电脑都要重启——明明换个密码就行了。

运行时成本拆解

环节耗时占比用 CSS 变量能否跳过
Context 传播~15%完全跳过
模板函数执行~35%完全跳过
CSS 字符串生成~25%完全跳过
DOM style 更新~25%两种方案都要,但粒度不同

前 75% 的成本,用 CSS 变量可以直接砍掉。

三、CSS 变量做主题:原理其实很朴素

CSS 自定义属性的核心能力:声明一次,到处引用,改一处全局生效

/* ✅ 在根节点声明变量 */
:root {
  --color-bg: #ffffff;
  --color-text: #333333;
  --color-primary: #1890ff;
}

/* 暗色主题:只需覆盖变量值,所有引用处自动更新 */
[data-theme="dark"] {
  --color-bg: #141414;
  --color-text: rgba(255, 255, 255, 0.85);
  --color-primary: #177ddc;
}

/* 组件样式引用变量,写一次永远不用改 */
.card {
  background: var(--color-bg);
  color: var(--color-text);
}

切换主题只要一行:

// 翻个开关,整个页面的颜色全换了
document.documentElement.setAttribute('data-theme', 'dark')
// 没有 re-render,没有 JS 重新计算
// 浏览器 CSS 引擎原生处理变量继承,比 JS 快一个数量级

主题切换是纯 CSS 行为,JS 只负责翻开关。这不是优化技巧,是选对了赛道。

四、工程化落地:不是改几个颜色那么简单

知道原理是一回事,在真实项目里落地是另一回事。以下是实际迁移过程中踩出来的路。

4.1 变量体系设计:三层架构

变量不能随便命名,否则维护成本比 CSS-in-JS 还高:

/* 第一层:基础色板(设计师维护,改了全局跟着变,开发不直接引用) */
:root {
  --palette-blue-6: #1890ff;
  --palette-gray-9: #333333;
  --palette-gray-1: #ffffff;
}

/* 第二层:语义化 Token(开发日常用这层,名字即含义) */
:root {
  --color-primary: var(--palette-blue-6);
  --color-bg-base: var(--palette-gray-1);     /* 比 --palette-gray-1 好懂得多 */
  --color-text-base: var(--palette-gray-9);
  --spacing-m: 16px;
  --radius-s: 4px;
  --font-size-base: 14px;
}

/* 第三层:组件级 Token(只有高频复用组件才需要,避免过度抽象) */
:root {
  --card-bg: var(--color-bg-base);
  --card-padding: var(--spacing-m);
  --card-radius: var(--radius-s);
}

一开始试过只用两层,后来发现暗色模式下 primary 色需要调亮度,但色板值不能改(会影响其他引用),只能在语义层做映射。三层看似多余,实则是最小必要设计。

4.2 暗色主题的实现

/* light.css */
:root,
[data-theme="light"] {
  --color-bg-base: #ffffff;
  --color-bg-elevated: #fafafa;
  --color-text-base: #333333;
  --color-text-secondary: #666666;
  --color-border: #e8e8e8;
  --color-primary: #1890ff;
  --color-primary-hover: #40a9ff;
  --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.08);  /* 浅色背景用浅阴影 */
}

/* dark.css */
[data-theme="dark"] {
  --color-bg-base: #141414;
  --color-bg-elevated: #1f1f1f;
  --color-text-base: rgba(255, 255, 255, 0.85);
  --color-text-secondary: rgba(255, 255, 255, 0.45);
  --color-border: #434343;
  --color-primary: #177ddc;
  --color-primary-hover: #3c9ae8;
  --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.32);  /* 深色背景要加重阴影,否则约等于没有 */
}

很多人只换颜色忘了换阴影。深色背景上用浅色模式的阴影,肉眼几乎看不出来。这种细节不踩一脚记不住。

4.3 主题切换的 JS 层

type Theme = 'light' | 'dark' | 'system'

const STORAGE_KEY = 'app-theme'

function setTheme(theme: Theme) {
  const resolved = theme === 'system'
    ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
    : theme

  document.documentElement.setAttribute('data-theme', resolved)
  // 存用户意图('system'),不是解析结果('dark')
  // 否则选了跟随系统,换台电脑就不跟随了
  localStorage.setItem(STORAGE_KEY, theme)
}

// 监听系统主题变化(用户选了"跟随系统"时生效)
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (localStorage.getItem(STORAGE_KEY) === 'system') {
      document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light')
    }
  })

// 页面加载时立即执行,避免闪白屏
setTheme((localStorage.getItem(STORAGE_KEY) as Theme) || 'system')

4.4 防闪烁:最容易被忽略的体验问题

如果主题初始化代码放在 Vue/React 的生命周期里,页面会先闪一下白色(默认主题),再切到暗色。用户会以为出 bug 了。

解法很暴力也很有效——在 <head> 里内联一段阻塞脚本:

<head>
  <script>
    // 必须同步执行,在 CSS 解析之前完成,所以放 <head> 内联
    ;(function() {
      var theme = localStorage.getItem('app-theme') || 'system'
      var resolved = theme === 'system'
        ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
        : theme
      document.documentElement.setAttribute('data-theme', resolved)
    })()
  </script>
  <link rel="stylesheet" href="/styles/theme.css">
</head>

对,特意在 <head> 里放了内联 JS。这在"JS 和 CSS 分离"的教条面前有点叛逆,但用户体验不闪屏 > 代码洁癖。

4.5 在 Vue 中封装

<script setup lang="ts">
import { ref, watchEffect } from 'vue'

type Theme = 'light' | 'dark' | 'system'

const theme = ref<Theme>(
  (localStorage.getItem('app-theme') as Theme) || 'system'
)

watchEffect(() => {
  const resolved = theme.value === 'system'
    ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
    : theme.value

  document.documentElement.setAttribute('data-theme', resolved)
  localStorage.setItem('app-theme', theme.value)
})

// 三档循环切换:light → dark → system → light ...
const toggle = () => {
  const order: Theme[] = ['light', 'dark', 'system']
  const idx = order.indexOf(theme.value)
  theme.value = order[(idx + 1) % order.length]
}
</script>

<template>
  <button @click="toggle">
    当前: {{ theme }}
  </button>
</template>

整个主题系统的 JS 代码不超过 30 行。对比 CSS-in-JS 方案需要的 ThemeProvider、createGlobalStyle、useTheme hook……写到这里开始怀疑之前那些代码是不是都白写了。

五、设计权衡:CSS 变量不是银弹

公平地说,CSS-in-JS 不是毫无优势,否则它不会流行这么多年。

CSS 变量的短板

维度CSS 变量CSS-in-JS
类型安全纯字符串,写错了没提示TypeScript 全链路检查
动态计算有限(calc 能做一些)完全的 JS 能力
作用域隔离靠命名约定自动 hash
死代码消除手动管理构建工具可 tree-shake
调试体验DevTools 直接看到变量值需要找到生成的 class

什么时候该用 CSS-in-JS?

高度动态的样式逻辑——比如拖拽编辑器中元素的位置、大小、旋转角度,这些值每帧都在变,用 CSS 变量意味着每帧都要 setProperty,而 CSS-in-JS 可以和组件状态直接绑定。

跨项目分发的组件库——如果组件库需要被不同技术栈的项目引用,CSS-in-JS 的零配置主题能力确实方便。

但对于 90% 的业务项目,尤其是中后台系统,CSS 变量就是更好的选择。

补上类型安全

// theme-tokens.ts —— 单一事实源
export const tokens = {
  colorPrimary: '--color-primary',
  colorBgBase: '--color-bg-base',
  colorTextBase: '--color-text-base',
  spacingM: '--spacing-m',
  radiusS: '--radius-s',
} as const

type TokenKey = keyof typeof tokens

// 工具函数:拼写错了 TS 直接报错
export const cssVar = (key: TokenKey): string => {
  return `var(${tokens[key]})`
}

// ✅ cssVar('colorPrimary')  → "var(--color-primary)"
// ❌ cssVar('colorPrimay')   → TypeScript Error,手滑也能兜住

不如 CSS-in-JS 的类型安全那么"原生",但覆盖了最常见的拼写错误场景,投入产出比很高。

六、性能实测:数字说话

同一个项目上做了 A/B 对比(600+ 组件的中后台系统):

指标styled-componentsCSS 变量提升
主题切换耗时~210ms~6ms35x
首屏 CSS 体积180KB(运行时生成)12KB(变量文件)15x
Dev HMR 速度2.8s0.3s9x
运行时 JS 体积+45KB(styled 运行时)+0KB-
Lighthouse 性能评分7291+19

35 倍的切换速度差距不是优化出来的,是选型决定的。

小项目(< 50 组件)这个差距可以忽略不计,用啥都行。但项目会长大,技术选型要为未来的规模买单。

七、迁移策略:渐进式,不要一刀切

如果你已经在用 CSS-in-JS,不建议大爆炸式迁移:

Phase 1(1 周):定义 CSS 变量体系,新组件直接用变量
Phase 2(持续):改一个组件 → 删一个 styled 依赖,随业务迭代逐步替换
Phase 3(收尾):最后一个 styled 组件迁完,移除 styled-components 依赖

关键是 Phase 1 和 Phase 2 可以共存。CSS 变量和 CSS-in-JS 不冲突,styled-components 里照样能引用 CSS 变量:

// 过渡期写法:styled 组件内部用 CSS 变量替代 theme 引用
const Card = styled.div`
  background: var(--color-bg-base);
  color: var(--color-text-base);
  padding: var(--spacing-m);
`
// 这个组件不再依赖 ThemeProvider 了
// 等哪天有空,把 styled.div 换成普通 class 就行

这比一口气重写 600 个组件靠谱多了。

八、边界与踩坑

踩坑 1:CSS 变量不支持媒体查询条件

/* ❌ CSS 变量不能用在媒体查询的条件里 */
@media (max-width: var(--breakpoint-md)) {
  /* 无效,浏览器直接忽略 */
}

/* ✅ 断点值只能硬编码,但可以在媒体查询内部改变量值 */
@media (max-width: 768px) {
  :root {
    --spacing-m: 12px;
  }
}

踩坑 2:var() 的 fallback 陷阱

.text {
  /* fallback 只在变量完全未定义时生效 */
  color: var(--color-text, #333);

  /* ❌ 如果变量被定义为空字符串,fallback 不会触发 */
  /* --color-text: ;   → color 变成 invalid,但不会用 #333 */
}

踩坑 3:性能边界

CSS 变量的继承是有成本的。如果你在 :root 上定义了 200+ 个变量,每个 DOM 节点都会继承这些变量。在极端 DOM 节点数(10000+)的场景下,可能有几毫秒的额外布局计算。

实际项目中很少遇到这个问题。但如果你在做超大表格渲染,可以用 contain: style 限制变量继承范围。

九、这背后是什么思维?

CSS 变量 vs CSS-in-JS 的选择,归根到底是一个问题:配置数据应该放在它天然属于的层,还是拉到更高层统一管理?

颜色、间距、字号——这些是视觉层面的配置,天然属于 CSS。把它们拉到 JS 层管理,换来了类型安全和动态能力,但也付出了运行时成本和架构复杂度。

CSS 变量方案的成功印证了一条工程直觉:当原生能力足够好时,上层抽象的边际收益会快速递减。 类似的事情正在很多地方发生——fetch 替代 axios、<dialog> 替代 Modal 库、Container Queries 替代 JS resize observer。

不是说抽象不好,而是要问一句:这个抽象带来的好处,是否值得它引入的复杂度?

2020 年 CSS 变量浏览器支持还不够好,CSS-in-JS 是合理选择。到了 2026 年,CSS 变量已经是 baseline 能力,该让 CSS 做回 CSS 的事了。

如果你今天开始一个新的中后台项目——CSS 变量,闭眼选。如果你在纠结老项目要不要迁,回头看看第七节的渐进策略,先在新组件上用起来,成本几乎为零。