暗黑模式与主题系统

暗黑模式已经从"锦上添花"变成了"用户期望"——所有主流操作系统和浏览器都支持暗黑偏好设置,如果你的应用只有亮色模式,在深夜打开时体验会很差。但暗黑模式的实现远比"反转颜色"复杂——SSR 防闪烁、用户偏好持久化、多主题架构、与 Tailwind / Nuxt UI 的协同,每一步都有坑。本章从底层原理到生产级实现,完整覆盖 Nuxt4 的主题系统。

1. @nuxtjs/color-mode

1.1 基础配置

Nuxt4 通过 @nuxtjs/color-mode 模块管理颜色模式:

npx nuxi module add @nuxtjs/color-mode
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/color-mode'],
  colorMode: {
    classSuffix: '',       // 类名不加后缀:.dark 而非 .dark-mode
    preference: 'system',  // 默认跟随系统
    fallback: 'light',     // 无法检测系统偏好时的回退值
    storageKey: 'nuxt-color-mode',
  },
})

1.2 工作原理

@nuxtjs/color-mode 做了三件事:

  1. 检测用户偏好:读取 Cookie(SSR)或 prefers-color-scheme 媒体查询(客户端)
  2. 设置 CSS 类:在 <html> 元素上添加 class="dark"class="light"
  3. 提供 ComposableuseColorMode() 返回当前模式和切换方法
<script setup>
const colorMode = useColorMode()
 
function toggleDark() {
  colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
</script>
 
<template>
  <button @click="toggleDark">
    {{ colorMode.value === 'dark' ? '☀️' : '🌙' }}
  </button>
</template>

1.3 三种模式

模式colorMode.preference行为
跟随系统'system'自动检测操作系统的暗黑模式设置
强制亮色'light'无论系统设置如何,始终亮色
强制暗色'dark'无论系统设置如何,始终暗色

推荐提供三选一的切换器,而非简单的亮/暗开关——"跟随系统"是很多用户的首选。

2. CSS 变量主题系统

2.1 基于 CSS 变量的颜色切换

暗黑模式的核心技术是 CSS 变量——同一个变量名在不同主题下映射不同的颜色值:

:root {
  --color-bg: #ffffff;
  --color-text: #111827;
  --color-border: #e5e7eb;
  --color-surface: #f9fafb;
  --color-primary: #3b82f6;
}
 
.dark {
  --color-bg: #0f172a;
  --color-text: #f1f5f9;
  --color-border: #334155;
  --color-surface: #1e293b;
  --color-primary: #60a5fa;
}

组件代码中始终使用语义化变量名,不关心当前是什么主题:

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

切换主题时,只需改变 &lt;html&gt; 的 class(从 lightdark),所有使用 CSS 变量的元素自动更新——零 JavaScript 运行时开销

2.2 与 Tailwind v4 集成

Tailwind v4 的 @theme 定义的颜色自动生成 CSS 变量。暗黑模式使用 Tailwind 的 dark: 变体:

<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
  <h1 class="text-primary dark:text-primary-400">标题</h1>
</div>

Tailwind 的 dark: 变体默认使用 class 策略(检测 &lt;html class="dark"&gt;),与 @nuxtjs/color-mode 完美配合。

2.3 语义化颜色命名

不要在暗黑模式中简单反转颜色。好的暗黑模式需要独立设计:

语义亮色值暗色值说明
--color-bg#ffffff#0f172a不是简单反转,暗色背景偏蓝灰
--color-text#111827#f1f5f9暗色文字不用纯白,减轻眼睛疲劳
--color-primary#3b82f6#60a5fa暗色模式主色调要更亮
--color-surface#f9fafb#1e293b卡片等浮层的背景
--color-muted#6b7280#94a3b8次要文字在暗色模式下要更亮

原则:

  • 暗色背景偏蓝灰#0f172a),不用纯黑(#000000)——纯黑对比度太高,刺眼
  • 暗色文字不用纯白#f1f5f9),减轻眼睛疲劳
  • 主色调在暗色模式中调亮,保持足够的对比度

3. SSR 防闪烁

3.1 闪烁的根因

SSR 场景下暗黑模式最常见的问题是闪烁(FOUC, Flash of Unstyled Content)

1. 服务端渲染 HTML(不知道用户偏好,用默认的亮色)
2. 浏览器显示亮色页面
3. JavaScript 加载,检测到用户偏好暗色
4. 切换到暗色 → 用户看到"先亮后暗"的闪烁

这个闪烁虽然只有几百毫秒,但用户体验极差——尤其在深夜,亮色页面会"亮瞎眼"。

@nuxtjs/color-mode 默认使用 Cookie 存储用户偏好。Cookie 在 SSR 请求中随 HTTP 头发送到服务端,因此服务端可以在渲染 HTML 之前就知道用户的主题偏好:

1. 首次访问:服务端读不到 Cookie → 用默认值 + 注入检测脚本
2. 检测脚本在 <head> 中同步执行,根据系统偏好设置 <html class="dark">
3. 后续访问:Cookie 中有偏好 → 服务端直接渲染正确的主题 → 零闪烁

3.3 注入脚本

@nuxtjs/color-mode 会在 &lt;head&gt; 中注入一段阻塞脚本(在页面渲染前执行):

<script>
  // 简化版逻辑
  const stored = document.cookie.match(/nuxt-color-mode=(\w+)/)?.[1]
  const preference = stored || 'system'
  const resolved = preference === 'system'
    ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
    : preference
  document.documentElement.classList.add(resolved)
</script>

这段脚本在 CSS 和 DOM 渲染前执行,确保第一帧就是正确的主题。

3.4 为什么不用 localStorage

localStorage 无法在 SSR 阶段访问(只有浏览器有 localStorage)。如果用 localStorage 存储偏好:

  • SSR 渲染时不知道用户偏好 → 用默认亮色
  • 客户端 JavaScript 读取 localStorage → 切换到暗色
  • 必然闪烁

Cookie 的优势在于它随 HTTP 请求发送,SSR 阶段也能读到。

4. 主题切换器

4.1 三态切换器

<!-- components/ThemeToggle.vue -->
<script setup>
const colorMode = useColorMode()
 
const modes = [
  { value: 'system', icon: 'i-heroicons-computer-desktop', label: '跟随系统' },
  { value: 'light', icon: 'i-heroicons-sun', label: '亮色' },
  { value: 'dark', icon: 'i-heroicons-moon', label: '暗色' },
] as const
 
function cycle() {
  const current = modes.findIndex(m => m.value === colorMode.preference)
  const next = (current + 1) % modes.length
  colorMode.preference = modes[next].value
}
</script>
 
<template>
  <button
    @click="cycle"
    class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
    :title="modes.find(m => m.value === colorMode.preference)?.label"
  >
    <span :class="modes.find(m => m.value === colorMode.preference)?.icon" />
  </button>
</template>

4.2 过渡动画

主题切换时添加平滑过渡:

/* 全局过渡 */
html.color-mode-transition,
html.color-mode-transition *,
html.color-mode-transition *::before,
html.color-mode-transition *::after {
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease !important;
}
// 在切换时添加过渡类
function switchTheme(mode: string) {
  document.documentElement.classList.add('color-mode-transition')
  colorMode.preference = mode
  setTimeout(() => {
    document.documentElement.classList.remove('color-mode-transition')
  }, 300)
}

注意:不要全局始终应用 transition——它会影响所有颜色变化(包括 hover、focus),导致性能问题和不必要的动画。只在切换主题的瞬间临时添加。

4.3 View Transition API

现代浏览器支持 View Transition API,可以实现更酷炫的主题切换动画:

async function toggleWithTransition() {
  if (!document.startViewTransition) {
    // 回退:直接切换
    colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
    return
  }
 
  await document.startViewTransition(() => {
    colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
  }).ready
 
  // 可以在这里添加自定义动画
}

5. 多主题系统

5.1 超越亮/暗

有些产品需要不止两种主题——品牌主题、季节主题、用户自定义主题。CSS 变量架构天然支持多主题:

/* 主题:默认 */
:root, .theme-default {
  --color-primary: #3b82f6;
  --color-accent: #8b5cf6;
  --color-bg: #ffffff;
}
 
/* 主题:暖色 */
.theme-warm {
  --color-primary: #f59e0b;
  --color-accent: #ef4444;
  --color-bg: #fffbeb;
}
 
/* 主题:冷色 */
.theme-cool {
  --color-primary: #06b6d4;
  --color-accent: #3b82f6;
  --color-bg: #ecfeff;
}
 
/* 每个主题都有暗色变体 */
.dark.theme-warm {
  --color-primary: #fbbf24;
  --color-accent: #f87171;
  --color-bg: #1c1917;
}

5.2 主题管理

// composables/useTheme.ts
export function useTheme() {
  const colorMode = useColorMode()
  const theme = useCookie('app-theme', { default: () => 'default' })
 
  const availableThemes = ['default', 'warm', 'cool'] as const
 
  function setTheme(name: typeof availableThemes[number]) {
    // 移除旧主题类
    availableThemes.forEach(t => {
      document.documentElement.classList.remove(`theme-${t}`)
    })
    // 添加新主题类
    document.documentElement.classList.add(`theme-${name}`)
    theme.value = name
  }
 
  // 初始化
  onMounted(() => {
    if (theme.value !== 'default') {
      document.documentElement.classList.add(`theme-${theme.value}`)
    }
  })
 
  return {
    theme: readonly(theme),
    colorMode,
    availableThemes,
    setTheme,
  }
}

5.3 用户自定义主题

最灵活的方案是让用户直接自定义主色调:

function setCustomPrimary(hue: number) {
  const root = document.documentElement
  root.style.setProperty('--color-primary', `oklch(0.6 0.2 ${hue})`)
  root.style.setProperty('--color-primary-50', `oklch(0.97 0.02 ${hue})`)
  root.style.setProperty('--color-primary-500', `oklch(0.6 0.2 ${hue})`)
  root.style.setProperty('--color-primary-900', `oklch(0.3 0.1 ${hue})`)
}

使用 oklch 色彩空间——只需改变色相(hue),就能生成感知一致的完整色阶。

6. 暗黑模式设计注意事项

6.1 图片适配

暗色背景上的纯白图片会非常刺眼。考虑降低亮度:

.dark img:not([data-no-dim]) {
  filter: brightness(0.9);
}

或者为暗黑模式提供专门的图片版本:

<template>
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="/images/logo-dark.svg" />
    <img src="/images/logo-light.svg" alt="Logo" />
  </picture>
</template>

6.2 阴影

亮色模式下的阴影在暗色背景上几乎不可见。暗色模式的"阴影"应该用更暗的背景或微妙的边框替代:

.card {
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
 
.dark .card {
  box-shadow: none;
  border: 1px solid var(--color-border);
}

6.3 对比度

确保文字在两种模式下都满足 WCAG AA 标准(对比度 ≥ 4.5:1)。可以用 Chrome DevTools 的 Contrast Ratio 工具检查。

本章小结

  • @nuxtjs/color-mode:检测偏好 + 设置 CSS 类 + 提供 Composable,三种模式(system/light/dark)
  • CSS 变量架构:语义化命名(--color-bg/--color-text),.dark 类切换变量值,零 JS 运行时开销
  • 防闪烁:Cookie 存储偏好 + &lt;head&gt; 阻塞脚本在首帧设置正确主题,不用 localStorage
  • 暗色设计:背景偏蓝灰(非纯黑)、文字非纯白、主色调调亮、图片降低亮度、阴影用边框替代
  • 多主题:CSS 变量 + 主题类名组合(theme-warm + dark),oklch 色彩空间实现用户自定义色调