前端应用中的主题切换看起来像是在“换颜色”,但一个可维护的实现不应该在 JavaScript 中逐个修改元素样式。更合适的方案是把主题值交给 CSS 管理:组件只使用语义化变量,运行时只负责切换主题标识。
本文以字体颜色为例,从零实现一套浅色、深色主题切换能力,并说明它与 Less 变量的区别以及在 React 项目中的实践方式。
一、核心思路
主题切换可以拆成三层:
- 使用 CSS 自定义变量定义颜色 Token。
- 使用属性选择器为不同主题提供不同的变量值。
- 使用 JavaScript 修改根节点的主题属性。
页面中的组件不关心当前是浅色还是深色,只消费 --text-color 这样的语义化变量。当主题属性改变时,浏览器会重新计算所有引用该变量的样式。
二、CSS 自定义变量
CSS 自定义变量也叫 CSS Variables,是浏览器原生支持的运行时能力。变量名以 -- 开头,通过 var() 使用:
:root {
--text-color: #1f2329;
}
.article-title {
color: var(--text-color);
}
:root 在 HTML 页面中表示 <html> 元素。定义在这里的变量可以通过 CSS 继承机制被整个页面使用。
var() 还支持回退值:
.article-title {
color: var(--text-color, #1f2329);
}
当 --text-color 没有定义时,浏览器会使用后面的 #1f2329。
三、为不同主题定义变量
首先定义浅色主题的默认值:
:root {
--text-color: #1f2329;
--secondary-text-color: #646a73;
--background-color: #ffffff;
}
然后通过属性选择器覆盖深色主题中的变量:
:root[data-theme='dark'] {
--text-color: #f5f6f7;
--secondary-text-color: #bbbfc4;
--background-color: #1f2329;
}
组件样式始终只引用变量:
body {
color: var(--text-color);
background: var(--background-color);
}
.article-description {
color: var(--secondary-text-color);
}
这样,主题规则集中在变量定义处。新增组件时不需要再写一套 .dark .component 样式,只需要选择正确的语义变量。
四、使用 JavaScript 切换主题
JavaScript 不需要操作具体元素,只需修改 <html> 的 data-theme 属性:
const root = document.documentElement;
function setTheme(theme: 'light' | 'dark') {
if (theme === 'dark') {
root.setAttribute('data-theme', 'dark');
} else {
root.removeAttribute('data-theme');
}
}
完整的按钮切换逻辑如下:
const button = document.querySelector('#theme-button');
button?.addEventListener('click', () => {
const isDark = document.documentElement.dataset.theme === 'dark';
setTheme(isDark ? 'light' : 'dark');
});
属性改变后,:root[data-theme='dark'] 是否匹配也会随之改变。浏览器会重新计算 CSS 变量及所有引用它们的属性,字体颜色和背景色便会自动更新。
五、一个可直接运行的 Demo
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>主题切换 Demo</title>
<style>
:root {
--text-color: #1f2329;
--background-color: #ffffff;
}
:root[data-theme='dark'] {
--text-color: #f5f6f7;
--background-color: #1f2329;
}
body {
color: var(--text-color);
background: var(--background-color);
transition: color 0.2s, background-color 0.2s;
}
</style>
</head>
<body>
<h1>主题切换 Demo</h1>
<p>这段文字的颜色由 CSS 自定义变量控制。</p>
<button id="theme-button">切换主题</button>
<script>
const button = document.querySelector('#theme-button');
button.addEventListener('click', () => {
const root = document.documentElement;
const isDark = root.dataset.theme === 'dark';
root.dataset.theme = isDark ? 'light' : 'dark';
});
</script>
</body>
</html>
六、在 React 中实现
React 中可以由组件状态驱动根节点属性:
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
export function ThemeDemo() {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.setAttribute('data-theme', 'dark');
} else {
root.removeAttribute('data-theme');
}
}, [theme]);
return (
<main>
<h1>当前主题:{theme}</h1>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
切换主题
</button>
</main>
);
}
如果组件不是页面唯一的主题管理者,应在 Effect 清理函数中恢复原来的属性,避免组件卸载后污染全局状态:
useEffect(() => {
const root = document.documentElement;
const previousTheme = root.getAttribute('data-theme');
if (theme === 'dark') {
root.setAttribute('data-theme', 'dark');
} else {
root.removeAttribute('data-theme');
}
return () => {
if (previousTheme) {
root.setAttribute('data-theme', previousTheme);
} else {
root.removeAttribute('data-theme');
}
};
}, [theme]);
七、记住用户选择
可以通过 localStorage 保存用户选择,下次访问时直接恢复:
type Theme = 'light' | 'dark';
function applyTheme(theme: Theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem('theme', theme);
}
const savedTheme = localStorage.getItem('theme') as Theme | null;
applyTheme(savedTheme ?? 'light');
实际项目中还可以结合 prefers-color-scheme,在用户没有主动选择时跟随系统主题:
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = savedTheme ?? (prefersDark ? 'dark' : 'light');
为了避免页面加载后先显示浅色再跳到深色,可以在 React 挂载前尽早执行初始化逻辑。
八、CSS Variables 与 Less 变量的区别
两者名字相似,但工作阶段不同。
Less 变量是构建时能力:
@text-color: #1f2329;
.title {
color: @text-color;
}
构建完成后,浏览器得到的是固定值:
.title {
color: #1f2329;
}
CSS 自定义变量则会保留在最终 CSS 中:
.title {
color: var(--text-color);
}
因此 Less 变量不能单独完成运行时主题切换。Less 可以帮助组织主题文件、生成选择器或复用 mixin,但动态切换真正依赖的是 CSS 自定义变量和 DOM 属性。
九、工程实践建议
使用语义化 Token
推荐使用 --text-primary、--bg-body、--border-default,而不是 --gray-900。组件关心的是颜色用途,而不是某个主题下的具体色值。
避免在组件中写死颜色
下面的样式无法自动响应主题:
.title {
color: #1f2329;
}
应改成:
.title {
color: var(--text-primary);
}
明确主题作用域
把主题属性设置在 <html> 上适合全站主题,但它是全局副作用。如果一个页面需要同时展示多个不同主题区域,可以把属性放到局部容器:
<section data-theme="dark">深色区域</section>
[data-theme='dark'] {
--text-color: #f5f6f7;
}
这样变量只会通过继承影响该容器的后代元素。
总结
一套简单、可维护的主题切换能力,本质上只需要完成三件事:
- 用 CSS 自定义变量表达颜色等设计 Token。
- 用主题属性选择器覆盖变量值。
- 用 JavaScript 或 React 切换主题属性。
Less 可以参与样式组织,但不是运行时切换的核心。真正让页面在不逐个修改组件样式的情况下完成主题更新的,是浏览器原生的 CSS Variables、继承机制和样式重新计算能力。