16 — 主题系统与 ThemeSwitcher
本章讲解本项目6 套主题系统的完整实现。
涵盖:主题切换原理、CSS 变量分组、CSS-only 主题应用、ThemeSwitcher 组件。
1. 主题列表
6 套主题(3 暗 + 3 亮):
| ID | 名称 | 风格 | 背景 | 主色 |
|---|
ink | 墨韵东方 | 暗 | #0e0d0b 浓墨 | #c9a36b 古铜金 |
minimal | 极简留白 | 暗 | #0a0a0a 纯黑 | #c8553d 朱砂 |
forest | 森绿雅静 | 暗 | #0f1611 深林 | #8ba67b 苔藓 |
paper | 素雅米黄 | 亮 | #f5f0e6 宣纸 | #a07a3e 古铜金 |
cream | 乳白简约 | 亮 | #fafaf7 乳白 | #3a4a6b 靛蓝 |
matcha | 抹茶清新 | 亮 | #e8e6d8 抹茶绿 | #5a6b3e 抹茶深绿 |
主题元数据(src/store/themeStore.ts):
1 2 3 4 5 6 7 8 9 10 11 12
| export interface ThemeMeta { id: ThemeId; name: string; description: string; swatch: string; mode: 'dark' | 'light'; }
export const THEMES: ThemeMeta[] = [ { id: 'ink', name: '墨韵东方', description: '深墨浓韵,宋画留白', swatch: '#0e0d0b', mode: 'dark' }, ];
|
2. 主题切换原理
2.1 核心机制:CSS 变量 + data-theme 属性
主题切换 不依赖 React 重渲染——它通过修改 <html> 元素的 data-theme 属性,让 CSS 选择器自动重新匹配:
1 2
| document.documentElement.setAttribute('data-theme', 'paper');
|
CSS 侧:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| :root, [data-theme='ink'] { --bg-base: #0e0d0b; --color-primary: #c9a36b; --text-primary: #d4cab8; }
[data-theme='paper'] { --bg-base: #f5f0e6; --color-primary: #a07a3e; --text-primary: #2a241a; }
|
所有引用 var(--bg-base) 等变量的组件瞬间变色,不需任何 JavaScript。
2.2 为什么这样设计?
| 方案 | 优点 | 缺点 |
|---|
| ❌ JSX 内联样式 | 直观 | 切换主题需重渲染所有组件 |
| ❌ CSS className 切换 | 简单 | 每个组件需写 6 套 className |
| ✅ CSS 变量 + data-theme | 切换快、零重渲染、自动级联 | 浏览器需支持 CSS 变量(现代浏览器都支持) |
2.3 主题持久化
1 2 3 4 5 6 7 8 9 10 11 12
| const STORAGE_KEY = 'dodo.theme';
function applyTheme(theme: ThemeId) { document.documentElement.setAttribute('data-theme', theme); try { localStorage.setItem(STORAGE_KEY, theme); } catch { } }
function readInitialTheme(): ThemeId { const t = localStorage.getItem(STORAGE_KEY) as ThemeId | null; if (t && THEMES.some((th) => th.id === t)) return t; return DEFAULT_THEME; }
|
2.4 防闪烁:HTML 加载前应用
问题:如果 React 启动后才应用主题,用户会先看到默认主题闪烁一下。
解决:在 index.html 的 <head> 里加同步脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <!doctype html> <html lang="zh-CN" data-theme="ink"> <head> <script> (function() { try { var t = localStorage.getItem('dodo.theme'); if (t) document.documentElement.setAttribute('data-theme', t); } catch (e) {} })(); </script> </head> <body> ... </body> </html>
|
页面加载完成时(React 还没启动),脚本立即从 localStorage 读取主题并应用,避免任何闪烁。
3. tokens.css 分组结构
文件:src/styles/tokens.css
3.1 通用令牌(所有主题共享)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| :root { --font-sans: 'Plus Jakarta Sans', ...; --font-display: 'Sora', ...; --font-mono: 'JetBrains Mono', ...;
--space-1: 4px; --space-2: 8px;
--radius-sm: 4px; --radius-md: 8px;
--sidebar-width: 280px; --max-content-width: 760px; --topbar-height: 56px; }
|
3.2 主题令牌(每个主题独立)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| :root, [data-theme='ink'] { --bg-base: #0e0d0b; --bg-surface: #161412; --bg-elevated: #1d1a16; --bg-hover: #25211c; --bg-overlay: rgba(14, 13, 11, 0.6);
--color-primary: #c9a36b; --color-primary-soft: rgba(201, 163, 107, 0.12); --color-primary-strong: #f4c26b; --color-primary-glow: rgba(201, 163, 107, 0.25);
--color-accent: #a8543e; --color-danger: #c25a4a; --color-success: #6b9b6a;
--text-primary: #d4cab8; --text-secondary: #8a7f6c; --text-tertiary: #5a5246; --text-inverse: #0e0d0b;
--border-subtle: rgba(212, 202, 184, 0.08); --border-default: rgba(212, 202, 184, 0.12); --border-strong: rgba(212, 202, 184, 0.2); --border-accent: rgba(201, 163, 107, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5); --shadow-glow: 0 0 24px rgba(201, 163, 107, 0.15); }
[data-theme='paper'] { --bg-base: #f5f0e6; --bg-surface: #fbf7ee; --bg-elevated: #ffffff; --color-primary: #a07a3e; --text-primary: #2a241a; --text-inverse: #fbf7ee; }
|
设计原则:
- 暗色主题用「古铜金/朱砂/苔藓」等暖色或自然色
- 亮色主题用「古铜金/靛蓝/抹茶」等有质感的颜色
- 同一变量在不同主题有「对偶」关系:暗的
--text-primary 浅,亮的 --text-primary 深
4. themeStore 完整代码
文件:src/store/themeStore.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import { create } from 'zustand';
export type ThemeId = 'ink' | 'minimal' | 'forest' | 'paper' | 'cream' | 'matcha';
export interface ThemeMeta { id: ThemeId; name: string; description: string; swatch: string; mode: 'dark' | 'light'; }
export const THEMES: ThemeMeta[] = [ { id: 'ink', name: '墨韵东方', description: '深墨浓韵,宋画留白', swatch: '#0e0d0b', mode: 'dark' }, { id: 'minimal', name: '极简留白', description: '纯黑灰阶,朱砂点缀', swatch: '#0a0a0a', mode: 'dark' }, { id: 'forest', name: '森绿雅静', description: '深林静谧,苔绿安然', swatch: '#0f1611', mode: 'dark' }, { id: 'paper', name: '素雅米黄', description: '宣纸温润,金色典雅', swatch: '#f5f0e6', mode: 'light' }, { id: 'cream', name: '乳白简约', description: '极致简约,靛蓝克制', swatch: '#fafaf7', mode: 'light' }, { id: 'matcha', name: '抹茶清新', description: '淡雅柔和,自然静谧', swatch: '#e8e6d8', mode: 'light' }, ];
const STORAGE_KEY = 'dodo.theme'; const DEFAULT_THEME: ThemeId = 'ink';
function readInitialTheme(): ThemeId { if (typeof window === 'undefined') return DEFAULT_THEME; try { const t = localStorage.getItem(STORAGE_KEY) as ThemeId | null; if (t && THEMES.some((th) => th.id === t)) return t; } catch { } return DEFAULT_THEME; }
function applyTheme(theme: ThemeId) { if (typeof document === 'undefined') return; document.documentElement.setAttribute('data-theme', theme); try { localStorage.setItem(STORAGE_KEY, theme); } catch { } }
export const useThemeStore = create<{ current: ThemeId; setTheme: (t: ThemeId) => void }>( (set) => ({ current: readInitialTheme(), setTheme: (theme) => { applyTheme(theme); set({ current: theme }); }, }) );
|
5. ThemeSwitcher 组件
文件:src/components/ThemeSwitcher/ThemeSwitcher.tsx
5.1 完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
| import { useEffect, useState } from 'react'; import { Check, Palette, X } from 'lucide-react'; import { THEMES, useThemeStore, type ThemeId } from '@/store/themeStore'; import styles from './ThemeSwitcher.module.css';
interface Props { open: boolean; onClose: () => void; }
export function ThemeSwitcher({ open, onClose }: Props) { const current = useThemeStore((s) => s.current); const setTheme = useThemeStore((s) => s.setTheme); const [activeId, setActiveId] = useState<ThemeId>(current);
useEffect(() => { setActiveId(current); }, [current, open]);
useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]);
if (!open) return null;
return ( <div className={styles.overlay} onClick={onClose}> <div className={styles.panel} onClick={(e) => e.stopPropagation()}> <div className={styles.header}> <div className={styles.title}> <Palette size={16} /> <span>主题风格</span> </div> <button className={styles.closeBtn} onClick={onClose} type="button" aria-label="关闭"> <X size={16} /> </button> </div>
<p className={styles.hint}>选择一种主题,点击即可切换</p>
<div className={styles.grid}> {THEMES.map((t) => ( <button key={t.id} type="button" className={`${styles.card} ${activeId === t.id ? styles.active : ''}`} onClick={() => setTheme(t.id)} > <div className={styles.swatch} style={{ background: t.swatch }}> <div className={styles.swatchText} style={{ color: t.mode === 'dark' ? '#d4cab8' : '#2a241a', }}> 豆 </div> <div className={styles.swatchAccent} style={{ background: getPrimaryColor(t.id) }} /> {activeId === t.id && ( <div className={styles.checkMark}><Check size={14} /></div> )} </div> <div className={styles.info}> <div className={styles.name}>{t.name}</div> <div className={styles.desc}>{t.description}</div> </div> </button> ))} </div> </div> </div> ); }
function getPrimaryColor(id: ThemeId): string { const map: Record<ThemeId, string> = { ink: '#c9a36b', minimal: '#c8553d', forest: '#8ba67b', paper: '#a07a3e', cream: '#3a4a6b', matcha: '#5a6b3e', }; return map[id]; }
|
5.2 关键设计
主题预览卡片
每个主题显示:
- 背景色块(
t.swatch) — 实际背景色 - 主色圆点(
getPrimaryColor(t.id)) — 主色调 - 文字颜色(按
mode 选) — 模拟真实观感 - 主题名 + 描述 — 文字信息
- 选中标记(对勾) — 当前主题
点击外部关闭
1 2 3 4 5
| <div className={styles.overlay} onClick={onClose}> <div className={styles.panel} onClick={(e) => e.stopPropagation()}> ... </div> </div>
|
外层点击关闭,内层 stopPropagation 阻止冒泡。
ESC 关闭
1 2 3 4 5 6 7 8
| useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]);
|
6. 主题打开方式:跨组件通信
问题:Topbar 在 main 内,ThemeSwitcher 在 App 顶层(兄弟组件),不方便传 props。
解决:用 CustomEvent:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| export function dispatchOpenTheme() { window.dispatchEvent(new CustomEvent('dodo:open-theme-switcher')); }
import { dispatchOpenTheme } from '@/store/themeStore'; <button onClick={dispatchOpenTheme}>主题</button>
useEffect(() => { const handler = () => setThemeOpen(true); window.addEventListener('dodo:open-theme-switcher', handler); return () => window.removeEventListener('dodo:open-theme-switcher', handler); }, []);
return <ThemeSwitcher open={themeOpen} onClose={() => setThemeOpen(false)} />;
|
详见 17-移动端适配实战.md 的「跨组件通信」章节。
7. 怎么加新主题
只需要 3 步:
步骤 1:在 themeStore.ts 加主题元数据
1 2 3 4
| export const THEMES: ThemeMeta[] = [ { id: 'sunset', name: '落日橘', description: '橙红暖调,落霞余晖', swatch: '#fef3e2', mode: 'light' }, ];
|
步骤 2:在 tokens.css 末尾加主题块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| [data-theme='sunset'] { --bg-base: #fef3e2; --bg-surface: #fff8eb; --bg-elevated: #ffffff; --color-primary: #ff6b35; --color-primary-soft: rgba(255, 107, 53, 0.1); --color-primary-strong: #ff5722; --color-primary-glow: rgba(255, 107, 53, 0.15); --text-primary: #2d1810; --text-secondary: #6d4a3a; --text-tertiary: #9a7868; --text-inverse: #ffffff; --border-subtle: rgba(45, 24, 16, 0.06); --border-default: rgba(45, 24, 16, 0.1); --border-accent: rgba(255, 107, 53, 0.4); --shadow-glow: 0 0 20px rgba(255, 107, 53, 0.15); }
|
步骤 3:在 ThemeSwitcher.tsx 的 getPrimaryColor 函数加映射
1 2 3 4 5 6 7 8
| function getPrimaryColor(id: ThemeId): string { const map: Record<ThemeId, string> = { ink: '#c9a36b', minimal: '#c8553d', forest: '#8ba67b', paper: '#a07a3e', cream: '#3a4a6b', matcha: '#5a6b3e', sunset: '#ff6b35', }; return map[id]; }
|
UI 自动显示新主题选项,无需改任何其他代码。
8. 实战:让用户上传自定义主题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| function handleThemeUpload(e: ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { const customTheme = JSON.parse(reader.result as string); if (!customTheme['--color-primary']) { alert('主题文件不完整'); return; } const style = document.createElement('style'); style.textContent = `[data-theme='custom'] { ${Object.entries(customTheme).map(([k, v]) => `${k}: ${v};`).join('\n')} }`; document.head.appendChild(style); useThemeStore.getState().setTheme('custom'); }; reader.readAsText(file); }
|
9. 一段话总结
6 主题系统通过 data-theme 属性 + CSS 变量分组覆盖实现。
切换机制:JS 修改 <html data-theme="...">,CSS 自动重新匹配,零 React 重渲染。
持久化:localStorage 存储 + 页面加载时同步脚本应用(防闪烁)。
新增主题:3 步:① 主题元数据 ② tokens.css 块 ③ getPrimaryColor 映射。
ThemeSwitcher 组件:点击主题立即切换,支持 ESC 关闭、点击外部关闭。
接下来
继续阅读 17-移动端适配实战.md — Chrome 100vh 坑、safe-area 适配、抽屉模式。