全局换肤系统支持 light、dark、system 三种主题模式,完全兼容 SSR,避免主题切换时的闪烁。
使用 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 中使用,需要配置 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>
);
}// 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;
}主题系统基于 CSS 变量(Custom Properties)实现,这是最灵活和性能最好的方案。
/* 定义主题变量 */
: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);
}通过设置 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 明确表示主题属性主题设置需要持久化,避免刷新页面后丢失。使用 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'; // 默认值
}SSR 环境下,服务端无法访问 localStorage,如果不在 HTML 渲染前设置主题,会出现闪烁(FOUC - Flash of Unstyled Content)。
浏览器的渲染流程是: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 重新计算,页面变深色 ← 闪烁!// ✅ 正确方式:在 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 水合(主题已正确)beforeInteractive 是 Next.js 的特殊策略,它确保脚本在以下时机执行:
// 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: 页面完全加载后执行(不阻塞)(function() {
// 代码
})();
// 为什么使用 IIFE?
// 1. 立即执行:不等待其他脚本,立即运行
// 2. 作用域隔离:避免污染全局作用域
// 3. 同步执行:阻塞式执行,确保在渲染前完成
// 对比:
// ❌ 不使用 IIFE:可能延迟执行
const theme = localStorage.getItem('theme');
// 如果这段代码在异步上下文中,可能延迟
// ✅ 使用 IIFE:立即执行
(function() {
const theme = localStorage.getItem('theme');
// 立即执行,不等待
})();// 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); /* 子元素可以使用 */
}时间线:
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+)时间线:
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 水合(主题已正确,无需切换)<head>
<script>...</script> ← 在这里执行
</head>
<body>
<!-- 内容 -->
</body>
// 原因:
// 1. <head> 中的脚本在 <body> 解析前执行
// 2. 确保在页面内容渲染前设置主题
// 3. 如果放在 <body> 底部,仍然会闪烁dangerouslySetInnerHTML={{
__html: `...` // 内联脚本
}}
// 原因:
// 1. 同步执行:内联脚本立即执行,不等待下载
// 2. 无网络延迟:不需要请求外部文件
// 3. 执行顺序可控:确保在其他脚本前执行
// 对比:
// ❌ 外部脚本:需要下载,可能延迟
<script src="/theme.js"></script>
// ✅ 内联脚本:立即执行
<script>
(function() { ... })();
</script>| 关键点 | 作用 | 为什么重要 |
|---|---|---|
strategy="beforeInteractive" | 在页面渲染前执行 | 确保主题在首次渲染前设置 |
| 内联脚本(IIFE) | 立即执行,无延迟 | 不等待网络请求,同步执行 |
document.documentElement | 在 <html> 上设置属性 | CSS 选择器匹配,全局生效 |
| CSS 变量 | 基于 data-theme 切换 | 浏览器原生支持,性能好 |
在浏览器渲染任何内容之前,就设置好主题属性,这样 CSS 应用时就是正确的主题,页面首次渲染就是正确的颜色,用户看不到闪烁。
这就是为什么这个 Script 能够完美解决 SSR 主题闪烁问题的原因。
支持跟随系统主题,需要监听 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]);主题切换时可以添加过渡动画,提升用户体验。
/* 为颜色属性添加过渡 */
: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;
}使用 React 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 = (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;
}主题设置存储在 localStorage 中,key 默认为 'theme',可以通过 storageKey 自定义。
通过设置 data-theme 属性切换主题,CSS 变量根据该属性生效。
在 HTML 头部注入脚本(beforeInteractive),在页面渲染前设置主题,避免闪烁。
监听 prefers-color-scheme 媒体查询,自动跟随系统主题。
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| defaultTheme | 默认主题 | 'light' | 'dark' | 'system' | 'system' |
| storageKey | 主题存储的 key | string | 'theme' |
| disableSystemTheme | 是否禁用系统主题检测 | boolean | false |
| children | 子元素 | React.ReactNode | - |
| 返回值 | 说明 | 类型 |
|---|---|---|
| theme | 当前主题设置 | 'light' | 'dark' | 'system' |
| resolvedTheme | 实际应用的主题 | 'light' | 'dark' |
| setTheme | 设置主题 | (theme: Theme) => void |
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| showLabel | 是否显示文本标签 | boolean | false |
| className | 自定义类名 | string | - |
ThemeProvider 内使用 useTheme Hook