Theme 全局换肤

全局换肤系统支持 light、dark、system 三种主题模式,完全兼容 SSR,避免主题切换时的闪烁。

特性

  • ✅ 支持 light、dark、system 三种主题模式
  • ✅ SSR 兼容,避免闪烁(FOUC)
  • ✅ localStorage 持久化
  • ✅ 系统主题自动检测
  • ✅ 平滑过渡动画
  • ✅ 基于 CSS 变量,易于定制

基础用法

使用 ThemeProvider 包裹应用,使用 useTheme Hook 获取主题状态。

当前主题:system
实际主题:light
import { ThemeProvider, useTheme, ThemeToggle } from '@enterprise-ui/react19';

function App() {
  return (
    <ThemeProvider>
      <YourApp />
    </ThemeProvider>
  );
}

function YourApp() {
  const { theme, setTheme } = useTheme();
  
  return (
    <div>
      <ThemeToggle />
      <button onClick={() => setTheme('dark')}>深色</button>
      <button onClick={() => setTheme('light')}>浅色</button>
      <button onClick={() => setTheme('system')}>系统</button>
    </div>
  );
}

主题切换组件

使用 ThemeToggle 组件快速切换主题。

import { ThemeToggle } from '@enterprise-ui/react19';

<ThemeToggle showLabel /> // 显示文本标签

Next.js App Router 集成

在 Next.js App Router 中使用,需要配置 SSR 安全脚本。

// app/layout.tsx
import { ThemeProvider } from '@enterprise-ui/react19';
import Script from 'next/script';
import '@enterprise-ui/react19/styles';

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        {/* 防止闪烁 */}
        <Script
          id="theme-script"
          strategy="beforeInteractive"
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                const theme = localStorage.getItem('theme') || 'system';
                const resolved = theme === 'system' 
                  ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
                  : theme;
                document.documentElement.setAttribute('data-theme', resolved);
              })();
            `,
          }}
        />
      </head>
      <body>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Next.js Pages Router 集成

// pages/_app.tsx
import { ThemeProvider } from '@enterprise-ui/react19';
import '@enterprise-ui/react19/styles';
import type { AppProps } from 'next/app';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider>
      <Component {...pageProps} />
    </ThemeProvider>
  );
}

// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html>
      <Head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                const theme = localStorage.getItem('theme') || 'system';
                const resolved = theme === 'system' 
                  ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
                  : theme;
                document.documentElement.setAttribute('data-theme', resolved);
              })();
            `,
          }}
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

自定义主题

主题基于 CSS 变量实现,可以通过覆盖变量来自定义主题。

/* 自定义主题变量 */
:root,
[data-theme='light'] {
  --color-primary: #your-color;
  --color-bg: #your-bg-color;
  --color-text: #your-text-color;
}

[data-theme='dark'] {
  --color-primary: #your-dark-color;
  --color-bg: #your-dark-bg-color;
  --color-text: #your-dark-text-color;
}

实现原理详解

1. CSS 变量系统

主题系统基于 CSS 变量(Custom Properties)实现,这是最灵活和性能最好的方案。

CSS 变量定义:

/* 定义主题变量 */
:root,
[data-theme='light'] {
  --color-primary: #3b82f6;
  --color-bg: #ffffff;
  --color-text: #111827;
  --color-border: #e5e7eb;
}

[data-theme='dark'] {
  --color-primary: #60a5fa;
  --color-bg: #111827;
  --color-text: #f9fafb;
  --color-border: #374151;
}

/* 使用变量 */
.button {
  background-color: var(--color-primary);
  color: var(--color-text);
}

为什么使用 CSS 变量:

  • 性能优秀:浏览器原生支持,无需 JavaScript 计算
  • 易于维护:集中管理颜色,修改方便
  • 支持过渡:CSS transition 可以平滑切换
  • 作用域控制:可以通过选择器控制变量作用域

2. DOM 属性切换机制

通过设置 data-theme 属性切换主题,CSS 选择器根据该属性生效。

切换机制:

// JavaScript 切换主题
function setTheme(theme: 'light' | 'dark') {
  const root = document.documentElement;
  
  // 移除旧主题类
  root.classList.remove('light', 'dark');
  
  // 设置 data-theme 属性
  root.setAttribute('data-theme', theme);
  
  // 添加主题类(可选,用于更细粒度控制)
  root.classList.add(theme);
  
  // CSS 自动生效
  // [data-theme='dark'] { ... }
}

属性选择器优势:

  • 优先级高:属性选择器优先级高于类选择器
  • 语义清晰data-theme 明确表示主题属性
  • 易于调试:可以在 DevTools 中直接看到和修改

3. localStorage 持久化

主题设置需要持久化,避免刷新页面后丢失。使用 localStorage 存储。

存储实现:

// 保存主题
function saveTheme(theme: Theme) {
  try {
    localStorage.setItem('theme', theme);
  } catch (e) {
    // localStorage 可能不可用(隐私模式等)
    console.warn('Failed to save theme:', e);
  }
}

// 读取主题
function loadTheme(): Theme {
  try {
    const stored = localStorage.getItem('theme');
    if (stored && ['light', 'dark', 'system'].includes(stored)) {
      return stored as Theme;
    }
  } catch (e) {
    console.warn('Failed to load theme:', e);
  }
  return 'system'; // 默认值
}

错误处理:

  • try-catch:localStorage 在某些情况下可能不可用
  • 默认值:读取失败时使用默认主题
  • 类型检查:确保存储的值有效

4. SSR 安全实现(防止闪烁)

SSR 环境下,服务端无法访问 localStorage,如果不在 HTML 渲染前设置主题,会出现闪烁(FOUC - Flash of Unstyled Content)。

4.1 问题根源:浏览器渲染流程

浏览器的渲染流程是:HTML 解析 → CSS 应用 → 页面渲染 → JavaScript 执行

如果主题设置在 JavaScript 中(如 useEffect),执行时机太晚:

// ❌ 错误方式:在 React 中读取 localStorage
function ThemeProvider() {
  useEffect(() => {
    // 问题:这会在以下时机执行:
    // 1. HTML 解析完成
    // 2. CSS 应用完成(使用默认主题)
    // 3. 页面已经渲染(显示默认主题)
    // 4. React 水合完成
    // 5. 此时才执行 useEffect ← 太晚了!
    const theme = localStorage.getItem('theme');
    setTheme(theme);
    document.documentElement.setAttribute('data-theme', theme);
    // 6. CSS 重新计算,页面突然变色 ← 闪烁!
  }, []);
}

// 时间线:
// T0: HTML 发送(data-theme 不存在或为默认值)
// T1: CSS 应用(使用默认主题 light)
// T2: 页面渲染(显示浅色)← 用户看到
// T3: JavaScript 执行
// T4: useEffect 执行,设置 dark
// T5: CSS 重新计算,页面变深色 ← 闪烁!

4.2 解决方案:beforeInteractive Script

// ✅ 正确方式:在 HTML 头部注入脚本
<Script
  id="theme-script"
  strategy="beforeInteractive"  // 关键:在页面渲染前执行
  dangerouslySetInnerHTML={{
    __html: `
      (function() {
        // 立即执行函数(IIFE),不等待其他脚本
        const theme = localStorage.getItem('theme') || 'system';
        const resolved = theme === 'system' 
          ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
          : theme;
        // 在 document.documentElement(<html>)上设置属性
        document.documentElement.setAttribute('data-theme', resolved);
      })();
    `,
  }}
/>

// 时间线:
// T0: HTML 发送(包含内联脚本)
// T1: 浏览器解析到 <script> 标签
// T2: **立即执行脚本**(beforeInteractive,阻塞式)
// T3: 读取 localStorage,设置 data-theme="dark"
// T4: CSS 应用(基于 data-theme="dark")
// T5: 页面渲染(直接显示深色)← 无闪烁!
// T6: JavaScript 执行,React 水合(主题已正确)

4.3 为什么能防止闪烁?深入解析

4.3.1 beforeInteractive 策略的作用

beforeInteractive 是 Next.js 的特殊策略,它确保脚本在以下时机执行:

  • HTML 解析时:浏览器解析到脚本标签立即执行
  • 页面渲染前:在首次内容绘制(FCP)之前
  • 阻塞式执行:同步执行,阻塞后续 HTML 解析
// Next.js 会将 beforeInteractive 脚本注入到 <head> 中:
<head>
  <script id="theme-script">
    (function() {
      // 这段代码会在页面任何内容渲染前执行
      document.documentElement.setAttribute('data-theme', 'dark');
    })();
  </script>
</head>
<body>
  <!-- 此时 data-theme 已经设置好了 -->
</body>

// Next.js Script 策略对比:
// beforeInteractive: HTML 解析时,在页面渲染前执行(阻塞)
// afterInteractive: 页面交互就绪后执行(不阻塞)
// lazyOnload: 页面完全加载后执行(不阻塞)
4.3.2 立即执行函数(IIFE)的作用
(function() {
  // 代码
})();

// 为什么使用 IIFE?
// 1. 立即执行:不等待其他脚本,立即运行
// 2. 作用域隔离:避免污染全局作用域
// 3. 同步执行:阻塞式执行,确保在渲染前完成

// 对比:
// ❌ 不使用 IIFE:可能延迟执行
const theme = localStorage.getItem('theme');
// 如果这段代码在异步上下文中,可能延迟

// ✅ 使用 IIFE:立即执行
(function() {
  const theme = localStorage.getItem('theme');
  // 立即执行,不等待
})();
4.3.3 document.documentElement 的作用
// document.documentElement = <html> 元素
document.documentElement.setAttribute('data-theme', 'dark');
// 等同于:<html data-theme="dark">

// 为什么在 <html> 上设置?
// 1. CSS 选择器匹配:[data-theme='dark'] 匹配的是 <html>
// 2. CSS 变量作用域:在 <html> 上定义,所有子元素都能访问
// 3. 全局生效:一次设置,整个页面生效

// CSS 变量作用域:
[data-theme='dark'] {
  --color-bg: #111827;  /* 在 <html> 上定义 */
}

body {
  background-color: var(--color-bg);  /* 子元素可以使用 */
}
4.3.4 完整的执行流程对比
错误方式(useEffect):
时间线:
T0: HTML 发送到浏览器(data-theme 不存在)
T1: 浏览器解析 HTML
T2: CSS 应用(使用默认主题 light)
T3: 页面首次渲染(FCP)- 显示浅色 ← 用户看到
T4: JavaScript 文件下载
T5: React 开始水合
T6: useEffect 执行
T7: 读取 localStorage,发现是 dark
T8: 设置 data-theme="dark"
T9: CSS 重新计算
T10: 页面重新渲染 - 显示深色 ← 闪烁!(延迟 200ms+)
正确方式(beforeInteractive):
时间线:
T0: HTML 发送到浏览器(包含内联脚本)
T1: 浏览器解析 HTML,遇到 <script>
T2: **立即执行脚本**(beforeInteractive,阻塞式)
T3: 读取 localStorage,设置 data-theme="dark"
T4: 继续解析 HTML
T5: CSS 应用(基于 data-theme="dark")
T6: 页面首次渲染(FCP)- 显示深色 ← 无闪烁!
T7: JavaScript 文件下载
T8: React 水合(主题已正确,无需切换)
4.3.5 为什么必须在 HTML 头部?
<head>
  <script>...</script>  ← 在这里执行
</head>
<body>
  <!-- 内容 -->
</body>

// 原因:
// 1. <head> 中的脚本在 <body> 解析前执行
// 2. 确保在页面内容渲染前设置主题
// 3. 如果放在 <body> 底部,仍然会闪烁
4.3.6 为什么使用内联脚本?
dangerouslySetInnerHTML={{
  __html: `...`  // 内联脚本
}}

// 原因:
// 1. 同步执行:内联脚本立即执行,不等待下载
// 2. 无网络延迟:不需要请求外部文件
// 3. 执行顺序可控:确保在其他脚本前执行

// 对比:
// ❌ 外部脚本:需要下载,可能延迟
<script src="/theme.js"></script>

// ✅ 内联脚本:立即执行
<script>
  (function() { ... })();
</script>

4.4 关键点总结

关键点作用为什么重要
strategy="beforeInteractive"在页面渲染前执行确保主题在首次渲染前设置
内联脚本(IIFE)立即执行,无延迟不等待网络请求,同步执行
document.documentElement在 <html> 上设置属性CSS 选择器匹配,全局生效
CSS 变量基于 data-theme 切换浏览器原生支持,性能好

4.5 核心思想

在浏览器渲染任何内容之前,就设置好主题属性,这样 CSS 应用时就是正确的主题,页面首次渲染就是正确的颜色,用户看不到闪烁。

这就是为什么这个 Script 能够完美解决 SSR 主题闪烁问题的原因。

5. 系统主题检测

支持跟随系统主题,需要监听 prefers-color-scheme 媒体查询。

检测实现:

// 1. 获取系统主题
function getSystemTheme(): 'light' | 'dark' {
  if (typeof window === 'undefined') {
    return 'light'; // SSR 默认值
  }
  
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
}

// 2. 监听系统主题变化
useEffect(() => {
  if (theme !== 'system') return;
  
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  
  const handleChange = (e: MediaQueryListEvent) => {
    const resolved = e.matches ? 'dark' : 'light';
    setResolvedTheme(resolved);
    applyTheme('system'); // 重新应用系统主题
  };
  
  // 现代浏览器
  if (mediaQuery.addEventListener) {
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }
  // 兼容旧浏览器
  else if (mediaQuery.addListener) {
    mediaQuery.addListener(handleChange);
    return () => mediaQuery.removeListener(handleChange);
  }
}, [theme]);

媒体查询优势:

  • 原生支持:浏览器原生 API,性能好
  • 实时响应:系统主题变化时自动触发
  • 无需轮询:事件驱动,效率高

6. 主题切换动画

主题切换时可以添加过渡动画,提升用户体验。

CSS 过渡实现:

/* 为颜色属性添加过渡 */
:root {
  --transition-duration: 0.3s;
}

/* 需要过渡的元素 */
.button,
.card,
.bg {
  transition: 
    background-color var(--transition-duration) ease,
    color var(--transition-duration) ease,
    border-color var(--transition-duration) ease;
}

/* 或者全局过渡(谨慎使用,可能影响性能) */
* {
  transition: background-color 0.3s ease, color 0.3s ease;
}

性能考虑:

  • 只过渡颜色:避免过渡布局属性(width、height 等)
  • 使用 transform:transform 和 opacity 性能最好
  • 避免全局过渡:只对需要的元素添加过渡

7. React Context 状态管理

使用 React Context 管理主题状态,提供全局访问。

Context 实现:

// 创建 Context
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

// Provider 组件
function ThemeProvider({ children, defaultTheme }) {
  const [theme, setThemeState] = useState(defaultTheme);
  const [resolvedTheme, setResolvedTheme] = useState('light');
  
  // 设置主题函数
  const setTheme = useCallback((newTheme: Theme) => {
    setThemeState(newTheme);
    applyTheme(newTheme);
    saveTheme(newTheme);
  }, []);
  
  return (
    <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Hook
function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

设计优势:

  • 类型安全:TypeScript 完整支持
  • 错误提示:未在 Provider 内使用时抛出错误
  • 性能优化:使用 useCallback 避免不必要的重渲染

工作原理

1. 主题存储

主题设置存储在 localStorage 中,key 默认为 'theme',可以通过 storageKey 自定义。

2. DOM 属性切换

通过设置 data-theme 属性切换主题,CSS 变量根据该属性生效。

3. SSR 安全

在 HTML 头部注入脚本(beforeInteractive),在页面渲染前设置主题,避免闪烁。

4. 系统主题检测

监听 prefers-color-scheme 媒体查询,自动跟随系统主题。

当前主题状态

当前主题:system
实际主题:light

API

ThemeProvider Props

参数说明类型默认值
defaultTheme默认主题'light' | 'dark' | 'system''system'
storageKey主题存储的 keystring'theme'
disableSystemTheme是否禁用系统主题检测booleanfalse
children子元素React.ReactNode-

useTheme Hook

返回值说明类型
theme当前主题设置'light' | 'dark' | 'system'
resolvedTheme实际应用的主题'light' | 'dark'
setTheme设置主题(theme: Theme) => void

ThemeToggle Props

参数说明类型默认值
showLabel是否显示文本标签booleanfalse
className自定义类名string-

注意事项

  • 必须在 ThemeProvider 内使用 useTheme Hook
  • Next.js 需要配置 SSR 安全脚本,避免主题闪烁
  • 主题切换会立即生效,无需刷新页面
  • 系统主题模式下,会跟随系统设置自动切换
  • CSS 变量可以在全局样式中覆盖,实现自定义主题