一、故事的开头:一次构建耗时让我开始反思
事情是这样的。项目用了 styled-components 做主题系统,功能没问题,暗色模式切换丝滑得很。直到有一天,项目膨胀到 600+ 组件,dev server 启动要 40 秒,HMR 改个颜色值要等 3 秒。
打开 Chrome DevTools 的 Performance 面板一看------主题切换时,JS 执行时间 200ms+,整棵组件树在重新渲染。
就为了换个颜色?
颜色本来就是 CSS 的事,为什么要绕一圈让 JS 来管?
二、CSS-in-JS 做主题:到底贵在哪?
先搞清楚运行时成本。以 styled-components 为例:
tsx
// ❌ 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 自定义属性的核心能力:声明一次,到处引用,改一处全局生效。
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);
}
切换主题只要一行:
ts
// 翻个开关,整个页面的颜色全换了
document.documentElement.setAttribute('data-theme', 'dark')
// 没有 re-render,没有 JS 重新计算
// 浏览器 CSS 引擎原生处理变量继承,比 JS 快一个数量级
主题切换是纯 CSS 行为,JS 只负责翻开关。这不是优化技巧,是选对了赛道。
四、工程化落地:不是改几个颜色那么简单
知道原理是一回事,在真实项目里落地是另一回事。以下是实际迁移过程中踩出来的路。
4.1 变量体系设计:三层架构
变量不能随便命名,否则维护成本比 CSS-in-JS 还高:
css
/* 第一层:基础色板(设计师维护,改了全局跟着变,开发不直接引用) */
: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 暗色主题的实现
css
/* 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 层
ts
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> 里内联一段阻塞脚本:
html
<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 中封装
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 变量就是更好的选择。
补上类型安全
ts
// 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-components | CSS 变量 | 提升 |
|---|---|---|---|
| 主题切换耗时 | ~210ms | ~6ms | 35x |
| 首屏 CSS 体积 | 180KB(运行时生成) | 12KB(变量文件) | 15x |
| Dev HMR 速度 | 2.8s | 0.3s | 9x |
| 运行时 JS 体积 | +45KB(styled 运行时) | +0KB | - |
| Lighthouse 性能评分 | 72 | 91 | +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 变量:
ts
// 过渡期写法: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
/* ❌ CSS 变量不能用在媒体查询的条件里 */
@media (max-width: var(--breakpoint-md)) {
/* 无效,浏览器直接忽略 */
}
/* ✅ 断点值只能硬编码,但可以在媒体查询内部改变量值 */
@media (max-width: 768px) {
:root {
--spacing-m: 12px;
}
}
踩坑 2:var() 的 fallback 陷阱
css
.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 变量,闭眼选。如果你在纠结老项目要不要迁,回头看看第七节的渐进策略,先在新组件上用起来,成本几乎为零。