引子
最近在开发个人网站时,被 Element Plus 官网的主题切换动画深深吸引——点击切换按钮后,新主题从按钮位置以圆形向外扩散,而非生硬的全局颜色跳变。这种流畅的视觉过渡让我决定在自己的 Next.js 项目中复刻这个效果。
经过一番实践,我基于 React 18 的 useSyncExternalStore、CSS 自定义属性、Tailwind CSS 4 的 @theme inline 以及 View Transitions API 搭建了一套完整的主题切换方案。本文将深入剖析这个实现,分享其中的亮点与踩过的坑。
核心架构
整个主题切换系统由以下几个核心模块组成:
┌─────────────────────────────────────────────────────┐
│ Root Layout │
│ ┌───────────────────────────────────────────────┐ │
│ │ Inline Script (FOUC Prevention) │ │
│ │ localStorage → .dark class on <html> │ │
│ └───────────────────────────────────────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ThemeProvider (React Context) │ │
│ │ useSyncExternalStore + module-level cache │ │
│ │ → sync .dark class via useEffect │ │
│ └───────────────────────────────────────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ThemeToggle (View Transitions API) │ │
│ │ startViewTransition + clipPath circle animate │ │
│ └───────────────────────────────────────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────────────────┐ │
│ │ globals.css (CSS Custom Properties) │ │
│ │ :root / .dark → @theme inline → Tailwind │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
实现步骤
第一步:CSS 变量定义与 Tailwind 映射
主题切换的基础是 CSS 自定义属性。在 globals.css 中,:root 定义亮色变量,.dark 覆盖为暗色值:
:root {
--primary: #8FB2C9;
--bg-primary: #FFFEF9;
--bg-secondary: #F5F5F0;
--text-primary: #1A1A1A;
--text-secondary: #5B6570;
}
.dark {
--primary: #8FB2C9;
--bg-primary: #000000;
--bg-secondary: #111111;
--text-primary: #E8E8E8;
--text-secondary: #9CA3AF;
}
然后通过 Tailwind CSS 4 的 @theme inline 指令,将 CSS 变量映射为 Tailwind 的颜色 token:
@theme inline {
--color-primary: var(--primary);
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
}
这样组件中就可以直接使用语义化的 Tailwind class:bg-bg-primary、text-text-primary 等。当 .dark class 切换时,CSS 变量自动更新,所有使用这些变量的组件都会响应变化——无需任何 JavaScript 参与。
第二步:FOUC 闪烁预防
在 Next.js 中,React 水合是异步的。如果等到 React 渲染后再设置 .dark class,用户会先看到亮色页面再闪烁为暗色。解决方案是在根布局的 <head> 中注入一段内联脚本:
const themeScript = `
(function() {
try {
var stored = localStorage.getItem('theme');
var theme = stored;
if (theme !== 'dark' && theme !== 'light') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
`;
这段脚本在浏览器解析 HTML 时同步执行,远早于 React 水合,确保首帧渲染就是正确的主题色。同时 <html> 标签上设置了 suppressHydrationWarning,避免 React 因 class 差异报出警告。
第三步:ThemeProvider — useSyncExternalStore
主题状态管理的核心是 ThemeProvider,使用 React 18 的 useSyncExternalStore 管理外部状态:
let listeners: (() => void)[] = [];
let cachedTheme: Theme | null = null;
function getSnapshot(): Theme {
if (cachedTheme !== null) return cachedTheme;
const stored = safeGetItem("theme") as Theme | null;
cachedTheme =
stored === "light" || stored === "dark"
? stored
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
return cachedTheme;
}
function getServerSnapshot(): Theme {
return "light";
}
这里有几个关键设计决策:
| 设计点 | 原因 |
|---|---|
模块级 cachedTheme | useSyncExternalStore 在并发模式下可能多次调用 getSnapshot,缓存确保同一渲染周期内返回一致值 |
getServerSnapshot 返回 "light" | SSR 时无法访问 localStorage,返回默认值避免水合不匹配 |
safeGetItem / safeSetItem | 隐私模式下 localStorage 可能不可用,try-catch 保护 |
mounted 检查 | SSR 阶段返回 "light" 和空函数,避免水合不匹配 |
useEffect 负责将 React 状态同步到 DOM:
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
}, [theme]);
第四步:View Transitions API 圆形扩散动画
这是整个实现中最精彩的部分,也是借鉴 Element Plus 的核心。当用户点击切换按钮时,新主题从按钮位置以圆形向外扩散覆盖旧主题:
const handleClick = async () => {
const button = buttonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const doc = document as Document & {
startViewTransition?: (cb: () => void) => ViewTransition;
};
// 优雅降级:不支持 View Transitions API 时直接切换
if (!doc.startViewTransition) {
toggleTheme();
setShouldAnimate(true);
return;
}
const isCurrentlyDark = document.documentElement.classList.contains('dark');
// 禁用 CSS 过渡,避免干扰 View Transition 动画
document.documentElement.classList.add('no-transition');
try {
const transition = doc.startViewTransition(() => {
// 在回调中切换 DOM class
if (isCurrentlyDark) {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
});
await transition.ready;
// 计算圆形扩散的半径——从按钮中心到视口最远角的距离
const radius = Math.sqrt(
Math.max(x, window.innerWidth - x) ** 2 +
Math.max(y, window.innerHeight - y) ** 2
);
// 圆形 clipPath 动画
document.documentElement.animate(
{
clipPath: [
`circle(0 at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
);
await transition.finished;
// 动画完成后同步 React 状态
toggleTheme();
setShouldAnimate(true);
} finally {
requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
}
};
配合 CSS 中的 View Transition 样式:
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
opacity: 1;
z-index: 9999;
}
.no-transition *,
.no-transition *::before,
.no-transition *::after {
transition: none !important;
}
.no-transition 类非常关键——在 View Transition 动画期间,CSS 过渡(如 transition: color 200ms)会产生干扰,导致颜色渐变而非瞬间切换,破坏圆形扩散的视觉效果。
第五步:图标动画
切换按钮的图标使用 Framer Motion 实现旋转和缩放动画:
<m.div
initial={false}
animate={{
rotate: isDark ? 360 : 0,
scale: shouldAnimate ? (isDark ? [0.8, 1] : [1, 0.8]) : 1,
}}
transition={{
duration: 0.5,
ease: [0.4, 0, 0.2, 1],
}}
>
{isDark ? <MoonIcon /> : <SunIcon />}
</m.div>
踩坑记录
坑 1:ThemeToggle 中的双重状态操控
在 handleClick 中,我先在 startViewTransition 回调里手动操作了 .dark class,动画完成后再调用 toggleTheme() 更新 React 状态。这导致 DOM 和 React 状态的更新时序分离——ThemeProvider 的 useEffect 会再次尝试同步 class,可能产生竞态条件。
更理想的做法是在 startViewTransition 回调中调用 toggleTheme(),让 ThemeProvider 的 useEffect 统一负责 DOM 操作。但由于 View Transition 需要在回调中同步修改 DOM 才能捕获快照,而 React 的状态更新是异步的,这就产生了矛盾。目前的实现虽然可行,但确实存在隐患。
坑 2:模块级状态的多实例冲突
listeners 和 cachedTheme 是模块级变量。项目中前端和 Admin 各有独立的 ThemeProvider 实例,但共享同一份模块状态。两个 useEffect 都会操作 document.documentElement.classList,理论上可能互相干扰。
坑 3:CDN 图标无法响应 CSS 主题
SkillsSection 中使用的 Simple Icons CDN 图标通过 @media (prefers-color-scheme) 决定颜色,无法感知网站的自定义主题。我不得不写了一个 getThemedIconUrl 函数,手动解析 URL 并根据当前主题选择颜色:
function getThemedIconUrl(url: string, theme: 'light' | 'dark'): string {
if (!url.includes('cdn.simpleicons.org')) return url;
const urlObj = new URL(url);
const parts = urlObj.pathname.split('/').filter(Boolean);
if (parts.length < 3) return url;
const [slug, lightColor, darkColor] = parts;
const color = theme === 'dark' ? darkColor : lightColor;
urlObj.pathname = color === '_' ? `/${slug}` : `/${slug}/${color}`;
return urlObj.toString();
}
这虽然解决了问题,但每次主题切换都会触发组件重渲染和图标 URL 变更。
坑 4:MouseGlow 使用 MutationObserver 而非 Context
MouseGlow 组件通过 MutationObserver 监听 document.documentElement 的 class 变化来感知主题切换,而非直接使用 useTheme() hook。这是因为 MouseGlow 需要在主题切换的瞬间做出响应,而 React 的状态更新可能有延迟。但从架构角度看,这增加了对 DOM 结构的依赖。
坑 5:PWA Manifest 仅适配亮色主题
manifest.ts 中 theme_color 和 background_color 硬编码为亮色值,暗色模式下移动端的状态栏颜色不匹配。Web Manifest 规范目前不支持基于媒体查询的动态主题色,这是一个已知的平台限制。
坑 6:错误页面的主题适配
global-error.tsx 在 React 树之外渲染,无法使用 ThemeProvider。我不得不使用内联样式硬编码亮色值,再通过脚本检测暗色模式后覆盖样式。这种方式脆弱且无法复用 CSS 变量体系。
优点总结
| 优点 | 说明 |
|---|---|
| FOUC 预防 | 内联脚本确保首帧渲染正确 |
| View Transitions 动画 | 圆形扩散效果,优雅降级 |
| useSyncExternalStore | 并发安全,避免 tearing |
| 模块级缓存 | 避免重复读取 localStorage |
| CSS 变量 + Tailwind 映射 | 语义化 class,维护方便 |
| prefers-color-scheme | 尊重系统偏好 |
| prefers-reduced-motion | 尊重用户动效偏好 |
| i18n 支持 | 切换按钮标签国际化 |
改进方向
- 统一主题操控入口:让 ThemeProvider 成为唯一操作 DOM class 的地方,ThemeToggle 通过回调触发状态变更
- 单例化 DOM 同步:将
useEffect中的 DOM 操作提取到模块级,避免多实例冲突 - 添加系统偏好监听:监听
matchMedia('(prefers-color-scheme: dark)')的change事件 - 统一主题方案:全部使用 CSS 变量,移除
dark:Tailwind 前缀的混用 - Firefox 降级动画:为不支持 View Transitions API 的浏览器提供 CSS transition 降级
总结
借鉴 Element Plus 的主题切换实现,让我深入理解了 View Transitions API 的强大之处——只需几行代码就能实现过去需要复杂 Canvas 或 CSS 动画才能做到的效果。同时,React 18 的 useSyncExternalStore 为外部状态管理提供了更安全的方式。
但在实践中,React 的异步状态更新与 View Transitions API 的同步 DOM 操作要求之间存在天然矛盾,需要仔细处理时序问题。CSS 变量与 Tailwind 的集成虽然优雅,但 CDN 资源、PWA Manifest、错误页面等边缘场景仍然需要额外的适配工作。
主题切换看似简单,实则涉及 SSR、并发模式、浏览器 API 兼容性、无障碍等多个维度。希望这篇文章能为正在实现类似功能的开发者提供一些参考。