换肤功能听起来简单,做起来却容易踩坑。最直觉的做法是写两套CSS文件然后动态切换<link>标签,或者用JS遍历元素逐个修改样式,前者会导致页面闪烁,后者性能差且难以维护。CSS变量(Custom Properties)的出现让这个问题有了标准答案:把主题相关的颜色值抽象成变量,切换主题时只改变量的值,浏览器会自动完成所有依赖这些变量的元素重绘。本文详细介绍这套方案在React项目中的完整落地过程。

一、CSS变量的基本原理与作用域规则
CSS变量的正式名称是自定义属性(Custom Properties),以两个短横线开头声明,通过var()函数引用。它和Sass、Less中的变量有本质区别:Sass变量在编译期就被替换成固定值,而CSS变量是运行时的,可以在JavaScript中随时读写,这正是动态换肤的基础。
作用域是理解CSS变量的关键。变量声明在哪个元素上,就只能在该元素及其后代中生效。通常我们会把主题变量声明在:root伪类上,这样全文档都能访问到:
:root {
--color-primary: #1677ff;
--color-bg: #ffffff;
--color-text: #333333;
}
.dark {
--color-primary: #4096ff;
--color-bg: #141414;
--color-text: #e8e8e8;
}
.button {
background: var(--color-primary);
color: var(--color-text);
}上面的代码里,当html元素被加上dark这个class时,三套变量会整体被覆盖值,而.button的规则一行都不用改,浏览器会自动用新值渲染。这种「样式规则不变、只改变量值」的思路,让主题切换的成本降到最低。var()还支持第二个参数作为兜底值,例如var(--color-primary, #1677ff),变量未定义时使用默认值,这在渐进迁移老项目时很有用。
还有一个细节需要注意:CSS变量是继承的。子元素会继承祖先元素上定义的变量,所以如果想做局部换肤(比如只让某个卡片区域变暗),把变量声明在那个卡片容器上即可,不会污染全局。这个特性在「局部强调色」「白标定制」等场景下非常实用。
二、在React中组织主题与切换逻辑
有了CSS层面的基础,接下来考虑React侧的架构。推荐的分层方式是:主题令牌定义在独立的TS文件中,样式变量写在全局CSS里,React只负责「切换根元素上的标识」这一件事,不直接操作具体颜色值。
先定义主题令牌。把所有会随主题变化的值集中管理,形成单一数据源:
// theme/tokens.ts
export const themes = {
light: {
name: 'light',
label: '浅色',
},
dark: {
name: 'dark',
label: '深色',
},
} as const;
export type ThemeName = keyof typeof themes;变量与主题的映射关系放在全局样式中,用data-theme属性做选择器比class更语义化,而且天然支持更多主题扩展:
/* global.css */
:root,
[data-theme='light'] {
--color-primary: #1677ff;
--color-bg: #ffffff;
--border-radius-base: 6px;
}
[data-theme='dark'] {
--color-primary: #4096ff;
--color-bg: #141414;
--border-radius-base: 6px;
}然后是React侧的切换逻辑。核心是通过Context让任意层级的组件都能读取和修改主题,再在Provider挂载时把主题名同步到document.documentElement上:
// theme/ThemeContext.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { themes, ThemeName } from './tokens';
interface ThemeCtx {
theme: ThemeName;
setTheme: (t: ThemeName) => void;
}
const Ctx = createContext<ThemeCtx>({ theme: 'light', setTheme: () => {} });
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemeName>(() => {
const saved = localStorage.getItem('app-theme');
return saved === 'dark' || saved === 'light' ? saved : 'light';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('app-theme', theme);
}, [theme]);
return (
<Ctx.Provider value={{ theme, setTheme: setThemeState }}>
{children}
</Ctx.Provider>
);
}
export const useTheme = () => useContext(Ctx);这套实现有几个值得推敲的点。第一,useState的初始化函数里读取localStorage,保证刷新页面后主题不丢。第二,副作用放在useEffect里同步DOM属性,避免在渲染过程中直接操作DOM。第三,任何组件调用useTheme()就能拿到当前主题和切换函数,组件树深处放一个切换按钮非常方便。
切换按钮本身可以做成很简单的受控组件,甚至根据当前主题显示不同的图标。这里不需要任何路由级别的改动,因为变量切换是全局即时生效的:
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? '切换到深色' : '切换到浅色'}
</button>
);
}三、避免切换闪烁与跟随系统主题
切换闪烁(FOUC,Flash of Unstyled Content)是换肤功能最常见的体验问题。原因在于:React的JS包需要下载和执行完成后才会设置data-theme,这之前页面已经用默认浅色渲染了一帧,深色用户就会先看到一瞬间的白屏闪瞎眼。解决办法是在HTML入口处内联一小段阻塞脚本,在React挂载之前就把主题属性设置好:
<!-- public/index.html 的 head 内 -->
<script>
(function () {
var saved = localStorage.getItem('app-theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>这段脚本只有几行,对首屏性能的影响可以忽略,但能彻底消除闪烁。注意它和React里的初始化逻辑要保持一致的优先级规则:本地存储的偏好优先,其次是系统偏好,最后是默认值。把这条规则抽成公共函数,内联脚本里直接展开写一遍,避免两处逻辑不一致。
跟随系统主题则需要监听prefers-color-scheme媒体查询的变化。如果用户没有手动选择过主题,系统切换深浅色时应用应该自动跟随:
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
// 用户手动设置过主题则不再跟随
if (!localStorage.getItem('app-theme')) {
setThemeState(e.matches ? 'dark' : 'light');
}
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);另外提醒一个容易忽略的坑:一些第三方组件库(如Ant Design 5.x)自带了一套CSS-in-JS的主题机制,如果你在项目中同时使用,需要把组件库的算法参数与你的CSS变量打通,例如在ConfigProvider中用theme.darkAlgorithm,并让组件库的primary色读取你定义的var(--color-primary),否则会出现页面背景变了、组件还是旧主题色的割裂感。
四、进阶技巧与工程化建议
当主题数量增多时,建议把每个主题的变量值也放进TS的tokens文件中,切换时用JS批量写入document.documentElement.style。这种方式适合「用户自定义主题色」的需求——后端下发一组颜色配置,前端不需要预先写死CSS就能动态生成主题:
interface ThemeTokens {
'color-primary': string;
'color-bg': string;
'color-text': string;
}
function applyTokens(tokens: Record<string, string>) {
const root = document.documentElement;
Object.entries(tokens).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value);
});
}
// 服务端下发的自定义主题
applyTokens({
'color-primary': '#722ed1',
'color-bg': '#faf5ff',
'color-text': '#262626',
});工程化方面有三条经验。第一,变量命名要有层级结构,推荐「类别-角色-状态」的格式,比如--color-primary-hover、--color-bg-container,避免团队各自发明命名导致后期混乱。第二,在TypeScript中为令牌建立类型定义,甚至写一个脚本从tokens文件自动生成CSS变量的.d.ts声明,让var()的引用也有类型提示。第三,组件内部的样式尽量只引用变量而不写死色值,可以用stylelint加一条自定义规则做约束,从机制上杜绝硬编码颜色混入。
最后聊聊性能。CSS变量切换时,浏览器只需对依赖这些变量的元素做重绘,不会触发布局重算,因此即使页面有上千个元素,切换也在一帧内完成,比JS遍历DOM改style快了几个量级。这也是各大组件库和浏览器自身的深色模式都采用变量方案的根本原因。把这套模式搭好之后,未来增加新主题不过是新增一组变量值的工作量,架构层面完全不用动。