创见博客
主题能力的实现原理:使用 CSS Variables 实现主题切换
七崽爱吃小饼干2026/08/04阅读 0

前端应用中的主题切换看起来像是在“换颜色”,但一个可维护的实现不应该在 JavaScript 中逐个修改元素样式。更合适的方案是把主题值交给 CSS 管理:组件只使用语义化变量,运行时只负责切换主题标识。

本文以字体颜色为例,从零实现一套浅色、深色主题切换能力,并说明它与 Less 变量的区别以及在 React 项目中的实践方式。

一、核心思路

主题切换可以拆成三层:

  1. 使用 CSS 自定义变量定义颜色 Token。
  2. 使用属性选择器为不同主题提供不同的变量值。
  3. 使用 JavaScript 修改根节点的主题属性。

页面中的组件不关心当前是浅色还是深色,只消费 --text-color 这样的语义化变量。当主题属性改变时,浏览器会重新计算所有引用该变量的样式。

二、CSS 自定义变量

CSS 自定义变量也叫 CSS Variables,是浏览器原生支持的运行时能力。变量名以 -- 开头,通过 var() 使用:

css
:root {
  --text-color: #1f2329;
}

.article-title {
  color: var(--text-color);
}

:root 在 HTML 页面中表示 <html> 元素。定义在这里的变量可以通过 CSS 继承机制被整个页面使用。

var() 还支持回退值:

css
.article-title {
  color: var(--text-color, #1f2329);
}

当 --text-color 没有定义时,浏览器会使用后面的 #1f2329。

三、为不同主题定义变量

首先定义浅色主题的默认值:

css
:root {
  --text-color: #1f2329;
  --secondary-text-color: #646a73;
  --background-color: #ffffff;
}

然后通过属性选择器覆盖深色主题中的变量:

css
:root[data-theme='dark'] {
  --text-color: #f5f6f7;
  --secondary-text-color: #bbbfc4;
  --background-color: #1f2329;
}

组件样式始终只引用变量:

css
body {
  color: var(--text-color);
  background: var(--background-color);
}

.article-description {
  color: var(--secondary-text-color);
}

这样,主题规则集中在变量定义处。新增组件时不需要再写一套 .dark .component 样式,只需要选择正确的语义变量。

四、使用 JavaScript 切换主题

JavaScript 不需要操作具体元素,只需修改 <html> 的 data-theme 属性:

ts
const root = document.documentElement;

function setTheme(theme: 'light' | 'dark') {
  if (theme === 'dark') {
    root.setAttribute('data-theme', 'dark');
  } else {
    root.removeAttribute('data-theme');
  }
}

完整的按钮切换逻辑如下:

ts
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

html
<!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 中可以由组件状态驱动根节点属性:

tsx
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 清理函数中恢复原来的属性,避免组件卸载后污染全局状态:

tsx
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 保存用户选择,下次访问时直接恢复:

ts
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,在用户没有主动选择时跟随系统主题:

ts
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = savedTheme ?? (prefersDark ? 'dark' : 'light');

为了避免页面加载后先显示浅色再跳到深色,可以在 React 挂载前尽早执行初始化逻辑。

八、CSS Variables 与 Less 变量的区别

两者名字相似,但工作阶段不同。

Less 变量是构建时能力:

less
@text-color: #1f2329;

.title {
  color: @text-color;
}

构建完成后,浏览器得到的是固定值:

css
.title {
  color: #1f2329;
}

CSS 自定义变量则会保留在最终 CSS 中:

css
.title {
  color: var(--text-color);
}

因此 Less 变量不能单独完成运行时主题切换。Less 可以帮助组织主题文件、生成选择器或复用 mixin,但动态切换真正依赖的是 CSS 自定义变量和 DOM 属性。

九、工程实践建议

使用语义化 Token

推荐使用 --text-primary、--bg-body、--border-default,而不是 --gray-900。组件关心的是颜色用途,而不是某个主题下的具体色值。

避免在组件中写死颜色

下面的样式无法自动响应主题:

css
.title {
  color: #1f2329;
}

应改成:

css
.title {
  color: var(--text-primary);
}

明确主题作用域

把主题属性设置在 <html> 上适合全站主题,但它是全局副作用。如果一个页面需要同时展示多个不同主题区域,可以把属性放到局部容器:

html
<section data-theme="dark">深色区域</section>
css
[data-theme='dark'] {
  --text-color: #f5f6f7;
}

这样变量只会通过继承影响该容器的后代元素。

总结

一套简单、可维护的主题切换能力,本质上只需要完成三件事:

  1. 用 CSS 自定义变量表达颜色等设计 Token。
  2. 用主题属性选择器覆盖变量值。
  3. 用 JavaScript 或 React 切换主题属性。

Less 可以参与样式组织,但不是运行时切换的核心。真正让页面在不逐个修改组件样式的情况下完成主题更新的,是浏览器原生的 CSS Variables、继承机制和样式重新计算能力。

评论
0/100