前端主题切换设计

主题切换不是简单地加一个“深色模式”按钮。真正要处理的是一套从设计 token 到运行时状态的协作机制,还包括系统主题、持久化、首屏渲染和业务组件边界。

一个可维护的主题方案,至少要解决三件事:

  • 语义稳定:业务组件不直接依赖具体颜色值,而是依赖语义 token;
  • 切换可靠:用户主动选择、系统主题变化、刷新页面、首屏渲染都能得到一致结果;
  • 成本可控:主题能力集中在基础设施层,不散落到每个组件里。

方案设计

主题系统可以拆成四层:

层级职责示例
token 层定义语义变量,不关心运行时逻辑--color-bg-page--color-text-primary
主题声明层为不同主题提供变量值lightdark
主题运行时层解析当前主题、监听变化、写入 DOMdata-themelocalStoragematchMedia
业务组件层只消费语义变量background: var(--color-bg-page)

主题状态不要只设计成 light | dark,否则无法表达“跟随系统”。更推荐拆成两个概念:

type ThemeMode = 'light' | 'dark'
type ThemePreference = ThemeMode | 'system'
  • ThemePreference:用户偏好,负责持久化;
  • ThemeMode:最终生效主题,负责渲染。

当用户选择 system 时,最终主题由系统主题推导;当用户选择 lightdark 时,最终主题不会被系统主题覆盖。

切换主题

主题切换的核心动作是把最终生效主题写到 <html> 上:

document.documentElement.dataset.theme = 'dark'

执行后页面会变成:

<html data-theme="dark"></html>

这行代码本身不直接改颜色,而是改变 CSS 选择器的命中结果:

[data-theme='light'] {
  --color-bg-page: #f8fafc;
  --color-text-primary: #111827;
}

[data-theme='dark'] {
  --color-bg-page: #0f172a;
  --color-text-primary: #f9fafb;
}

组件只消费变量:

.page {
  background: var(--color-bg-page);
  color: var(--color-text-primary);
}

完整链路是:

  1. 用户选择主题。
  2. 运行时解析出最终主题。
  3. JS 写入 <html data-theme="...">
  4. 对应主题的 CSS 变量生效。
  5. 使用这些变量的组件自动更新样式。

所以主题切换的本质是:JS 只切换主题标识,CSS 变量承载具体视觉差异

适应系统主题

系统主题通过 prefers-color-scheme 获取:

const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches

当用户选择“跟随系统”时,还需要监听系统主题变化:

const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')

const handleSystemThemeChange = () => {
  // 只有 preference === 'system' 时,才重新应用系统主题
}

mediaQuery.addEventListener('change', handleSystemThemeChange)

这里要注意边界:系统主题变化只能影响 preference === 'system' 的用户;如果用户已经手动选择 darklight,系统变化不应该覆盖用户选择。

首屏闪屏

如果初始主题写入得太晚,页面可能先按默认主题完成绘制,再切换到用户主题,从而出现短暂闪烁。解决首屏闪屏的关键是:在应用启动前就写入初始主题

常见做法有两种:

  • SPA 或静态站:在 <head> 放一段很小的内联脚本,提前写入 data-theme
  • SSR:把主题偏好同步到 cookie,服务端直接输出初始 <html data-theme="...">

具体实现

前面讨论的是通用设计思路,下面以 React SPA 为例给出一套完整方案,包含四部分:

  1. <head> 内联脚本:解决首屏闪屏;
  2. 全局 CSS 变量:承载主题视觉差异;
  3. React ThemeProvider:管理运行时主题状态;
  4. 主题切换与业务组件:业务组件通过 CSS 变量自动应用主题。

1. 写入初始主题

这段脚本要放在 CSS 和 React 应用脚本之前,越早执行越好。

<script>
  ;(() => {
    const key = 'theme-preference'
    const value = localStorage.getItem(key)
    const themes = ['light', 'dark', 'system']
    const preference = themes.includes(value) ? value : 'system'
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
    const theme = preference === 'system' ? (isDark ? 'dark' : 'light') : preference

    document.documentElement.dataset.theme = theme
  })()
</script>

它只做一件事:在 React 启动前,把正确的 data-theme 写到 <html> 上。

2. 定义主题变量

主题变量统一放在全局 CSS 中:

:root,
[data-theme='light'] {
  color-scheme: light;

  --color-bg-page: #f8fafc;
  --color-bg-surface: #ffffff;
  --color-text-primary: #111827;
  --color-text-secondary: #4b5563;
  --color-border-subtle: #e5e7eb;
  --color-brand-primary: #2563eb;
}

[data-theme='dark'] {
  color-scheme: dark;

  --color-bg-page: #0f172a;
  --color-bg-surface: #111827;
  --color-text-primary: #f9fafb;
  --color-text-secondary: #cbd5e1;
  --color-border-subtle: #334155;
  --color-brand-primary: #60a5fa;
}

color-scheme 用来告诉浏览器当前页面适合使用哪种原生配色。它会影响表单控件、滚动条等浏览器默认 UI 的明暗表现;业务组件的颜色仍然由后面的 CSS 变量控制。

3. 封装 ThemeProvider

在 React 应用中,ThemeProvider 负责运行时主题状态,包括读取用户偏好、切换主题、监听系统主题变化,并把最终主题同步到 DOM。

普通业务组件仍然只消费 CSS 变量,不需要在 JSX 中判断当前是浅色还是深色。

import type { ReactNode } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'

type ThemeMode = 'light' | 'dark'
type ThemePreference = ThemeMode | 'system'

type ThemeContextValue = {
  preference: ThemePreference
  resolvedTheme: ThemeMode
  setPreference: (preference: ThemePreference) => void
}

type ThemeProviderProps = {
  children: ReactNode
}

const THEME_STORAGE_KEY = 'theme-preference'
const ThemeContext = createContext<ThemeContextValue | null>(null)
const themePreferences: ThemePreference[] = ['light', 'dark', 'system']

function isThemePreference(value: unknown): value is ThemePreference {
  return typeof value === 'string' && themePreferences.includes(value as ThemePreference)
}

function getSystemTheme(): ThemeMode {
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}

function resolveTheme(preference: ThemePreference): ThemeMode {
  return preference === 'system' ? getSystemTheme() : preference
}

function readThemePreference(): ThemePreference {
  const value = localStorage.getItem(THEME_STORAGE_KEY)

  if (isThemePreference(value)) {
    return value
  }

  return 'system'
}

function applyTheme(theme: ThemeMode) {
  document.documentElement.dataset.theme = theme
}

export const ThemeProvider = (props: ThemeProviderProps) => {
  const { children } = props
  const [preference, setPreferenceState] = useState<ThemePreference>(readThemePreference)
  const [resolvedTheme, setResolvedTheme] = useState<ThemeMode>(() => resolveTheme(preference))

  const setPreference = useCallback((nextPreference: ThemePreference) => {
    localStorage.setItem(THEME_STORAGE_KEY, nextPreference)
    setPreferenceState(nextPreference)
  }, [])

  useEffect(() => {
    const nextTheme = resolveTheme(preference)

    setResolvedTheme(nextTheme)
    applyTheme(nextTheme)
  }, [preference])

  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')

    const onChange = () => {
      if (preference !== 'system') {
        return
      }

      const nextTheme = getSystemTheme()

      setResolvedTheme(nextTheme)
      applyTheme(nextTheme)
    }

    mediaQuery.addEventListener('change', onChange)

    return () => {
      mediaQuery.removeEventListener('change', onChange)
    }
  }, [preference])

  const value = useMemo<ThemeContextValue>(
    () => ({
      preference,
      resolvedTheme,
      setPreference,
    }),
    [preference, resolvedTheme, setPreference]
  )

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}

ThemeProvider.displayName = 'ThemeProvider'

export function useTheme() {
  const context = useContext(ThemeContext)

  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider')
  }

  return context
}

应用入口包一层 ThemeProvider

import { ThemeProvider } from './ThemeProvider'

export const App = () => {
  return (
    <ThemeProvider>
      <Page />
    </ThemeProvider>
  )
}

App.displayName = 'App'

4. 实现主题切换

切换控件只更新用户偏好,不直接操作 DOM:

import type { ChangeEvent } from 'react'

export const ThemeSwitch = () => {
  const { preference, setPreference } = useTheme()

  const onPreferenceChange = (event: ChangeEvent<HTMLSelectElement>) => {
    const nextPreference = event.target.value

    if (isThemePreference(nextPreference)) {
      setPreference(nextPreference)
    }
  }

  return (
    <select value={preference} onChange={onPreferenceChange}>
      <option value="light">浅色</option>
      <option value="dark">深色</option>
      <option value="system">跟随系统</option>
    </select>
  )
}

ThemeSwitch.displayName = 'ThemeSwitch'

普通业务组件只消费 CSS 变量:

export const Page = () => {
  return (
    <main className="page">
      <ThemeSwitch />
      <section className="card">主题内容</section>
    </main>
  )
}

Page.displayName = 'Page'
.page {
  min-height: 100vh;
  background: var(--color-bg-page);
  color: var(--color-text-primary);
}

.card {
  background: var(--color-bg-surface);
  border: 1px solid var(--color-border-subtle);
}

最佳实践

1. 控制 token 数量

主题变量应从使用场景出发,而不是照搬完整色板。

推荐:

--color-bg-page
--color-bg-surface
--color-text-primary
--color-text-secondary
--color-border-subtle
--color-state-danger

不推荐业务组件直接使用:

--blue-500
--gray-100
--slate-900

色阶变量可以存在于设计系统内部,但组件层应该消费语义变量。

2. 避免主题逻辑分散

不要让每个组件都判断主题:

const isDark = theme === 'dark'

return <div className={isDark ? 'dark-card' : 'light-card'} />

更好的方式是把差异留给 CSS 变量:

return <div className="card" />

3. 明确主题切换边界

主题切换应该影响视觉表达,不应该改变业务语义。

适合放进主题系统:

  • 颜色;
  • 阴影;
  • 图表配色;
  • 代码高亮;
  • 插图明暗版本。

不适合放进主题系统:

  • 权限逻辑;
  • 数据结构;
  • 接口参数;
  • 页面流程。

4. 处理图片和图表

纯 CSS 变量无法覆盖所有视觉资产。图表、图片、地图、编辑器和代码高亮通常需要单独适配。

图表可以用 resolvedTheme 生成配置:

function createChartTheme(theme: ThemeMode) {
  return {
    backgroundColor: 'transparent',
    textColor: theme === 'dark' ? '#e5e7eb' : '#111827',
    gridColor: theme === 'dark' ? '#334155' : '#e5e7eb',
  }
}

图片可以按复杂度选择方案:

  • 简单图标:使用 currentColor 或 CSS 变量;
  • 产品插图:准备浅色、深色两套资源;
  • 用户上传图片:通常不随主题变化,只保证周围容器对比度足够。

5. 保证可访问性

深色主题不是把颜色反过来。需要单独检查文本、边框、禁用态、提示态和危险态的对比度。

需要重点检查:

  • 正文与背景的对比度;
  • 次级文本在深色背景下是否过暗;
  • 细边框在浅色和深色下是否都可见;
  • focus ring 是否明显;
  • 危险、成功、警告状态是否只依赖颜色表达。

6. 兼容 SSR

如果应用使用 SSR,服务端无法直接读取 localStoragematchMedia。更稳定的做法是把用户主题偏好同步到 cookie,服务端根据 cookie 输出初始主题:

<html data-theme="dark"></html>

如果采用这种方案,客户端切换主题时也要同步更新 cookie。否则刷新后服务端仍然只能看到旧的主题偏好,首屏 HTML、CSS 和 React hydration 也就无法稳定保持同一个主题。

总结

前端主题切换的难点不在于切换几组颜色,而在于把用户偏好、系统主题、首屏渲染和业务组件样式放到同一套机制里处理。落地时可以按职责拆开:运行时负责状态和切换,CSS 变量负责视觉差异,首屏脚本或服务端输出负责在页面绘制前确定初始主题。