CSS 变量与主题切换实践

工具相关 ·

CSS 自定义属性(Custom Properties),也叫 CSS 变量,是 CSS3 引入的一项强大特性。与预处理器变量不同,CSS 变量是浏览器原生支持的、可以在运行时动态修改的值。这使得它们成为实现主题切换(如暗黑模式/明亮模式)的理想选择。

本文将深入讲解 CSS 变量的定义与使用、暗黑模式的实现方案、以及如何通过 JavaScript 实现动态主题切换。


一、CSS 变量基础

定义语法

CSS 变量使用 -- 前缀命名,通过 var() 函数引用:

:root {
  /* 定义变量 */
  --primary-color: #007bff;
  --secondary-color: #6c757d;
  --font-size-base: 16px;
  --spacing-unit: 8px;
  --border-radius: 4px;
  --transition-speed: 0.3s;
}

/* 使用变量 */
.button {
  background-color: var(--primary-color);
  font-size: var(--font-size-base);
  padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
  border-radius: var(--border-radius);
  transition: all var(--transition-speed) ease;
}

变量的作用域

CSS 变量遵循 CSS 的级联规则,子元素可以继承父元素的变量:

:root {
  --text-color: #333;
}

.dark-section {
  --text-color: #fff;
}

p {
  color: var(--text-color);
  /* 在 .dark-section 内的 p 元素颜色为 #fff */
  /* 其他 p 元素颜色为 #333 */
}

变量默认值

当变量未定义时,var() 可以提供回退值:

.element {
  /* 如果 --accent-color 未定义,使用 #ff6b35 */
  color: var(--accent-color, #ff6b35);

  /* 支持多层回退 */
  color: var(--accent-color, var(--primary-color, #007bff));
}

变量与预处理器的区别

特性CSS 变量SCSS/Less 变量
作用域遵循 CSS 级联和作用域编译时确定,无作用域
运行时修改可以通过 JS 动态修改不支持,编译后为固定值
继承可以继承不可以
浏览器支持现代浏览器均支持需要预处理器编译
动态响应可以响应媒体查询、JS 修改不支持
条件逻辑可以通过覆盖实现需要 @if/@else

二、使用 CSS 变量构建设计令牌(Design Tokens)

什么是设计令牌?

设计令牌是设计系统中最小的可复用单元,它们代表了设计决策的具体值,如颜色、字体、间距等。

设计令牌层级

:root {
  /* ===== 全局令牌(Global Tokens)===== */
  /* 原始值,不直接用于组件 */
  --color-blue-50: #eff6ff;
  --color-blue-100: #dbeafe;
  --color-blue-500: #3b82f6;
  --color-blue-600: #2563eb;
  --color-blue-700: #1d4ed8;
  --color-gray-50: #f9fafb;
  --color-gray-100: #f3f4f6;
  --color-gray-500: #6b7280;
  --color-gray-700: #374151;
  --color-gray-900: #111827;
  --color-white: #ffffff;
  --color-black: #000000;

  --font-family-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --font-family-mono: 'Fira Code', 'Consolas', monospace;

  --font-size-xs: 0.75rem;    /* 12px */
  --font-size-sm: 0.875rem;   /* 14px */
  --font-size-base: 1rem;     /* 16px */
  --font-size-lg: 1.125rem;   /* 18px */
  --font-size-xl: 1.25rem;    /* 20px */
  --font-size-2xl: 1.5rem;    /* 24px */
  --font-size-3xl: 1.875rem;  /* 30px */

  --spacing-1: 0.25rem;  /* 4px */
  --spacing-2: 0.5rem;   /* 8px */
  --spacing-3: 0.75rem;  /* 12px */
  --spacing-4: 1rem;     /* 16px */
  --spacing-5: 1.25rem;  /* 20px */
  --spacing-6: 1.5rem;   /* 24px */
  --spacing-8: 2rem;     /* 32px */

  /* ===== 语义令牌(Semantic Tokens)===== */
  /* 有明确含义,直接用于组件 */
  --color-primary: var(--color-blue-600);
  --color-primary-hover: var(--color-blue-700);
  --color-primary-light: var(--color-blue-50);

  --color-text: var(--color-gray-900);
  --color-text-secondary: var(--color-gray-500);
  --color-text-inverse: var(--color-white);

  --color-bg: var(--color-white);
  --color-bg-secondary: var(--color-gray-50);
  --color-bg-tertiary: var(--color-gray-100);

  --color-border: var(--color-gray-100);
  --color-border-strong: var(--color-gray-500);

  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
  --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
}

三、暗黑模式实现

方案一:使用 prefers-color-scheme 媒体查询

这是最简单的方式,跟随用户系统的主题偏好:

:root {
  --color-text: #111827;
  --color-bg: #ffffff;
  --color-bg-secondary: #f9fafb;
  --color-border: #e5e7eb;
  --color-primary: #2563eb;
}

/* 用户系统设置为暗黑模式时 */
@media (prefers-color-scheme: dark) {
  :root {
    --color-text: #e5e7eb;
    --color-bg: #111827;
    --color-bg-secondary: #1f2937;
    --color-border: #374151;
    --color-primary: #3b82f6;
  }
}

/* 使用变量 */
body {
  background-color: var(--color-bg);
  color: var(--color-text);
}

.card {
  background: var(--color-bg-secondary);
  border: 1px solid var(--color-border);
}

方案二:手动切换主题(类名方案)

通过给 <html> 或 <body> 添加类名来切换主题:

/* 默认(明亮主题) */
:root {
  --color-text: #111827;
  --color-text-secondary: #6b7280;
  --color-bg: #ffffff;
  --color-bg-secondary: #f9fafb;
  --color-bg-tertiary: #f3f4f6;
  --color-border: #e5e7eb;
  --color-primary: #2563eb;
  --color-primary-hover: #1d4ed8;
  --shadow-color: rgba(0, 0, 0, 0.1);
}

/* 暗黑主题 */
[data-theme="dark"] {
  --color-text: #e5e7eb;
  --color-text-secondary: #9ca3af;
  --color-bg: #111827;
  --color-bg-secondary: #1f2937;
  --color-bg-tertiary: #374151;
  --color-border: #4b5563;
  --color-primary: #3b82f6;
  --color-primary-hover: #60a5fa;
  --shadow-color: rgba(0, 0, 0, 0.3);
}

方案三:多主题支持

/* 明亮主题(默认) */
:root,
[data-theme="light"] {
  --color-text: #111827;
  --color-bg: #ffffff;
  --color-primary: #2563eb;
  --color-success: #16a34a;
  --color-warning: #d97706;
  --color-danger: #dc2626;
}

/* 暗黑主题 */
[data-theme="dark"] {
  --color-text: #e5e7eb;
  --color-bg: #111827;
  --color-primary: #3b82f6;
  --color-success: #22c55e;
  --color-warning: #f59e0b;
  --color-danger: #ef4444;
}

/* 蓝色主题(品牌主题) */
[data-theme="ocean"] {
  --color-text: #1e3a5f;
  --color-bg: #f0f7ff;
  --color-primary: #0369a1;
  --color-success: #0d9488;
  --color-warning: #ca8a04;
  --color-danger: #be123c;
}

/* 护眼主题 */
[data-theme="nature"] {
  --color-text: #2d3a2e;
  --color-bg: #f5f7f0;
  --color-primary: #4a7c59;
  --color-success: #2d6a4f;
  --color-warning: #bc6c25;
  --color-danger: #9b2226;
}

四、JavaScript 动态主题切换

基础主题切换器

// 主题切换逻辑
class ThemeSwitcher {
  constructor(options = {}) {
    this.storageKey = options.storageKey || 'theme-preference';
    this.defaultTheme = options.defaultTheme || 'light';
    this.themes = options.themes || ['light', 'dark'];
    this.root = document.documentElement;

    this.init();
  }

  init() {
    // 1. 获取存储的主题偏好
    const stored = localStorage.getItem(this.storageKey);

    // 2. 如果有存储值,使用存储值;否则使用系统偏好
    if (stored) {
      this.setTheme(stored, false);
    } else {
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      this.setTheme(prefersDark ? 'dark' : 'light', false);
    }

    // 3. 监听系统主题变化
    window.matchMedia('(prefers-color-scheme: dark)')
      .addEventListener('change', (e) => {
        if (!localStorage.getItem(this.storageKey)) {
          this.setTheme(e.matches ? 'dark' : 'light', false);
        }
      });
  }

  setTheme(theme, save = true) {
    if (!this.themes.includes(theme)) return;

    this.root.setAttribute('data-theme', theme);
    this.currentTheme = theme;

    if (save) {
      localStorage.setItem(this.storageKey, theme);
    }
  }

  toggle() {
    const nextTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
    this.setTheme(nextTheme);
  }

  getTheme() {
    return this.currentTheme;
  }
}

// 使用
const themeSwitcher = new ThemeSwitcher({
  defaultTheme: 'light',
  themes: ['light', 'dark', 'ocean', 'nature']
});

// 绑定按钮
document.getElementById('theme-toggle')
  .addEventListener('click', () => themeSwitcher.toggle());

避免闪烁(FOUC)

主题切换时常见的"闪烁"问题:页面加载时先显示默认主题,再切换到用户偏好主题。解决方案是在 <head> 中内联以下脚本:

<!DOCTYPE html>
<html>
<head>
  <script>
    // 尽早设置主题,避免闪烁
    (function() {
      const theme = localStorage.getItem('theme-preference')
        || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    })();
  </script>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <!-- 页面内容 -->
</body>
</html>

添加切换动画

/* 主题切换时的过渡效果 */
[data-theme] * {
  transition: background-color 0.3s ease,
              color 0.3s ease,
              border-color 0.3s ease,
              box-shadow 0.3s ease;
}

/* 排除不需要过渡的元素 */
[data-theme] script,
[data-theme] iframe {
  transition: none;
}

五、主题切换的 HTML 实现示例

<div class="theme-selector">
  <button
    class="theme-btn"
    data-theme="light"
    aria-label="切换到明亮模式"
    title="明亮模式"
  >
    ☀️ 明亮
  </button>
  <button
    class="theme-btn"
    data-theme="dark"
    aria-label="切换到暗黑模式"
    title="暗黑模式"
  >
    🌙 暗黑
  </button>
  <button
    class="theme-btn"
    data-theme="ocean"
    aria-label="切换到海洋主题"
    title="海洋主题"
  >
    🌊 海洋
  </button>
</div>

<script>
  document.querySelectorAll('.theme-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      const theme = btn.getAttribute('data-theme');
      document.documentElement.setAttribute('data-theme', theme);
      localStorage.setItem('theme-preference', theme);
    });
  });
</script>

六、CSS 变量使用注意事项

1. 变量命名规范

/* 不好的命名 */
:root {
  --c1: #333;       /* 含义不明 */
  --blue: blue;     /* 过于笼统 */
  --fs: 16px;       /* 缩写不清晰 */
}

/* 好的命名 */
:root {
  --color-text-primary: #333;
  --color-brand-blue: #2563eb;
  --font-size-base: 16px;
}

2. 常见陷阱

/* 陷阱 1:变量在 calc() 中的使用需要空格 */
.element {
  /* 错误 */
  width: calc(var(--base-width) * 2);
  /* 正确 */
  width: calc(var(--base-width) * 2);

  /* 错误:缺少单位 */
  --gap: 16;
  margin: var(--gap)px; /* 无效 */

  /* 正确 */
  --gap: 16px;
  margin: var(--gap);
}

/* 陷阱 2:变量值不能包含 !important */
:root {
  /* 错误 */
  --color: red !important;

  /* 正确 */
  --color: red;
}

/* 陷阱 3:变量回退值中使用逗号 */
.element {
  /* 如果变量值中包含逗号,需要特殊处理 */
  font-family: var(--font-family, 'Helvetica Neue', Arial, sans-serif);
  /* 这里回退值实际上会被解析为多个值,需要加引号 */
}

3. 浏览器兼容性

特性ChromeFirefoxSafariEdgeIE
CSS 变量49+31+9.1+15+不支持
@property85+128+15+85+不支持

对于不支持 CSS 变量的浏览器,需要提供回退值:

.element {
  /* 回退值写在 var() 前面 */
  color: #333;
  color: var(--color-text, #333);
}

七、完整的主题系统示例

/* ===== 完整主题系统 ===== */

/* 设计令牌 - 全局 */
:root {
  /* 间距 */
  --space-xs: 4px;
  --space-sm: 8px;
  --space-md: 16px;
  --space-lg: 24px;
  --space-xl: 32px;

  /* 圆角 */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 16px;
  --radius-full: 9999px;

  /* 字体 */
  --font-sans: system-ui, -apple-system, sans-serif;
  --font-mono: ui-monospace, monospace;

  /* 过渡 */
  --duration-fast: 150ms;
  --duration-normal: 300ms;
  --duration-slow: 500ms;
}

/* 语义令牌 - 明亮主题 */
:root,
[data-theme="light"] {
  --color-bg: #ffffff;
  --color-bg-soft: #f9fafb;
  --color-bg-muted: #f3f4f6;
  --color-text: #111827;
  --color-text-subtle: #6b7280;
  --color-border: #e5e7eb;
  --color-primary: #2563eb;
  --color-primary-hover: #1d4ed8;
  --color-shadow: rgba(0, 0, 0, 0.1);
}

/* 语义令牌 - 暗黑主题 */
[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-bg-soft: #1e293b;
  --color-bg-muted: #334155;
  --color-text: #f1f5f9;
  --color-text-subtle: #94a3b8;
  --color-border: #475569;
  --color-primary: #3b82f6;
  --color-primary-hover: #60a5fa;
  --color-shadow: rgba(0, 0, 0, 0.4);
}

/* 组件样式 - 完全使用变量 */
.card {
  background: var(--color-bg-soft);
  color: var(--color-text);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-lg);
  box-shadow: 0 2px 4px var(--color-shadow);
  transition: background-color var(--duration-normal),
              color var(--duration-normal),
              border-color var(--duration-normal);
}

.btn-primary {
  background: var(--color-primary);
  color: white;
  border: none;
  border-radius: var(--radius-sm);
  padding: var(--space-sm) var(--space-md);
  font-size: var(--font-size-base, 16px);
  cursor: pointer;
}

.btn-primary:hover {
  background: var(--color-primary-hover);
}

总结

CSS 变量为前端主题系统带来了革命性的变化:

  • 定义变量:使用 :root 定义全局变量,使用作用域覆盖实现局部变量
  • 设计令牌:分层管理全局令牌和语义令牌
  • 暗黑模式:通过 prefers-color-scheme、data-theme 属性、或类名方案实现
  • 动态切换:结合 JavaScript 实现用户可控的主题切换
  • 避免闪烁:在 <head> 中尽早设置主题属性

通过合理运用 CSS 变量,你可以构建一个灵活、可维护、用户友好的主题系统。

阅读 14