Skip to content
📖预计阅读时长:0 分钟字数:0

话题 4:多主题 / 暗黑模式最佳实践

本文是「设计系统与 Tailwind CSS 深度实践」系列的第四篇,深入探讨多主题系统的架构设计与实现方案。


一、多主题的本质:理解问题域

1.1 主题切换的几种场景

场景描述复杂度
暗黑模式同一品牌的 Light / Dark 切换⭐⭐
多品牌品牌 A 和品牌 B 使用同一套组件,但不同视觉⭐⭐⭐
暗黑 + 多品牌品牌 A (Light/Dark) + 品牌 B (Light/Dark)⭐⭐⭐⭐
用户自定义用户可调整主色、强调色等⭐⭐⭐⭐⭐

1.2 主题切换的技术本质

主题切换 = 在不改变组件代码的前提下,切换一组设计变量的值

┌─────────────────────────────────────────────────────────┐
│                    组件代码                              │
│        <Button className="bg-primary" />                │
│                       │                                  │
│                       ▼                                  │
│              primary 指向什么?                           │
│                       │                                  │
│         ┌─────────────┼─────────────┐                   │
│         ▼             ▼             ▼                   │
│   Light Theme    Dark Theme    Brand B Theme            │
│   #3B82F6        #60A5FA       #10B981                  │
└─────────────────────────────────────────────────────────┘

关键洞察:组件只关心"语义"(primary),不关心"值"(#3B82F6)。主题切换就是切换语义到值的映射。


二、主题切换的四种实现方式

2.1 方式对比

方式原理运行时切换性能推荐场景
CSS 变量切换修改 CSS 变量值⭐⭐⭐⭐⭐大多数场景
Class 切换切换根元素 class⭐⭐⭐⭐⭐与 Tailwind 配合
多套 CSS 文件加载不同 CSS 文件⚠️ 需刷新⭐⭐⭐⭐品牌完全隔离
JS 主题对象ThemeProvider 传递⭐⭐⭐CSS-in-JS

2.2 推荐方案:CSS 变量 + Class 切换

这是 Tailwind 生态的标准方案,也是我推荐你使用的方案。

核心思路

  1. 用 CSS 变量定义语义 Token
  2. 用不同的 class(如 .dark.brand-b)定义不同的变量值
  3. 切换根元素的 class 来切换主题
css
/* 主题变量定义 */
:root {
  --color-bg: #FFFFFF;
  --color-text: #111827;
  --color-primary: #3B82F6;
}

.dark {
  --color-bg: #0F172A;
  --color-text: #F8FAFC;
  --color-primary: #60A5FA;
}
jsx
// 切换主题
document.documentElement.classList.toggle('dark');

三、暗黑模式完整实现

3.1 Tailwind 配置

javascript
// tailwind.config.js
module.exports = {
  darkMode: 'class',  // 关键:使用 class 策略
  theme: {
    colors: {
      // 语义颜色通过 CSS 变量
      background: 'var(--color-bg)',
      foreground: 'var(--color-text)',
      primary: {
        DEFAULT: 'var(--color-primary)',
        foreground: 'var(--color-primary-foreground)',
      },
      muted: {
        DEFAULT: 'var(--color-muted)',
        foreground: 'var(--color-muted-foreground)',
      },
      border: 'var(--color-border)',
      ring: 'var(--color-ring)',
      // ... 其他语义颜色
    }
  }
}

3.2 CSS 变量定义

css
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    /* ========== Light Theme ========== */
    /* 背景 */
    --color-bg: 255 255 255;           /* #FFFFFF */
    --color-bg-secondary: 249 250 251; /* #F9FAFB */
    
    /* 文字 */
    --color-text: 17 24 39;            /* #111827 */
    --color-text-muted: 107 114 128;   /* #6B7280 */
    
    /* 品牌/主色 */
    --color-primary: 59 130 246;       /* #3B82F6 */
    --color-primary-foreground: 255 255 255;
    
    /* 静音/次要 */
    --color-muted: 243 244 246;        /* #F3F4F6 */
    --color-muted-foreground: 107 114 128;
    
    /* 边框 */
    --color-border: 229 231 235;       /* #E5E7EB */
    
    /* 焦点环 */
    --color-ring: 59 130 246;
    
    /* 状态色 */
    --color-success: 34 197 94;
    --color-warning: 234 179 8;
    --color-error: 239 68 68;
  }

  .dark {
    /* ========== Dark Theme ========== */
    --color-bg: 15 23 42;              /* #0F172A */
    --color-bg-secondary: 30 41 59;    /* #1E293B */
    
    --color-text: 248 250 252;         /* #F8FAFC */
    --color-text-muted: 148 163 184;   /* #94A3B8 */
    
    --color-primary: 96 165 250;       /* #60A5FA */
    --color-primary-foreground: 15 23 42;
    
    --color-muted: 51 65 85;           /* #334155 */
    --color-muted-foreground: 148 163 184;
    
    --color-border: 51 65 85;
    
    --color-ring: 96 165 250;
    
    --color-success: 74 222 128;
    --color-warning: 250 204 21;
    --color-error: 248 113 113;
  }
}

为什么用 RGB 值而不是十六进制?

为了支持 Tailwind 的透明度修饰符:

jsx
// 如果用十六进制,无法这样写
<div className="bg-primary/50" />  // 50% 透明度

// 需要在 Tailwind config 中这样配置
primary: 'rgb(var(--color-primary) / <alpha-value>)',

完整的支持透明度的配置:

javascript
// tailwind.config.js
module.exports = {
  theme: {
    colors: {
      background: 'rgb(var(--color-bg) / <alpha-value>)',
      foreground: 'rgb(var(--color-text) / <alpha-value>)',
      primary: {
        DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
        foreground: 'rgb(var(--color-primary-foreground) / <alpha-value>)',
      },
      muted: {
        DEFAULT: 'rgb(var(--color-muted) / <alpha-value>)',
        foreground: 'rgb(var(--color-muted-foreground) / <alpha-value>)',
      },
      border: 'rgb(var(--color-border) / <alpha-value>)',
    }
  }
}

3.3 主题切换 Hook

typescript
// hooks/useTheme.ts
import { useEffect, useState } from 'react';

type Theme = 'light' | 'dark' | 'system';

export function useTheme() {
  const [theme, setTheme] = useState<Theme>(() => {
    // 从 localStorage 读取,默认跟随系统
    if (typeof window !== 'undefined') {
      return (localStorage.getItem('theme') as Theme) || 'system';
    }
    return 'system';
  });

  useEffect(() => {
    const root = document.documentElement;
    
    const applyTheme = (newTheme: Theme) => {
      if (newTheme === 'dark') {
        root.classList.add('dark');
      } else if (newTheme === 'light') {
        root.classList.remove('dark');
      } else {
        // system: 跟随系统偏好
        const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
        root.classList.toggle('dark', systemDark);
      }
    };

    applyTheme(theme);
    localStorage.setItem('theme', theme);

    // 监听系统主题变化
    if (theme === 'system') {
      const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
      const handler = (e: MediaQueryListEvent) => {
        root.classList.toggle('dark', e.matches);
      };
      mediaQuery.addEventListener('change', handler);
      return () => mediaQuery.removeEventListener('change', handler);
    }
  }, [theme]);

  return { theme, setTheme };
}

3.4 主题切换组件

tsx
// components/ThemeToggle.tsx
import { useTheme } from '@/hooks/useTheme';

const themes = [
  { value: 'light', label: '浅色', icon: '☀️' },
  { value: 'dark', label: '深色', icon: '🌙' },
  { value: 'system', label: '系统', icon: '💻' },
] as const;

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <div className="flex gap-1 p-1 bg-muted rounded-lg">
      {themes.map(({ value, label, icon }) => (
        <button
          key={value}
          onClick={() => setTheme(value)}
          className={`
            px-3 py-1.5 rounded-md text-sm font-medium transition-colors
            ${theme === value 
              ? 'bg-background text-foreground shadow-sm' 
              : 'text-muted-foreground hover:text-foreground'
            }
          `}
        >
          <span className="mr-1">{icon}</span>
          {label}
        </button>
      ))}
    </div>
  );
}

3.5 防止闪烁(FOUC)

在 SSR/SSG 场景下,页面加载时可能会闪烁。解决方案是在 <head> 中内联一段脚本:

html
<!-- 在 Next.js 中,放在 _document.tsx 或 layout.tsx -->
<head>
  <script dangerouslySetInnerHTML={{
    __html: `
      (function() {
        const theme = localStorage.getItem('theme') || 'system';
        const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
        if (theme === 'dark' || (theme === 'system' && systemDark)) {
          document.documentElement.classList.add('dark');
        }
      })();
    `
  }} />
</head>

四、多品牌主题实现

4.1 场景说明

假设你的组件库需要支持两个品牌:

  • Brand A:蓝色系,科技感
  • Brand B:绿色系,自然感

每个品牌都需要 Light 和 Dark 两种模式。

4.2 架构设计

主题矩阵:
┌──────────────┬──────────────┬──────────────┐
│              │    Light     │     Dark     │
├──────────────┼──────────────┼──────────────┤
│   Brand A    │  A-Light     │   A-Dark     │
├──────────────┼──────────────┼──────────────┤
│   Brand B    │  B-Light     │   B-Dark     │
└──────────────┴──────────────┴──────────────┘

实现策略:品牌 class + 模式 class 组合

<html class="brand-a dark">      → Brand A Dark
<html class="brand-b">           → Brand B Light
<html class="brand-a">           → Brand A Light
<html class="brand-b dark">      → Brand B Dark

4.3 CSS 变量组织

css
/* globals.css */
@layer base {
  /* ================================================
     Brand A (默认品牌)
     ================================================ */
  :root,
  .brand-a {
    /* Light Mode */
    --color-primary: 59 130 246;       /* Blue-500 */
    --color-primary-hover: 37 99 235;  /* Blue-600 */
    --color-primary-foreground: 255 255 255;
    
    --color-accent: 99 102 241;        /* Indigo-500 */
    
    --color-bg: 255 255 255;
    --color-bg-secondary: 248 250 252;
    --color-text: 15 23 42;
    --color-text-muted: 100 116 139;
    --color-border: 226 232 240;
  }

  .brand-a.dark,
  :root.dark {
    /* Brand A Dark Mode */
    --color-primary: 96 165 250;       /* Blue-400 */
    --color-primary-hover: 147 197 253;
    --color-primary-foreground: 15 23 42;
    
    --color-accent: 129 140 248;
    
    --color-bg: 15 23 42;
    --color-bg-secondary: 30 41 59;
    --color-text: 248 250 252;
    --color-text-muted: 148 163 184;
    --color-border: 51 65 85;
  }

  /* ================================================
     Brand B
     ================================================ */
  .brand-b {
    /* Light Mode */
    --color-primary: 16 185 129;       /* Emerald-500 */
    --color-primary-hover: 5 150 105;  /* Emerald-600 */
    --color-primary-foreground: 255 255 255;
    
    --color-accent: 20 184 166;        /* Teal-500 */
    
    --color-bg: 255 255 255;
    --color-bg-secondary: 240 253 250;
    --color-text: 17 24 39;
    --color-text-muted: 107 114 128;
    --color-border: 209 250 229;
  }

  .brand-b.dark {
    /* Brand B Dark Mode */
    --color-primary: 52 211 153;       /* Emerald-400 */
    --color-primary-hover: 110 231 183;
    --color-primary-foreground: 17 24 39;
    
    --color-accent: 45 212 191;
    
    --color-bg: 6 78 59;               /* 深绿背景 */
    --color-bg-secondary: 4 120 87;
    --color-text: 236 253 245;
    --color-text-muted: 167 243 208;
    --color-border: 5 150 105;
  }
}

4.4 主题切换 Hook(多品牌版)

typescript
// hooks/useTheme.ts
import { useEffect, useState } from 'react';

type ColorMode = 'light' | 'dark' | 'system';
type Brand = 'brand-a' | 'brand-b';

interface ThemeConfig {
  colorMode: ColorMode;
  brand: Brand;
}

const DEFAULT_THEME: ThemeConfig = {
  colorMode: 'system',
  brand: 'brand-a',
};

export function useTheme() {
  const [config, setConfig] = useState<ThemeConfig>(() => {
    if (typeof window !== 'undefined') {
      const saved = localStorage.getItem('theme-config');
      return saved ? JSON.parse(saved) : DEFAULT_THEME;
    }
    return DEFAULT_THEME;
  });

  useEffect(() => {
    const root = document.documentElement;
    
    // 应用品牌
    root.classList.remove('brand-a', 'brand-b');
    root.classList.add(config.brand);
    
    // 应用色彩模式
    const applyColorMode = (mode: ColorMode) => {
      if (mode === 'dark') {
        root.classList.add('dark');
      } else if (mode === 'light') {
        root.classList.remove('dark');
      } else {
        const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
        root.classList.toggle('dark', systemDark);
      }
    };

    applyColorMode(config.colorMode);
    localStorage.setItem('theme-config', JSON.stringify(config));

    // 监听系统主题变化
    if (config.colorMode === 'system') {
      const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
      const handler = (e: MediaQueryListEvent) => {
        root.classList.toggle('dark', e.matches);
      };
      mediaQuery.addEventListener('change', handler);
      return () => mediaQuery.removeEventListener('change', handler);
    }
  }, [config]);

  const setColorMode = (colorMode: ColorMode) => {
    setConfig(prev => ({ ...prev, colorMode }));
  };

  const setBrand = (brand: Brand) => {
    setConfig(prev => ({ ...prev, brand }));
  };

  return {
    colorMode: config.colorMode,
    brand: config.brand,
    setColorMode,
    setBrand,
  };
}

4.5 多品牌切换组件

tsx
// components/ThemePanel.tsx
import { useTheme } from '@/hooks/useTheme';

export function ThemePanel() {
  const { colorMode, brand, setColorMode, setBrand } = useTheme();

  return (
    <div className="p-4 bg-background border border-border rounded-lg space-y-4">
      {/* 品牌切换 */}
      <div>
        <label className="block text-sm font-medium text-foreground mb-2">
          品牌主题
        </label>
        <div className="flex gap-2">
          <button
            onClick={() => setBrand('brand-a')}
            className={`
              flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all
              ${brand === 'brand-a'
                ? 'bg-primary text-primary-foreground'
                : 'bg-muted text-muted-foreground hover:bg-muted/80'
              }
            `}
          >
            Brand A (蓝色)
          </button>
          <button
            onClick={() => setBrand('brand-b')}
            className={`
              flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all
              ${brand === 'brand-b'
                ? 'bg-primary text-primary-foreground'
                : 'bg-muted text-muted-foreground hover:bg-muted/80'
              }
            `}
          >
            Brand B (绿色)
          </button>
        </div>
      </div>

      {/* 色彩模式切换 */}
      <div>
        <label className="block text-sm font-medium text-foreground mb-2">
          色彩模式
        </label>
        <div className="flex gap-2">
          {[
            { value: 'light', label: '浅色' },
            { value: 'dark', label: '深色' },
            { value: 'system', label: '跟随系统' },
          ].map(({ value, label }) => (
            <button
              key={value}
              onClick={() => setColorMode(value as any)}
              className={`
                flex-1 px-3 py-2 rounded-md text-sm font-medium transition-all
                ${colorMode === value
                  ? 'bg-foreground text-background'
                  : 'bg-muted text-muted-foreground hover:bg-muted/80'
                }
              `}
            >
              {label}
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}

五、Token 文件组织:最佳实践

5.1 推荐目录结构

src/
├── styles/
│   ├── tokens/
│   │   ├── primitive.css      # 原始值(调色板、刻度)
│   │   ├── semantic-light.css # 语义层 - Light
│   │   ├── semantic-dark.css  # 语义层 - Dark
│   │   ├── brand-a.css        # 品牌 A 覆盖
│   │   └── brand-b.css        # 品牌 B 覆盖
│   └── globals.css            # 入口,导入所有 token
├── components/
└── tailwind.config.js

5.2 分文件组织示例

css
/* styles/tokens/primitive.css */
/* 调色板 - 所有主题共享 */
:root {
  /* Blue palette */
  --palette-blue-50: 239 246 255;
  --palette-blue-100: 219 234 254;
  --palette-blue-500: 59 130 246;
  --palette-blue-600: 37 99 235;
  
  /* Green palette */
  --palette-green-50: 240 253 244;
  --palette-green-500: 34 197 94;
  --palette-green-600: 22 163 74;
  
  /* Gray palette */
  --palette-gray-50: 249 250 251;
  --palette-gray-100: 243 244 246;
  --palette-gray-500: 107 114 128;
  --palette-gray-900: 17 24 39;
  
  /* Spacing scale */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
}
css
/* styles/tokens/semantic-light.css */
:root {
  /* 语义颜色 - Light 模式 */
  --color-bg: var(--palette-gray-50);
  --color-bg-secondary: 255 255 255;
  --color-text: var(--palette-gray-900);
  --color-text-muted: var(--palette-gray-500);
  --color-border: var(--palette-gray-200);
}
css
/* styles/tokens/semantic-dark.css */
.dark {
  /* 语义颜色 - Dark 模式 */
  --color-bg: 15 23 42;
  --color-bg-secondary: 30 41 59;
  --color-text: 248 250 252;
  --color-text-muted: 148 163 184;
  --color-border: 51 65 85;
}
css
/* styles/tokens/brand-a.css */
:root,
.brand-a {
  --color-primary: var(--palette-blue-500);
  --color-primary-hover: var(--palette-blue-600);
  --color-primary-foreground: 255 255 255;
}

.brand-a.dark {
  --color-primary: 96 165 250;
  --color-primary-hover: 147 197 253;
  --color-primary-foreground: 15 23 42;
}
css
/* styles/tokens/brand-b.css */
.brand-b {
  --color-primary: var(--palette-green-500);
  --color-primary-hover: var(--palette-green-600);
  --color-primary-foreground: 255 255 255;
}

.brand-b.dark {
  --color-primary: 74 222 128;
  --color-primary-hover: 134 239 172;
  --color-primary-foreground: 17 24 39;
}
css
/* styles/globals.css */
@import './tokens/primitive.css';
@import './tokens/semantic-light.css';
@import './tokens/semantic-dark.css';
@import './tokens/brand-a.css';
@import './tokens/brand-b.css';

@tailwind base;
@tailwind components;
@tailwind utilities;

六、运行时切换 vs 构建时生成

6.1 两种策略对比

维度运行时切换构建时生成
实现CSS 变量 + class 切换多套 CSS 文件
切换速度即时需重新加载
首屏性能稍大(包含所有主题变量)更小(只加载当前主题)
适用场景用户可切换主题品牌完全隔离
维护成本

6.2 我的建议

对于你的场景(个人组件库 + 多主题),选择运行时切换

原因:

  1. 你需要用户能在运行时切换主题
  2. CSS 变量的性能影响可以忽略
  3. 维护成本低,一套代码多套主题

什么时候用构建时生成?

  • 多个完全独立的产品(不需要运行时切换)
  • 对首屏性能有极致要求
  • 安全考虑(不希望其他品牌的样式出现在代码中)

七、高级技巧

7.1 CSS 变量的响应式覆盖

css
:root {
  --page-padding: 1rem;
}

@media (min-width: 768px) {
  :root {
    --page-padding: 2rem;
  }
}

@media (min-width: 1024px) {
  :root {
    --page-padding: 3rem;
  }
}

7.2 组件级主题覆盖

jsx
// 局部覆盖主题(例如:深色卡片在浅色页面中)
function DarkCard({ children }) {
  return (
    <div 
      className="dark bg-background text-foreground p-4 rounded-lg"
      style=\{\{
        // 或者用内联变量覆盖
        '--color-bg': '15 23 42',
        '--color-text': '248 250 252',
      \}\}
    >
      {children}
    </div>
  );
}

7.3 主题过渡动画

css
/* 全局主题切换动画 */
:root {
  color-scheme: light;
}

.dark {
  color-scheme: dark;
}

/* 平滑过渡 */
*,
*::before,
*::after {
  transition: background-color 0.2s ease, 
              border-color 0.2s ease,
              color 0.2s ease;
}

/* 排除不需要过渡的元素 */
.no-transition,
.no-transition *,
.no-transition *::before,
.no-transition *::after {
  transition: none !important;
}

7.4 检测用户系统主题偏好

typescript
// 检测用户是否偏好暗色
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

// 检测用户是否偏好减少动画
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// 检测用户是否偏好高对比度
const prefersContrast = window.matchMedia('(prefers-contrast: more)').matches;

八、本轮小结

核心架构

多主题实现架构:

┌─────────────────────────────────────────────────────────┐
│                   tailwind.config.js                    │
│         colors: { primary: 'rgb(var(--color-primary))' }│
└───────────────────────────┬─────────────────────────────┘
                            │ 引用

┌─────────────────────────────────────────────────────────┐
│                    CSS 变量层                            │
│  :root { --color-primary: 59 130 246 }                  │
│  .dark { --color-primary: 96 165 250 }                  │
│  .brand-b { --color-primary: 34 197 94 }                │
└───────────────────────────┬─────────────────────────────┘
                            │ 切换

┌─────────────────────────────────────────────────────────┐
│                    HTML 根元素                           │
│  <html class="brand-a dark">                            │
│  通过 JS 动态切换 class                                  │
└─────────────────────────────────────────────────────────┘

关键实践

  1. 用 CSS 变量存储语义 Token,用 class 切换变量值
  2. 使用 RGB 格式以支持 Tailwind 的透明度修饰符
  3. 品牌 + 模式双维度.brand-a.dark 组合使用
  4. 防止 FOUC:在 <head> 中内联主题检测脚本
  5. 分文件组织:primitive / semantic / brand 分离

文件清单

你需要创建的文件:

  • tailwind.config.js - 引用 CSS 变量
  • styles/globals.css - 定义所有主题的 CSS 变量
  • hooks/useTheme.ts - 主题切换逻辑
  • components/ThemeToggle.tsx - 主题切换 UI

本轮思考问题

在进入下一个话题(工程化落地方案)之前:

  1. 你的多主题需求具体是哪种? 纯暗黑模式?还是需要多品牌?

  2. 你的项目是否需要 SSR(如 Next.js)? 这会影响防闪烁方案的实现。

  3. 这套 CSS 变量 + class 切换的方案,你觉得复杂度可接受吗?