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' },
// ... 其他 5 个
];

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
/* 默认主题(也是 ink 主题) */
:root,
[data-theme='ink'] {
--bg-base: #0e0d0b;
--color-primary: #c9a36b;
--text-primary: #d4cab8;
/* ... */
}

/* 切换到 paper 主题时,CSS 自动重新匹配 */
[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 { /* noop */ }
}

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; // 'ink'
}

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 { /* noop */ }
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 { /* noop */ }
}

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]);

// ESC 关闭
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
// themeStore.ts
export function dispatchOpenTheme() {
window.dispatchEvent(new CustomEvent('dodo:open-theme-switcher'));
}

// Topbar.tsx
import { dispatchOpenTheme } from '@/store/themeStore';
<button onClick={dispatchOpenTheme}>主题</button>

// App.tsx
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[] = [
// ... 现有 6 个
{ 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.tsxgetPrimaryColor 函数加映射

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
// 思路:让用户上传 JSON 主题文件
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;
}
// 动态插入到 head
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 适配、抽屉模式。