返回博客列表
基

基于 View Transitions API 的主题切换:借鉴 Element Plus 的实践与反思

2026年4月24日·10 分钟阅读
View Transitions APIDark ModeCSS Variables

目录

  • 引子
  • 核心架构
  • 实现步骤
  • 第一步:CSS 变量定义与 Tailwind 映射
  • 第二步:FOUC 闪烁预防
  • 第三步:ThemeProvider — useSyncExternalStore
  • 第四步:View Transitions API 圆形扩散动画
  • 第五步:图标动画
  • 踩坑记录
  • 坑 1:ThemeToggle 中的双重状态操控
  • 坑 2:模块级状态的多实例冲突
  • 坑 3:CDN 图标无法响应 CSS 主题
  • 坑 4:MouseGlow 使用 MutationObserver 而非 Context
  • 坑 5:PWA Manifest 仅适配亮色主题
  • 坑 6:错误页面的主题适配
  • 优点总结
  • 改进方向
  • 总结

目录

  • 引子
  • 核心架构
  • 实现步骤
  • 第一步:CSS 变量定义与 Tailwind 映射
  • 第二步:FOUC 闪烁预防
  • 第三步:ThemeProvider — useSyncExternalStore
  • 第四步:View Transitions API 圆形扩散动画
  • 第五步:图标动画
  • 踩坑记录
  • 坑 1:ThemeToggle 中的双重状态操控
  • 坑 2:模块级状态的多实例冲突
  • 坑 3:CDN 图标无法响应 CSS 主题
  • 坑 4:MouseGlow 使用 MutationObserver 而非 Context
  • 坑 5:PWA Manifest 仅适配亮色主题
  • 坑 6:错误页面的主题适配
  • 优点总结
  • 改进方向
  • 总结

引子

最近在开发个人网站时,被 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";
}

这里有几个关键设计决策:

设计点原因
模块级 cachedThemeuseSyncExternalStore 在并发模式下可能多次调用 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 支持切换按钮标签国际化

改进方向

  1. 统一主题操控入口:让 ThemeProvider 成为唯一操作 DOM class 的地方,ThemeToggle 通过回调触发状态变更
  2. 单例化 DOM 同步:将 useEffect 中的 DOM 操作提取到模块级,避免多实例冲突
  3. 添加系统偏好监听:监听 matchMedia('(prefers-color-scheme: dark)') 的 change 事件
  4. 统一主题方案:全部使用 CSS 变量,移除 dark: Tailwind 前缀的混用
  5. 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 兼容性、无障碍等多个维度。希望这篇文章能为正在实现类似功能的开发者提供一些参考。