深浅主题全站适配:从 CSS 变量到 Canvas 粒子
一个主题切换按钮,要协调 5 个彼此独立的子系统
On this page
给博客加深浅主题切换,听起来就是装个 next-themes、写几行 CSS 变量的事。但真做下来发现,一个 toggle 按钮要同时驱动 5 个完全独立的子系统:CSS 变量、代码高亮、Canvas 粒子动画、Mermaid 图表、KaTeX 公式。任何一个没跟上,用户切换主题时就会看到"半黑半白"的割裂感。
这篇文章记录我怎么让这 5 层保持同步。
中枢:next-themes 的 class 策略
所有子系统的同步依赖一个前提——主题状态只有一个来源:<html> 标签上的 class。
ThemeProvider.tsx 的配置:
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem themes={['light', 'dark']} disableTransitionOnChange/>关键决策:attribute="class" 而不是 data-theme。这意味着切换主题时,<html> 会被加上或移除 .light class(暗色是默认态,不加 class)。所有子系统都去读这一个 class 就行了。
disableTransitionOnChange 也值得一提——切换时临时禁用所有 CSS transition,避免背景色、文字颜色在过渡期产生不协调的中间态。
还有一个 React 19 的坑:next-themes 内部会在 <head> 里注入一个防闪烁 <script>,但 React 19 在客户端 re-render 时会对组件内的 <script> 标签发出警告。解决方式是在客户端给 script 传一个非执行的 type:
const scriptProps = typeof window === 'undefined' ? undefined : ({ type: 'application/json' } as const)第 1 层:CSS 变量架构
Tailwind v4 用 @theme 声明设计令牌,这些令牌会直接变成 CSS 自定义属性。我的暗色模式作为默认态:
@theme { --color-base: #0f0e0c; --color-surface: #141210; --color-card: #1a1816; --color-hover: #1e1c1a; --color-primary: #f0ede8; --color-body: #c8c6c0; --color-secondary: #8a8884; --color-muted: #5a5854; --color-dim: #3a3836; --color-ghost: #2e2c2a; --color-bd-weak: #1a1816; --color-bd-base: #1e1c1a; --color-bd-strong: #2a2826; --color-bd-hover: #4a4846; --color-code: #a0cfa0;}然后 .light class 覆盖同名变量:
.light { --color-base: #f5f2ed; --color-surface: #ede9e3; --color-card: #e5e0d8; --color-primary: #1a1814; --color-body: #2e2c28; --color-secondary: #4a4640; --color-muted: #6a6560; --color-code: #3a7a3a;}级联顺序是关键:.light 选择器的特异性只比 @theme 生成的 :root 高一点,但因为它出现在后面,同名变量会被覆盖。整个站点所有用到 text-body、bg-base、border-bd-base 的地方自动切换,不需要写任何 dark: 前缀。
色板的设计思路是"暖灰"——所有颜色都偏黄褐色调,RGB 通道保持 R > G > B 的微妙梯度(比如 #c8c6c0 而不是纯中灰 #c8c8c8)。这样深色模式不会冷冰冰的,浅色模式也不会死白一片。
第 2 层:ThemeToggle 水合安全
主题切换按钮面临经典的 SSR 水合问题:服务端不知道用户偏好什么主题,渲染出来的 icon 和客户端不一致就会闪烁。
常见方案是 useEffect + useState:
// ❌ 传统方案:有并发模式隐患const [mounted, setMounted] = useState(false)useEffect(() => setMounted(true), [])这在 React 18+ 的并发模式下不够安全——useEffect 的执行时机不确定,可能导致 tearing(撕裂)。
我用了 useSyncExternalStore 的三参数技巧:
const mounted = useSyncExternalStore( () => () => {}, // subscribe: 空函数,不需要监听变化 () => true, // getSnapshot: 客户端返回 true () => false, // getServerSnapshot: 服务端返回 false)三个参数分别做了什么:
subscribe: () => () => {}——返回一个空的 unsubscribe 函数。因为mounted这个值永远不变(客户端就是true),不需要订阅任何外部源getSnapshot: () => true——客户端始终返回true,表示已挂载getServerSnapshot: () => false——服务端始终返回false,表示未挂载
为什么这比 useEffect 好?useSyncExternalStore 是 React 为并发模式专门设计的 API,它保证快照的一致性——不会在一次 render 中前半段读到 false 后半段读到 true。
未挂载时渲染一个同尺寸的占位 div,避免布局跳动:
if (!mounted) { return <div className="w-[18px] h-[18px]" aria-hidden="true" />}切换逻辑是三态循环:system → light → dark → system,不是简单的二元 toggle。
第 3 层:Expressive Code 双主题
代码块用的是 Expressive Code,它支持自定义 VS Code 风格的主题。我定义了两套完整的配色,每套 10 个 token 类别:
export const chaosDarkTheme = { name: 'chaos-dark', type: 'dark', settings: { foreground: '#c8c6c0', background: '#141210', }, tokenColors: [ { scope: ['comment'], settings: { foreground: '#5a5854' } }, { scope: ['string', 'constant.other.symbol'], settings: { foreground: '#a0cfa0' } }, { scope: ['keyword', 'storage.type'], settings: { foreground: '#d4a574' } }, { scope: ['entity.name.function'], settings: { foreground: '#8ab4c8' } }, { scope: ['entity.name.tag'], settings: { foreground: '#c8a0c8' } }, // ... ],}浅色主题的 token 颜色不是简单反转,而是重新设计了对比度。比如暗色的字符串是 #a0cfa0(柔绿),浅色变成 #3a7a3a(深绿)——同色相但明度反转,在各自的背景上保持足够的对比度。
关键配置在 expressive-code.config.mjs:
export const ecOptions = { themes: [chaosDarkTheme, chaosLightTheme], themeCssSelector: (theme) => `.${theme.type}`, useDarkModeMediaQuery: false,}themeCssSelector: (theme) => '.${theme.type}'——告诉 Expressive Code:当 .dark class 存在时用暗色主题,当 .light class 存在时用浅色主题。这正好和 next-themes 在 <html> 上切换的 class 对齐。
useDarkModeMediaQuery: false——禁用 prefers-color-scheme 媒体查询。如果不禁用,Expressive Code 会同时响应系统偏好和 class,产生冲突:用户手动选了 light,但系统是 dark,代码块就会显示暗色。
第 4 层:Canvas 粒子背景
粒子背景是全站铺底的 Canvas 动画,这一层不走 CSS 变量——Canvas 的 fillStyle、strokeStyle 需要直接传 RGBA 字符串。
解决方式很直接:每帧 requestAnimationFrame 时,直接读 DOM class:
const PALETTE = { dark: { particle: '180,170,155', line: '120,110,95', particleOpacityScale: 1, lineOpacityScale: 0.12, }, light: { particle: '120,110,95', line: '160,150,135', particleOpacityScale: 0.7, lineOpacityScale: 0.08, },}
function getPalette() { return document.documentElement.classList.contains('light') ? PALETTE.light : PALETTE.dark}每帧调 getPalette() 看起来浪费,但 classList.contains() 本身非常快(浏览器内部是 hash set),一帧一次完全不是瓶颈。
粒子背景还有两个值得一提的性能细节:
DPR 处理:Retina 屏幕下 Canvas 的物理像素是逻辑像素的 2-3 倍。如果不处理,粒子会模糊:
const dpr = window.devicePixelRatio || 1canvas.width = rect.width * dprcanvas.height = rect.height * dprctx.setTransform(dpr, 0, 0, dpr, 0, 0)粒子数量与连线复杂度:粒子数量按 W × H / 7000 计算。在 1920×1080 的屏幕上大约 296 个粒子。连线检测是 O(n²) 的双重循环——296 个粒子意味着每帧约 296 × 295 / 2 ≈ 43,660 次距离计算。这个量级在现代浏览器上完全没问题,但如果屏幕更大或密度更高就需要考虑空间分区优化了。
浅色模式下粒子的 opacity 也做了调整(particleOpacityScale: 0.7),加上 CSS 层面 .light .particle-bg { opacity: 0.4 } 的双重降低,确保浅色背景下粒子不会太抢眼。
第 5 层:KaTeX 与 Mermaid
KaTeX 的问题比较特殊:它内部有很多细粒度的 class(.mord、.mop、.mbin、.mrel 等),默认渲染成黑色,在暗色模式下完全看不见。
解决方式是暴力覆盖所有内部 class 的颜色:
.light .katex .mord,.light .katex .mop,.light .katex .mbin,.light .katex .mrel,.light .katex .mopen,.light .katex .mclose,.light .katex .mpunct,.light .katex .minner { color: var(--color-body);}暗色模式下只需要 .katex { color: var(--color-body) } 就够了,因为 KaTeX 默认黑色字在暗色模式下需要覆盖,但实际上只要让根级 .katex 用 CSS 变量就行。.light 下之所以要逐个覆盖,是因为 KaTeX 内部样式对子元素有明确的 color 声明,优先级比根级的继承高。
Mermaid 图表则走了另一条路——直接用 next-themes 的 resolvedTheme:
const { resolvedTheme } = useTheme()
mermaid.initialize({ startOnLoad: false, theme: resolvedTheme === 'light' ? 'neutral' : 'dark',})resolvedTheme 会把 system 解析成实际的 light 或 dark,省去自己判断系统偏好的逻辑。当 resolvedTheme 变化时,useEffect 的依赖数组会触发 Mermaid 重新渲染。
总结:5 层协调
用户点一下 toggle 按钮后发生的事:
| 子系统 | 感知主题的方式 | 文件 |
|---|---|---|
| CSS 变量 | .light class → 变量覆盖 | globals.css |
| ThemeToggle | next-themes 的 useTheme() | ThemeToggle.tsx |
| Expressive Code | themeCssSelector → .light / .dark | expressive-code.config.mjs |
| Canvas 粒子 | 每帧读 classList.contains('light') | ParticleBackground.tsx |
| KaTeX | .light .katex .mord 等 CSS 覆盖 | globals.css |
| Mermaid | useTheme() → resolvedTheme | Mermaid.tsx |
所有子系统的同步都不需要事件总线、全局 state 或 context 传递。核心在于 next-themes 往 <html> 上切 class 这个副作用足够简单、足够快——CSS 级联自动处理大部分场景,Canvas 和 JS 库各自用最轻量的方式去读这个 class。
深浅主题的难点不在于任何单个子系统的适配,而在于确保所有子系统对"当前是什么主题"这个问题给出一致的答案。把 <html> 上的 class 当成唯一信源,每层都去读它,就不会错。