Theme 全局换肤
全局换肤系统支持 light、dark、system 三种主题模式,完全兼容 SSR,避免主题切换时的闪烁。
特性
- ✅ 支持 light、dark、system 三种主题模式
- ✅ SSR 兼容,避免闪烁(FOUC)
- ✅ localStorage 持久化
- ✅ 系统主题自动检测
- ✅ 平滑过渡动画
- ✅ 基于 CSS 变量,易于定制
基础用法
使用 ThemeProvider 包裹应用,使用 useTheme Hook 获取主题状态。
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 媒体查询,自动跟随系统主题。
当前主题状态
API
ThemeProvider Props
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| defaultTheme | 默认主题 | 'light' | 'dark' | 'system' | 'system' |
| storageKey | 主题存储的 key | string | 'theme' |
| disableSystemTheme | 是否禁用系统主题检测 | boolean | false |
| children | 子元素 | React.ReactNode | - |
useTheme Hook
| 返回值 | 说明 | 类型 |
|---|---|---|
| theme | 当前主题设置 | 'light' | 'dark' | 'system' |
| resolvedTheme | 实际应用的主题 | 'light' | 'dark' |
| setTheme | 设置主题 | (theme: Theme) => void |
ThemeToggle Props
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| showLabel | 是否显示文本标签 | boolean | false |
| className | 自定义类名 | string | - |
注意事项
- 必须在
ThemeProvider内使用useThemeHook - Next.js 需要配置 SSR 安全脚本,避免主题闪烁
- 主题切换会立即生效,无需刷新页面
- 系统主题模式下,会跟随系统设置自动切换
- CSS 变量可以在全局样式中覆盖,实现自定义主题