← 文章
tutorial·2026-07-04·17 min read

深浅主题全站适配:从 CSS 变量到 Canvas 粒子

一个主题切换按钮,要协调 5 个彼此独立的子系统

目录

给博客加深浅主题切换,听起来就是装个 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-bodybg-baseborder-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
)

三个参数分别做了什么:

  1. subscribe: () => () => {}——返回一个空的 unsubscribe 函数。因为 mounted 这个值永远不变(客户端就是 true),不需要订阅任何外部源
  2. getSnapshot: () => true——客户端始终返回 true,表示已挂载
  3. 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 的 fillStylestrokeStyle 需要直接传 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 || 1
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
ctx.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-themesresolvedTheme

const { resolvedTheme } = useTheme()
mermaid.initialize({
startOnLoad: false,
theme: resolvedTheme === 'light' ? 'neutral' : 'dark',
})

resolvedTheme 会把 system 解析成实际的 lightdark,省去自己判断系统偏好的逻辑。当 resolvedTheme 变化时,useEffect 的依赖数组会触发 Mermaid 重新渲染。

总结:5 层协调

用户点一下 toggle 按钮后发生的事:

子系统感知主题的方式文件
CSS 变量.light class → 变量覆盖globals.css
ThemeTogglenext-themesuseTheme()ThemeToggle.tsx
Expressive CodethemeCssSelector.light / .darkexpressive-code.config.mjs
Canvas 粒子每帧读 classList.contains('light')ParticleBackground.tsx
KaTeX.light .katex .mord 等 CSS 覆盖globals.css
MermaiduseTheme()resolvedThemeMermaid.tsx

所有子系统的同步都不需要事件总线、全局 state 或 context 传递。核心在于 next-themes 往 <html> 上切 class 这个副作用足够简单、足够快——CSS 级联自动处理大部分场景,Canvas 和 JS 库各自用最轻量的方式去读这个 class。

深浅主题的难点不在于任何单个子系统的适配,而在于确保所有子系统对"当前是什么主题"这个问题给出一致的答案。把 <html> 上的 class 当成唯一信源,每层都去读它,就不会错。