Writing

深浅主题 (Theming)

文章發表於

深色与浅色主题近年来几乎已成为网站的标配,从个人博客到全球级的客户端应用(例如:GitHub),皆提供深浅色主题的切换功能,让使用者能依照个人喜好自由选择界面风格。

要实现网站主题的无痛切换,我认为最重要的原则是:在开发时,以〈设计标签〉作为 CSS 参数的基石。在先前的章节〈从设计标签到 CSS〉 中,我们已介绍过其带来的好处。如果产品色码未被纳入设计标签 (Design Token) 内,就必须在应用层的 CSS 中额外处理,这在日后更改颜色时会增添不必要的麻烦。

回顾

设计标签 (Design Tokens)

〈设计标签〉 一文中,我们介绍了设计标签的基本概念。其中,透过 System Tokenalias.json)的深浅主题配置,分别对应到不同的 Reference Tokenbase.json),这样的设计使我们能根据不同条件,动态地将同一个 System Token 解析为不同的值。

标准化 CSS (Normalized CSS)

接着,在 Style Dictionary 构建设计标签的过程中,其会将 :root 中的变量根据主题拆分为 lightdark,并整理到 normalized.cssSource Code)。并将这份 CSS 档放在 <head> 内,就可以让网页加载时引入,而主题的切换则透过 data-theme 属性来控制使用哪一组主题色。

Style Dictionary 的实际输出结构:

/* 建议的结构 - 优先使用系统偏好设定 */
/* 深色主题(默认) */
:root {
--your-design-system-sys-color-primary: #D0BCFF;
--your-design-system-sys-color-background: #141218;
--your-design-system-sys-color-on-background: #E6E0E9;
/* ... 其他深色主题 tokens */
}
/* 浅色主题 */
html[data-theme='light'], .your-design-system-light {
--your-design-system-sys-color-primary: #6750A4;
--your-design-system-sys-color-background: #FEF7FF;
--your-design-system-sys-color-on-background: #1D1B20;
/* ... 其他浅色主题 tokens */
}

以下我们将会介绍如何实作让使用者可以切换深浅色模式的功能,也就是本篇要介绍的 useTheme Hook。

useTheme

描述

useTheme 的核心功能是控制 data-theme 属性的值,同时提供主题状态管理和持久化存储功能。

API

理想上我们希望当使用 useTheme 时,可以让我们更新 data-theme 以及取得当前的 theme 状态。

import { useTheme } from '@tocino-ui/core/hooks'
export default () => {
const { theme, resolvedTheme, toggleTheme, setTheme } = useTheme()
return (
<div>
<span>Current Theme: {theme}</span>
<span>Resolved Theme: {resolvedTheme}</span>
<button onClick={toggleTheme}>Toggle Theme</button>
<button onClick={() => setTheme('system')}>Use System Theme</button>
</div>
)
}

参数

名称类型初始值描述
initialTheme'light' | 'dark' | 'system''system'初始的主题

返回 API

名称类型描述
theme'light' | 'dark' | 'system'当前的主题模式
resolvedTheme'light' | 'dark'实际渲染的主题
toggleTheme() => void在 light/dark 间切换
setTheme(theme: 'light' | 'dark' | 'system') => void设定特定主题

实作

请点击『展开代码』以查看代码。
import React, { useEffect, useState, useCallback, useMemo } from 'react'
import { Button } from '@tocino-ui/button'

/**
 * ====== CONSTANTS ======
 */
const THEME_STORAGE_KEY = 'your-design-system-theme'
const THEME_MODES = {
  LIGHT: 'light',
  DARK: 'dark',
  SYSTEM: 'system',
}

/**
 * ====== THEME MANAGER ======
 */
const themeManager = {
  getStoredTheme: () => {
    if (typeof window === 'undefined') return THEME_MODES.SYSTEM
    
    try {
      const stored = localStorage.getItem(THEME_STORAGE_KEY)
      if (stored && Object.values(THEME_MODES).includes(stored)) {
        return stored
      }
    } catch (error) {
      console.warn('无法读取主题设定:', error)
    }
    
    return THEME_MODES.SYSTEM
  },

  setStoredTheme: (theme) => {
    if (typeof window === 'undefined') return
    
    try {
      localStorage.setItem(THEME_STORAGE_KEY, theme)
    } catch (error) {
      console.warn('无法储存主题设定:', error)
    }
  },

  getSystemTheme: () => {
    if (typeof window === 'undefined') return THEME_MODES.DARK
    
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? THEME_MODES.DARK
      : THEME_MODES.LIGHT
  },

  resolveTheme: (theme) => {
    return theme === THEME_MODES.SYSTEM ? themeManager.getSystemTheme() : theme
  },

  applyTheme: (resolvedTheme) => {
    if (typeof document === 'undefined') return
    
    document.documentElement.setAttribute('data-theme', resolvedTheme)
    
    // 设定 color-scheme 以改善浏览器原生元件的显示
    document.documentElement.style.colorScheme = resolvedTheme
  },
}

/**
 * ====== useTheme Hook ======
 */
function useTheme(initialTheme = THEME_MODES.SYSTEM) {
  // 初始化主题状态
  const [theme, setThemeState] = useState(() => {
    if (typeof window === 'undefined') return initialTheme
    return themeManager.getStoredTheme()
  })

  // 计算实际渲染的主题
  const resolvedTheme = useMemo(() => {
    return themeManager.resolveTheme(theme)
  }, [theme])

  // 监听系统主题变化
  useEffect(() => {
    if (typeof window === 'undefined' || theme !== THEME_MODES.SYSTEM) return

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
    
    const handleSystemThemeChange = () => {
      // 当前是 system 模式时,强制重新渲染以更新 resolvedTheme
      setThemeState(THEME_MODES.SYSTEM)
    }

    mediaQuery.addEventListener('change', handleSystemThemeChange)
    return () => mediaQuery.removeEventListener('change', handleSystemThemeChange)
  }, [theme])

  // 套用主题到 DOM
  useEffect(() => {
    themeManager.applyTheme(resolvedTheme)
  }, [resolvedTheme])


  // 持久化主题设定
  useEffect(() => {
    themeManager.setStoredTheme(theme)
  }, [theme])

  // 切换主题函数
  const toggleTheme = useCallback(() => {
    setThemeState(prev => {
      if (prev === THEME_MODES.SYSTEM) {
        // system 模式下,根据当前系统主题决定切换方向
        const currentSystemTheme = themeManager.getSystemTheme()
        return currentSystemTheme === THEME_MODES.DARK 
          ? THEME_MODES.LIGHT 
          : THEME_MODES.DARK
      }
      return prev === THEME_MODES.DARK ? THEME_MODES.LIGHT : THEME_MODES.DARK
    })
  }, [])

  // 设定主题函数
  const setTheme = useCallback((newTheme) => {
    setThemeState(newTheme)
  }, [])

  return useMemo(
    () => ({
      theme,
      resolvedTheme,
      toggleTheme,
      setTheme,
    }),
    [theme, resolvedTheme, toggleTheme, setTheme]
  )
}

/**
 * ====== 使用范例 ======
 */
export default function ThemeExample() {
  const { theme, resolvedTheme, toggleTheme, setTheme } = useTheme()

  return (
    <div className="container">
      <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/@tocino-ui/design-tokens/dist/normalize/normalize.css"
      />
      
      <h2 className="header">
        Current Theme: {theme}
        {theme === 'system' && ` (resolved: ${resolvedTheme})`}
      </h2>
      
      <div className="button-group">
        <Button 
          variant={theme === 'light' ? 'filled' : 'outline'} 
          onClick={() => setTheme('light')}
        >
          Light Mode
        </Button>
        
        <Button 
          variant={theme === 'dark' ? 'filled' : 'outline'} 
          onClick={() => setTheme('dark')}
        >
          Dark Mode
        </Button>
        
        <Button 
          variant={theme === 'system' ? 'filled' : 'outline'} 
          onClick={() => setTheme('system')}
        >
          System Mode
        </Button>
      </div>
      
      <div className="info">
        <p>试着切换你的操作系统主题设定,看看 System Mode 的变化!</p>
        <p>主题设定会自动储存在 localStorage 中。</p>
      </div>
    </div>
  )
}

可以看到上面引入了我们先前做的 Button 组件,并且透过 useTheme 去控制 data-theme 的值,这样就可以让设计系统能够支援切换深浅色主题。

如果您喜欢这篇文章,请点击下方按钮分享给更多人,这将是对笔者创作的最大支持和鼓励。
Buy me a coffee