Skip to content

CSS 多主题方案 (Theming)

现代前端应用的主题切换方案,从 CSS 变量到 CSS-in-JS 的完整实践指南

目录


核心概念

什么是主题系统?

主题系统允许用户在不同的视觉风格(如亮色/暗色模式)之间切换,涉及:

维度说明
颜色背景、文字、边框、阴影
间距内外边距、组件尺寸
字体字号、字重、行高
圆角边框弧度

CSS 变量方案

基础用法

css
/* 定义主题变量 */
:root {
  /* 颜色 */
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
  --color-primary: #3b82f6;
  --color-secondary: #64748b;
  
  /* 间距 */
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
  
  /* 圆角 */
  --radius-sm: 4px;
  --radius-md: 8px;
}

/* 暗色主题 */
[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-text: #f1f5f9;
  --color-primary: #60a5fa;
  --color-secondary: #94a3b8;
}

/* 使用变量 */
.card {
  background: var(--color-bg);
  color: var(--color-text);
  padding: var(--spacing-md);
  border-radius: var(--radius-md);
}

系统主题检测

css
/* 跟随系统主题 */
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0f172a;
    --color-text: #f1f5f9;
  }
}

@media (prefers-color-scheme: light) {
  :root {
    --color-bg: #ffffff;
    --color-text: #1a1a1a;
  }
}

主题切换实现

方案一:data-theme 属性切换

javascript
// 主题管理器
class ThemeManager {
  constructor() {
    this.STORAGE_KEY = 'theme-preference';
    this.init();
  }

  init() {
    // 优先级:用户设置 > 系统偏好 > 默认值
    const stored = localStorage.getItem(this.STORAGE_KEY);
    const systemPrefers = window.matchMedia('(prefers-color-scheme: dark)').matches 
      ? 'dark' 
      : 'light';
    
    this.setTheme(stored || systemPrefers);
    
    // 监听系统主题变化
    window.matchMedia('(prefers-color-scheme: dark)')
      .addEventListener('change', (e) => {
        if (!localStorage.getItem(this.STORAGE_KEY)) {
          this.setTheme(e.matches ? 'dark' : 'light');
        }
      });
  }

  setTheme(theme) {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem(this.STORAGE_KEY, theme);
  }

  toggle() {
    const current = document.documentElement.getAttribute('data-theme');
    this.setTheme(current === 'dark' ? 'light' : 'dark');
  }
}

const themeManager = new ThemeManager();

方案二:CSS 类切换

css
/* 默认亮色 */
.theme-light {
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
}

/* 暗色 */
.theme-dark {
  --color-bg: #0f172a;
  --color-text: #f1f5f9;
}

React Hook 封装

tsx
function useTheme() {
  const [theme, setTheme] = useState<'light' | 'dark'>(() => {
    if (typeof window === 'undefined') return 'light';
    return localStorage.getItem('theme') as 'light' | 'dark' || 'light';
  });

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('theme', theme);
  }, [theme]);

  const toggle = useCallback(() => {
    setTheme(prev => prev === 'dark' ? 'light' : 'dark');
  }, []);

  return { theme, setTheme, toggle };
}

防止闪烁 (FOUC)

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>

方案对比

方案优点缺点适用场景
CSS 变量原生支持、性能好、运行时切换IE 不支持现代项目首选
CSS-in-JS类型安全、动态计算、与组件耦合运行时开销、SSR 复杂React 生态
预处理器兼容性好编译时确定、切换需多套 CSS传统项目
Tailwind类名驱动、与 dark: 前缀配合学习成本Tailwind 项目

CSS-in-JS 示例 (styled-components)

tsx
import { ThemeProvider, createGlobalStyle } from 'styled-components';

const lightTheme = {
  colors: { bg: '#ffffff', text: '#1a1a1a', primary: '#3b82f6' },
  spacing: { sm: '8px', md: '16px' }
};

const darkTheme = {
  colors: { bg: '#0f172a', text: '#f1f5f9', primary: '#60a5fa' },
  spacing: { sm: '8px', md: '16px' }
};

const GlobalStyle = createGlobalStyle`
  body {
    background: ${props => props.theme.colors.bg};
    color: ${props => props.theme.colors.text};
  }
`;

function App() {
  const [isDark, setIsDark] = useState(false);
  return (
    <ThemeProvider theme={isDark ? darkTheme : lightTheme}>
      <GlobalStyle />
      <button onClick={() => setIsDark(!isDark)}>Toggle</button>
    </ThemeProvider>
  );
}

Tailwind 暗色模式

js
// tailwind.config.js
module.exports = {
  darkMode: 'class', // 或 'media' 跟随系统
  // ...
}
html
<div class="bg-white dark:bg-slate-900 text-slate-900 dark:text-white">
  自动切换
</div>

设计 Token 规范

语义化命名

css
:root {
  /* 不推荐:具体颜色命名 */
  --blue-500: #3b82f6;
  
  /* 推荐:语义化命名 */
  --color-primary: #3b82f6;
  --color-primary-hover: #2563eb;
  --color-success: #22c55e;
  --color-error: #ef4444;
  
  /* 层级化 */
  --color-bg-base: #ffffff;
  --color-bg-elevated: #f8fafc;
  --color-bg-overlay: rgba(0, 0, 0, 0.5);
}

多主题扩展

css
/* 品牌主题 */
[data-theme="brand-a"] {
  --color-primary: #8b5cf6; /* 紫色系 */
}

[data-theme="brand-b"] {
  --color-primary: #f97316; /* 橙色系 */
}

高频面试题

Q1: CSS 变量和预处理器变量有什么区别?

特性CSS 变量预处理器变量
解析时机运行时编译时
动态修改✅ 支持 JS 修改❌ 编译后固定
继承性✅ 遵循 CSS 继承❌ 无继承
作用域选择器作用域文件/块作用域
回退值var(--x, fallback)需手动处理

Q2: 如何避免主题切换时的闪烁?

  1. 阻塞脚本:在 <head> 中同步读取并设置 data-theme
  2. CSS 匹配:使用 prefers-color-scheme 作为默认值
  3. SSR:服务端根据 Cookie 注入正确的主题类名

Q3: 多主题系统的设计要点?

  1. 语义化 Token:用途命名而非颜色命名
  2. 层级结构:基础变量 → 语义变量 → 组件变量
  3. 对比度保证:确保文字与背景的对比度符合 WCAG 标准
  4. 过渡动画:添加 transition 让切换更平滑
css
:root {
  transition: background-color 0.3s, color 0.3s;
}

Q4: CSS-in-JS 主题方案的运行时开销?

  • 样式计算:每次渲染都需要计算样式对象
  • CSS 注入:动态生成并插入 <style> 标签
  • 优化方向:静态提取(如 vanilla-extract)、原子化 CSS

最佳实践清单

  • [ ] 使用 CSS 变量作为主题基础
  • [ ] 支持跟随系统主题 (prefers-color-scheme)
  • [ ] 持久化用户选择 (localStorage)
  • [ ] 防止首屏闪烁 (同步脚本)
  • [ ] 语义化 Token 命名
  • [ ] 保证色彩对比度 (WCAG AA)
  • [ ] 添加切换过渡动画

前端面试知识库