教程 zustand tutorial

案例 2:多主题切换引擎

在现代 Web 应用中,提供多主题切换功能已成为提升用户体验的重要特性。本案例将使用 Zustand 实现一个功能完整的多主题切换引擎,支持亮色/暗色主题切换、自定义主题配置以及主题持久化。

1. 主题引擎设计思路

1.1 核心功能需求

  • 支持亮色、暗色两种默认主题
  • 允许用户自定义主题颜色
  • 主题配置持久化存储
  • 实时主题切换,无需页面刷新
  • 支持嵌套主题和局部主题覆盖

1.2 Store 设计方案

使用 Zustand 创建主题管理 Store,集成 Persist 中间件实现主题配置持久化,使用 Immer 简化状态更新逻辑。

2. 实现步骤

2.1 安装必要依赖

bash
npm install zustand immer zustand/middleware

2.2 创建主题 Store

javascript
// src/stores/themeStore.js
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

// 定义默认主题
const defaultThemes = {
  light: {
    name: 'light',
    colors: {
      primary: '#3b82f6',
      background: '#ffffff',
      surface: '#f3f4f6',
      text: '#1f2937',
      textSecondary: '#6b7280',
      border: '#e5e7eb',
      error: '#ef4444'
    }
  },
  dark: {
    name: 'dark',
    colors: {
      primary: '#60a5fa',
      background: '#111827',
      surface: '#1f2937',
      text: '#f9fafb',
      textSecondary: '#d1d5db',
      border: '#374151',
      error: '#f87171'
    }
  }
}

// 创建主题 Store
export const useThemeStore = create(
  persist(
    immer((set, get) => ({
      // 当前主题
      currentTheme: 'light',
      // 主题配置
      themes: { ...defaultThemes },
      // 自定义主题
      customThemes: {},

      // 切换主题
      toggleTheme: () => {
        set(state => {
          state.currentTheme = state.currentTheme === 'light' ? 'dark' : 'light'
        })
      },

      // 切换到指定主题
      setTheme: (themeName) => {
        set(state => {
          if (state.themes[themeName] || state.customThemes[themeName]) {
            state.currentTheme = themeName
          }
        })
      },

      // 更新主题颜色
      updateThemeColors: (themeName, colors) => {
        set(state => {
          const targetTheme = state.themes[themeName] || state.customThemes[themeName]
          if (targetTheme) {
            Object.assign(targetTheme.colors, colors)
          }
        })
      },

      // 创建自定义主题
      createCustomTheme: (themeName, colors) => {
        set(state => {
          state.customThemes[themeName] = {
            name: themeName,
            colors: { ...defaultThemes.light.colors, ...colors }
          }
        })
      },

      // 删除自定义主题
      deleteCustomTheme: (themeName) => {
        set(state => {
          delete state.customThemes[themeName]
          // 如果当前正在使用被删除的主题,切换到默认主题
          if (state.currentTheme === themeName) {
            state.currentTheme = 'light'
          }
        })
      },

      // 获取当前主题配置
      getCurrentThemeConfig: () => {
        const { currentTheme, themes, customThemes } = get()
        return themes[currentTheme] || customThemes[currentTheme]
      },

      // 获取所有可用主题名称
      getAllThemeNames: () => {
        const { themes, customThemes } = get()
        return [...Object.keys(themes), ...Object.keys(customThemes)]
      }
    })),
    {
      name: 'theme-storage',
      storage: createJSONStorage(() => localStorage)
    }
  )
)

2.3 创建主题上下文提供者

javascript
// src/components/ThemeProvider.jsx
import React, { useEffect } from 'react'
import { useThemeStore } from '../stores/themeStore'

export const ThemeProvider = ({ children }) => {
  const currentThemeConfig = useThemeStore(state => state.getCurrentThemeConfig())

  useEffect(() => {
    // 应用主题到根元素
    const root = document.documentElement
    
    // 清除旧的主题变量
    root.style.setProperty('--color-primary', '')
    root.style.setProperty('--color-background', '')
    root.style.setProperty('--color-surface', '')
    root.style.setProperty('--color-text', '')
    root.style.setProperty('--color-text-secondary', '')
    root.style.setProperty('--color-border', '')
    root.style.setProperty('--color-error', '')

    // 设置新的主题变量
    root.style.setProperty('--color-primary', currentThemeConfig.colors.primary)
    root.style.setProperty('--color-background', currentThemeConfig.colors.background)
    root.style.setProperty('--color-surface', currentThemeConfig.colors.surface)
    root.style.setProperty('--color-text', currentThemeConfig.colors.text)
    root.style.setProperty('--color-text-secondary', currentThemeConfig.colors.textSecondary)
    root.style.setProperty('--color-border', currentThemeConfig.colors.border)
    root.style.setProperty('--color-error', currentThemeConfig.colors.error)

    // 更新根元素的主题类名
    root.className = currentThemeConfig.name
  }, [currentThemeConfig])

  return <>{children}</>
}

2.4 创建主题切换组件

javascript
// src/components/ThemeSwitcher.jsx
import React from 'react'
import { useThemeStore } from '../stores/themeStore'

const ThemeSwitcher = () => {
  const {
    currentTheme,
    toggleTheme,
    setTheme,
    getAllThemeNames
  } = useThemeStore()

  const themeNames = getAllThemeNames()

  return (
    <div className="theme-switcher">
      <button 
        onClick={toggleTheme} 
        className="theme-toggle-btn"
        aria-label="切换主题"
      >
        {currentTheme === 'light' ? '🌙' : '☀️'}
      </button>
      
      <select 
        value={currentTheme} 
        onChange={(e) => setTheme(e.target.value)}
        className="theme-select"
      >
        {themeNames.map(themeName => (
          <option key={themeName} value={themeName}>
            {themeName === 'light' ? '亮色主题' : themeName === 'dark' ? '暗色主题' : themeName}
          </option>
        ))}
      </select>
    </div>
  )
}

export default ThemeSwitcher

2.5 创建自定义主题编辑器

javascript
// src/components/ThemeEditor.jsx
import React, { useState } from 'react'
import { useThemeStore } from '../stores/themeStore'

const ThemeEditor = () => {
  const { createCustomTheme, updateThemeColors } = useThemeStore()
  const [themeName, setThemeName] = useState('')
  const [colors, setColors] = useState({
    primary: '#3b82f6',
    background: '#ffffff',
    surface: '#f3f4f6',
    text: '#1f2937'
  })

  const handleColorChange = (colorName, value) => {
    setColors(prev => ({ ...prev, [colorName]: value }))
  }

  const handleCreateTheme = () => {
    if (themeName.trim()) {
      createCustomTheme(themeName, colors)
      setThemeName('')
      // 重置为默认颜色
      setColors({
        primary: '#3b82f6',
        background: '#ffffff',
        surface: '#f3f4f6',
        text: '#1f2937'
      })
    }
  }

  return (
    <div className="theme-editor">
      <h3>自定义主题</h3>
      <input
        type="text"
        placeholder="主题名称"
        value={themeName}
        onChange={(e) => setThemeName(e.target.value)}
        className="theme-name-input"
      />
      
      <div className="color-pickers">
        <div className="color-picker-group">
          <label>主色调:</label>
          <input
            type="color"
            value={colors.primary}
            onChange={(e) => handleColorChange('primary', e.target.value)}
          />
        </div>
        
        <div className="color-picker-group">
          <label>背景色:</label>
          <input
            type="color"
            value={colors.background}
            onChange={(e) => handleColorChange('background', e.target.value)}
          />
        </div>
        
        <div className="color-picker-group">
          <label>表面色:</label>
          <input
            type="color"
            value={colors.surface}
            onChange={(e) => handleColorChange('surface', e.target.value)}
          />
        </div>
        
        <div className="color-picker-group">
          <label>文字色:</label>
          <input
            type="color"
            value={colors.text}
            onChange={(e) => handleColorChange('text', e.target.value)}
          />
        </div>
      </div>
      
      <button 
        onClick={handleCreateTheme} 
        disabled={!themeName.trim()}
        className="create-theme-btn"
      >
        创建主题
      </button>
    </div>
  )
}

export default ThemeEditor

2.6 创建样式文件

css
/* src/styles/theme.css */
:root {
  /* 默认亮色主题变量 */
  --color-primary: #3b82f6;
  --color-background: #ffffff;
  --color-surface: #f3f4f6;
  --color-text: #1f2937;
  --color-text-secondary: #6b7280;
  --color-border: #e5e7eb;
  --color-error: #ef4444;
}

/* 暗色主题类 */
.dark {
  color-scheme: dark;
}

/* 主题切换器样式 */
.theme-switcher {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 1rem;
  background-color: var(--color-surface);
  border-radius: 8px;
  border: 1px solid var(--color-border);
}

.theme-toggle-btn {
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  padding: 0.5rem;
  border-radius: 4px;
  transition: background-color 0.2s;
}

.theme-toggle-btn:hover {
  background-color: var(--color-border);
}

.theme-select {
  padding: 0.5rem;
  border: 1px solid var(--color-border);
  border-radius: 4px;
  background-color: var(--color-background);
  color: var(--color-text);
  cursor: pointer;
}

/* 主题编辑器样式 */
.theme-editor {
  padding: 1.5rem;
  background-color: var(--color-surface);
  border-radius: 8px;
  border: 1px solid var(--color-border);
  max-width: 500px;
  margin: 1rem 0;
}

.theme-editor h3 {
  margin-top: 0;
  margin-bottom: 1rem;
  color: var(--color-text);
}

.theme-name-input {
  width: 100%;
  padding: 0.75rem;
  margin-bottom: 1rem;
  border: 1px solid var(--color-border);
  border-radius: 4px;
  background-color: var(--color-background);
  color: var(--color-text);
}

.color-pickers {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  margin-bottom: 1rem;
}

.color-picker-group {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.color-picker-group label {
  color: var(--color-text-secondary);
  font-size: 0.875rem;
}

.color-picker-group input[type="color"] {
  width: 100%;
  height: 40px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.create-theme-btn {
  width: 100%;
  padding: 0.75rem;
  background-color: var(--color-primary);
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: background-color 0.2s;
}

.create-theme-btn:hover:not(:disabled) {
  opacity: 0.9;
}

.create-theme-btn:disabled {
  background-color: var(--color-border);
  cursor: not-allowed;
  opacity: 0.7;
}

/* 主题预览样式 */
.theme-preview {
  margin-top: 2rem;
  padding: 1.5rem;
  background-color: var(--color-surface);
  border-radius: 8px;
  border: 1px solid var(--color-border);
}

.theme-preview h3 {
  color: var(--color-text);
  margin-top: 0;
  margin-bottom: 1rem;
}

.preview-card {
  background-color: var(--color-background);
  padding: 1.5rem;
  border-radius: 8px;
  border: 1px solid var(--color-border);
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}

.preview-card h4 {
  color: var(--color-text);
  margin-top: 0;
  margin-bottom: 0.5rem;
}

.preview-card p {
  color: var(--color-text-secondary);
  margin: 0;
}

2.7 在应用中集成主题系统

javascript
// src/App.jsx
import React from 'react'
import { ThemeProvider } from './components/ThemeProvider'
import ThemeSwitcher from './components/ThemeSwitcher'
import ThemeEditor from './components/ThemeEditor'
import './styles/theme.css'

function App() {
  return (
    <ThemeProvider>
      <div className="app">
        <header className="app-header">
          <h1>多主题切换引擎</h1>
          <ThemeSwitcher />
        </header>
        
        <main className="app-main">
          <ThemeEditor />
          
          <div className="theme-preview">
            <h3>主题预览</h3>
            <div className="preview-card">
              <h4>示例卡片</h4>
              <p>这是一个使用当前主题样式的示例卡片。</p>
              <button style={{ 
                marginTop: '1rem',
                padding: '0.5rem 1rem',
                backgroundColor: 'var(--color-primary)',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer'
              }}>
                主按钮
              </button>
            </div>
          </div>
        </main>
      </div>
    </ThemeProvider>
  )
}

export default App

3. 功能特性与最佳实践

3.1 核心功能特性

  • 主题持久化: 使用 Zustand 的 Persist 中间件将主题配置保存到 localStorage
  • 实时切换: 主题切换实时生效,无需页面刷新
  • 自定义主题: 支持用户创建和编辑自定义主题
  • 响应式设计: 主题系统与 CSS 变量无缝集成,支持响应式设计
  • 类型安全: 如果使用 TypeScript,可以为主题配置添加完整的类型定义

3.2 最佳实践

  1. 使用 CSS 变量: 将主题颜色定义为 CSS 变量,便于全局应用和动态更新
  2. 主题继承: 自定义主题可以继承默认主题的颜色,减少重复配置
  3. 性能优化: 使用 Zustand 的 selector 模式只订阅必要的主题状态
  4. 可扩展性: 设计时考虑未来可能的主题扩展,如字体大小、间距等
  5. 无障碍支持: 确保所有主题都符合无障碍标准,特别是颜色对比度

3.3 扩展建议

  • 添加主题导入/导出功能
  • 实现主题预览功能
  • 支持基于系统主题自动切换
  • 添加主题历史记录功能
  • 实现主题分享功能

4. 总结

本案例实现了一个功能完整的多主题切换引擎,使用 Zustand 管理主题状态,集成了 Persist 和 Immer 中间件。通过 CSS 变量和 React 上下文提供者,实现了主题的全局应用和实时切换。该方案具有良好的可扩展性和维护性,可以轻松应用到各种 React 项目中,为用户提供个性化的主题体验。