#React#TypeScript

CSS 暗色模式实现:prefers-color-scheme 与 class 切换

暗色模式不是简单反转颜色——要处理系统偏好跟随、手动切换、持久化、闪烁(FOUC)、图片/图标适配。本文讲清 prefers-color-scheme 媒体查询、class 切换策略、localStorage 持久化、防闪烁脚本、CSS 变量主题系统,给一套完整的暗色模式方案。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年1月24日·更新于 2026年7月23日

CSS 暗色模式实现:prefers-color-scheme 与 class 切换

"暗色模式不是把白底黑字反过来那么简单——它是一整套'主题系统'的试金石。"

很多开发者对暗色模式有两个误解:一是觉得它"不就是 filter: invert(1) 吗",二是觉得"加个 @media (prefers-color-scheme: dark) 不就完了"。

真正做过一遍,你会发现坑全藏在细节里:

  • 用户系统是暗色,但 ta 偏想看你网站的亮色——你跟不跟随?
  • 用户手动切到暗色,刷新页面后"白闪"一下再变暗——这叫 FOUC(Flash of Unstyled Content),是暗色模式差评的重灾区。
  • 同一张产品大图,亮色下鲜艳,暗色下刺眼得像直视车灯——怎么办?
  • SSR 框架(Next.js)下,服务端根本不知道用户的主题,首屏必然闪烁——怎么破?

这篇文章不讲"暗色模式是什么",而是讲怎么工程化地落地一套不打架、不闪烁、能持久化、能跟随系统的暗色模式。读完你应该能:

  • 区分 prefers-color-scheme 媒体查询与 class 切换两种策略,知道何时用哪个
  • 设计一个"系统 / 亮 / 暗"三态模型,而不是粗暴的二值切换
  • 写出防 FOUC 的 inline 脚本,从首帧起就给对的颜色
  • 用 CSS 变量搭一套语义化主题系统,让组件与主题彻底解耦
  • 在 Next.js 里正确使用 next-themes,而不是自己重新发明轮子

一、prefers-color-scheme:跟随系统偏好,最省心的起点

现代操作系统(macOS、Windows、iOS、Android)都有"系统级深色模式"。浏览器通过 prefers-color-scheme 这个媒体特性,把系统偏好暴露给 CSS 和 JS。

1.1 最朴素的暗色模式:纯 CSS 跟随系统

如果你完全不需要手动切换——用户系统是暗色,你网站就是暗色——那只需要 CSS,连一行 JS 都不用写:

css
:root {
  --bg: #ffffff;
  --text: #1a1a1a;
  --border: #e5e5e5;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0a0a0a;
    --text: #ededed;
    --border: #2a2a2a;
  }
}

body {
  background-color: var(--bg);
  color: var(--text);
}

这就是 prefers-color-scheme 的全部魔法。浏览器(包括 Safari iOS 13+、Chrome 76+、Firefox 67+)会自动响应系统主题切换,用户改系统设置时,你的网页无需刷新就会跟着变。

1.2 三个值,不是两个

prefers-color-scheme 有三个取值,别只盯着 dark:

css
/* 用户未表达偏好(少数系统/无障碍设置下会出现) */
@media (prefers-color-scheme: no-preference) {
  /* 通常啥也不用做,走默认(亮色)即可 */
}

@media (prefers-color-scheme: light) {
  /* 显式亮色 */
}

@media (prefers-color-scheme: dark) {
  /* 显式暗色 */
}

no-preference 这个值很容易被忽略。稳妥的做法是:默认值(:root 不在媒体查询里的那一套)就是亮色,把 dark 当作覆盖。这样 no-preference 自然落到亮色,不会出现"没偏好结果一片黑"的事故。

1.3 JS 侧怎么感知系统主题

光 CSS 不够,比如你的切换按钮想显示当前是亮是暗、想监听系统切换事件,就得用 JS 的 matchMedia:

ts
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const systemPrefersDark = mql.matches;

// 监听系统主题实时变化(用户在系统设置里切换时触发)
mql.addEventListener('change', (e) => {
  console.log('系统切到', e.matches ? '暗色' : '亮色');
});

注意是 addEventListener('change', ...),老的 addListener 已废弃,别再用了。

1.4 纯 prefers-color-scheme 的致命局限

这套方案最大的问题是:用户没有选择权。

很多场景下,用户希望"我系统是暗色,但这个网站我想看亮色"(比如长时间看代码、看需要精确辨色的设计稿)。纯媒体查询做不到——它把系统偏好当圣旨,用户无法在你网站里override。

所以现实工程里,prefers-color-scheme 几乎从不单独使用,它只是"三态模型"里的一个输入源。真正的核心是下一节的 class 切换策略。


二、手动切换 + class 策略:把控制权还给用户

2.1 为什么是 class,不是别的

手动切换的实现方式有好几种:切 data-theme 属性、切不同 <link> 样式表、改 name="color-scheme" 的 meta……但社区事实标准是——在 <html> 上加一个 dark(或类似名字)class。

为什么?三个理由:

  1. 选择器性能/简单性最好:.dark .card { ... } 比属性选择器 [data-theme="dark"] .card 略快,可读性也更好。
  2. 与 Tailwind / shadcn 等生态天然兼容:Tailwind 的 dark: 变体默认就是 class 策略(darkMode: 'class')。
  3. CSS 变量覆盖最自然:见第五节,.dark { --bg: ... } 一行就能覆盖整套变量。
css
/* 默认(亮色)变量定义在 :root */
:root {
  --bg: #ffffff;
  --text: #1a1a1a;
}

/* 暗色覆盖:只要 html 上有 .dark class,整站变量就切了 */
.dark {
  --bg: #0a0a0a;
  --text: #ededed;
}

2.2 三态模型:别用 boolean,用 'light' | 'dark' | 'system'

新手最常犯的错误是:用一个 boolean isDark 来管理主题。这会丢掉"跟随系统"这个状态。

正确的模型是三态:

ts
type Theme = 'light' | 'dark' | 'system';
  • light:强制亮色,无视系统。
  • dark:强制暗色,无视系统。
  • system:跟随系统(默认值,通过 prefers-color-scheme 判断)。

用户的选择存的是这三个值之一,而实际应用到 DOM 上的 class,是 resolved 之后的结果。这点非常关键,后面持久化时还会用到。

2.3 切换逻辑:把三态 resolve 成实际的 class

ts
const STORAGE_KEY = 'theme';

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

function applyTheme(theme: Theme) {
  // 1. 把三态 resolve 成实际的 light/dark
  const resolved = theme === 'system' ? getSystemTheme() : theme;

  // 2. 操作 html 上的 class
  document.documentElement.classList.toggle('dark', resolved === 'dark');

  // 3. 顺手把 color-scheme meta 也设上,让表单控件、滚动条也跟随
  document.documentElement.style.colorScheme = resolved;
}

function setTheme(theme: Theme) {
  localStorage.setItem(STORAGE_KEY, theme);
  applyTheme(theme);
}

这里有一个常被忽略的细节:color-scheme CSS 属性(或 style.colorScheme)。设上它之后,浏览器原生的滚动条、<input>、<select> 这些"UA 控件"才会真正变成暗色样式。不加这行,你会看到暗色页面上飘着一条亮闪闪的滚动条,违和感拉满。

css
/* 或者在 CSS 里也兜一道 */
:root { color-scheme: light; }
.dark { color-scheme: dark; }

2.4 切换按钮:一个坑——hydration 之前别渲染当前态

在 React/Next.js 里写切换按钮时,另一个坑是:服务端不知道当前主题,首屏渲染的图标(🌙 还是 ☀️)很可能和客户端不一致,导致 hydration mismatch 警告。

tsx
'use client';
import { useEffect, useState } from 'react';

export function ThemeToggle() {
  const [mounted, setMounted] = useState(false);
  const [isDark, setIsDark] = useState(false);

  useEffect(() => {
    setMounted(true);
    setIsDark(document.documentElement.classList.contains('dark'));
  }, []);

  // 关键:挂载前渲染一个占位,避免服务端/客户端图标不一致
  if (!mounted) return <button className="w-9 h-9" aria-label="切换主题" />;

  return (
    <button
      onClick={() => {
        const next = isDark ? 'light' : 'dark';
        setTheme(next);
        setIsDark(!isDark);
      }}
      aria-label={isDark ? '切换到亮色' : '切换到暗色'}
    >
      {isDark ? '☀️' : '🌙'}
    </button>
  );
}

mounted 这个守卫看着土,但它是 Next.js 里处理"客户端才知道的状态"的标准范式。后面会看到,next-themes 也一样逃不掉。


三、持久化:localStorage 记住用户选择

3.1 存什么、不存什么

持久化的实现很简单——localStorage.getItem('theme') / setItem。但存什么有讲究:

  • ✅ 存三态之一:'light' | 'dark' | 'system'。这样用户选了"跟随系统",下次依然跟随。
  • ❌ 不要存 resolved 后的 'light' | 'dark'。如果你把 resolved 结果存进去,用户白天系统切了暗色,他下次打开网站还是亮色——"跟随系统"就名存实亡了。

记住一个原则:localStorage 存的是用户的"意图",而不是"当时的计算结果"。

3.2 完整的读取与降级

ts
function getStoredTheme(): Theme {
  if (typeof window === 'undefined') return 'system';
  const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
  // 校验合法性,防止脏数据
  return stored === 'light' || stored === 'dark' ? stored : 'system';
}

注意要做合法性校验。用户可能通过 devtools 乱写、旧版本可能存过别的 key、隐私模式下 localStorage 可能直接抛错——这些边界都要兜住。

3.3 系统切换时,"跟随系统"的用户也要跟着变

持久化 + 跟随系统组合时,还有一个易漏点:当用户主题是 system 时,系统切换了,你得重新 apply。

ts
useEffect(() => {
  const mql = window.matchMedia('(prefers-color-scheme: dark)');
  const onChange = () => {
    if (getStoredTheme() === 'system') {
      applyTheme('system');
    }
  };
  mql.addEventListener('change', onChange);
  return () => mql.removeEventListener('change', onChange);
}, []);

不加这段,"跟随系统"的用户切了系统主题,必须刷新页面才生效,体验是断的。


四、防 FOUC:白闪是暗色模式的头号杀手

到这里为止的逻辑都没问题,但你只要把它跑在 SSR 框架上,必然会撞上一道墙——FOUC(Flash of Unstyled Content),俗称"白闪"。

4.1 白闪是怎么产生的

以 Next.js 为例,时序是这样的:

  1. 服务端渲染 HTML,服务端不知道用户主题,只能按默认(亮色)渲染。
  2. 浏览器收到 HTML,先按亮色画出第一帧。
  3. 接着 JS bundle 下载、解析、执行,useEffect 里读 localStorage、加 dark class。
  4. 页面从亮变暗——用户眼前闪了一下白光。

对暗色用户来说,每次刷新都闪一下白光,这是极其伤眼的体验(夜间尤为明显)。这个问题的根源是:决定主题的逻辑,跑在了第一帧绘制之后。

4.2 解法:在 CSS 应用之前、首帧绘制之前,注入一段同步阻塞脚本

思路是——在 <head> 里塞一段同步(非 async/defer)的 inline <script>,它在浏览器解析到 <body> 之前就执行完毕,提前把 dark class 加到 <html> 上。这样第一帧画出来时就已经是对的颜色。

html
<!doctype html>
<html lang="zh">
  <head>
    <!-- 这段脚本必须同步执行,且放在所有 CSS 之前/尽早位置 -->
    <script>
      (function () {
        try {
          var stored = localStorage.getItem('theme');
          var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
          var isDark = stored === 'dark' || (!stored || stored === 'system') && systemDark;
          if (isDark) document.documentElement.classList.add('dark');
          document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
        } catch (e) {}
      })();
    </script>
    <!-- 后续 CSS、JS 资源 -->
  </head>
  <body><!-- ... --></body>
</html>

关键点:

  • 必须 inline,不能是外部文件(外部文件要多一个网络请求,赶不上首帧)。
  • 必须同步,不能 defer/async,必须阻塞解析直到它跑完。
  • 必须在 <head> 里、尽量靠前,让它在 <body> 渲染前完成。
  • 逻辑要和 React 里的 applyTheme 完全一致,否则会出现"inline 脚本判断是暗、React hydration 后又变亮"的二次闪烁。两端逻辑同源,是防 FOUC 的纪律。
  • 用 try/catch 包住(隐私模式、禁用 localStorage 时会抛错),失败了就静默回退到系统判断,别让脚本崩了影响整页。

4.3 用 color-scheme 兜底,即便脚本挂了也不至于全白

即便那段 inline 脚本因为某种原因没生效(比如被 CSP 挡了),你也可以让浏览器先用系统偏好兜底,避免"雪白一片":

html
<meta name="color-scheme" content="light dark" />

声明了 content="light dark" 后,浏览器在 CSS 应用前就会按系统偏好渲染 UA 控件和默认背景,相当于多一道保险。强烈建议作为基础配置写进每一个 <head>。

4.4 一个反模式:用 CSS 媒体查询硬切,结果和手动选择打架

有人为了防闪烁,会写 @media (prefers-color-scheme: dark) { body { background: #000; } },试图用纯 CSS 先把背景铺暗。但这会和 class 策略打架——当用户选了"系统暗色但我网站要亮色"时,CSS 媒体查询照样把背景涂黑,逻辑分裂。

纪律是:主题来源只能有一个真相源。 要么全跟系统(纯媒体查询),要么全跟 class(手动策略 + inline 脚本),别两套混用。


五、CSS 变量主题系统:组件与主题彻底解耦

暗色模式做得好不好,七成取决于你的 CSS 变量抽象层。

5.1 反面教材:在组件里硬编码颜色

css
/* 千万别这么写 */
.card {
  background: #ffffff;
  color: #1a1a1a;
  border: 1px solid #e5e5e5;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.dark .card {
  background: #1a1a1a;
  color: #ededed;
  border: 1px solid #2a2a2a;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}

这种写法意味着每个组件都要写两遍——亮色一遍、暗色一遍。组件一多,维护成本指数级上升,改一个颜色要改 N 个地方,迟早漏。

5.2 正解:语义化 token,组件只认 token 不认色值

正确的做法是建立一套语义化设计 token——给颜色起"按用途"的名字,而不是"按色相"的名字:

css
:root {
  /* === 基础色板:原始色值,组件不直接用 === */
  --gray-50: #fafafa;
  --gray-900: #111111;
  --gray-200: #e5e5e5;
  --gray-800: #262626;

  /* === 语义 token:组件只引用这些 === */
  --color-bg: #ffffff;            /* 页面背景 */
  --color-surface: #ffffff;       /* 卡片/面板背景 */
  --color-text: var(--gray-900);  /* 主文字 */
  --color-text-muted: #6b6b6b;    /* 次要文字 */
  --color-border: var(--gray-200);
  --color-primary: #2563eb;       /* 品牌主色 */
  --color-primary-hover: #1d4ed8;
  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.dark {
  --color-bg: #0a0a0a;
  --color-surface: #161616;
  --color-text: #ededed;
  --color-text-muted: #9a9a9a;
  --color-border: var(--gray-800);
  --color-primary: #3b82f6;        /* 暗色下主色稍微提亮,保证对比度 */
  --color-primary-hover: #60a5fa;
  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.5);  /* 暗色下阴影更深 */
}

组件侧只引用语义 token,对亮暗色一无所知:

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

收益是结构性的:

  • 切主题 = 换一套变量,组件 CSS 一行不改。
  • 改主题 = 改变量定义,一处生效全局生效。
  • 想做第三套主题(比如"高对比度模式"),只要再加一套变量,组件代码零改动。

5.3 暗色不是把颜色取反——要注意对比度和层级

暗色模式最容易翻车的是对比度和层级感。

几个经验法则:

  1. 暗色背景别用纯黑 #000000。纯黑会让阴影完全失效(黑色上看不到更黑的阴影),层级感全无。推荐 #0a0a0a ~ #1a1a1a 这种"接近黑但留出阴影空间"的深灰。
  2. 暗色下主色(primary)通常要提亮。同一个蓝色 #2563eb 在白底上对比度够,放到黑底上会显得发暗、发脏,往往要往亮调(如 #3b82f6)。
  3. 层级靠"微微提亮"而非阴影。暗色下卡片背景通常比页面背景亮一点点(如 bg #0a0a0a、surface #161616),形成自然分层,比单纯堆阴影更和谐。
  4. 正文别用纯白 #ffffff。纯白文字配深色背景对比过强,长文阅读刺眼,推荐 #ededed ~ #e0e0e0。

一个心法:暗色模式的设计不是"反色",而是"重新调色"。每个颜色都要重新评估在深色背景下的表现。

5.4 配合 Tailwind:一行配置接入

如果用 Tailwind(v3+),把 token 接进来即可,dark: 变体会自动套用 .dark 下的值:

js
// tailwind.config.js
module.exports = {
  darkMode: 'class',   // 关键:用 class 策略,跟我们的 inline 脚本对齐
  theme: {
    extend: {
      colors: {
        bg: 'var(--color-bg)',
        surface: 'var(--color-surface)',
        text: 'var(--color-text)',
        border: 'var(--color-border)',
      },
    },
  },
};
tsx
<div className="bg-surface text-text border border-border">
  卡片内容
</div>

这样连 dark: 前缀都很少需要写——因为颜色本身就是变量,自动跟着 class 切换。


六、图片与图标适配:暗色下的视觉资产

文字和背景搞定了,但还有一个常被遗忘的战场——图片、图标、Logo。

6.1 内容图片:暗色下降亮度

产品截图、Banner 这类内容图,在暗色页面上常常"亮得刺眼"。最简单的兜底是给 <img> 套一层降亮:

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

data-no-dim 是一个逃生口——某些图(如已经偏暗的设计稿)不需要再降,用 <img data-no-dim ... /> 豁免。

注意 filter 会创建合成层,大量图片同时 filter 会吃 GPU 显存。生产环境建议优先用 <picture> 换源,filter 作为兜底。

6.2 不同图源:用 <picture> + media 切换

Logo、Hero 图、带透明背景的 PNG,往往需要为亮/暗各准备一张。用 <picture> 元素是最优雅的方案:

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

但注意——<source> 的 media 只认系统偏好,不认你手动加的 .dark class!这意味着三态模型下,用户手动切暗色(但系统是亮色)时,<picture> 不会换图。

工程上的解法有两种:

  1. JS 监听主题变化,动态换 src(最可靠):
    tsx
    const logo = isDark ? '/logo-dark.svg' : '/logo-light.svg';
    return <img src={logo} alt="Logo" />;
    
  2. 两套图都渲染,用 CSS 控制显隐:
    html
    <img className="block dark:hidden" src="/logo-light.svg" alt="Logo" />
    <img className="hidden dark:block" src="/logo-dark.svg" alt="Logo" />
    
    简单粗暴,但多一次资源下载,对性能敏感的场景慎用。

6.3 单色图标:currentColor 与 invert filter

矢量图标(SVG)处理起来更省心。最佳实践是让 SVG 用 fill="currentColor" / stroke="currentColor",颜色自动跟随文字色,亮暗切换零成本:

html
<svg fill="currentColor" viewBox="0 0 24 24">
  <path d="..." />
</svg>

那些没法改源码、又只有亮色版本的第三方图标,可以临时用 invert:

css
/* 暗色下,亮色图标反转——只在万不得已时用,颜色会失真 */
.dark .icon-invert {
  filter: invert(1) hue-rotate(180deg);
}

invert(1) 是粗暴反色,hue-rotate(180deg) 是补偿色相(让非黑白的部分不至于变成怪色)。这只是应急方案,能改 currentColor 就别用 invert。

6.4 prefers-reduced-transparency 与无障碍

顺手提一个容易忽略的无障碍维度。部分用户( Windows 有"禁用透明"设置)会开启 prefers-reduced-transparency,对这类用户应避免半透明背景层叠。还有 prefers-contrast: more(高对比度),可以在三套主题之外再加一套 .high-contrast 变量,进一步照顾视觉障碍用户。暗色模式做深了,最终一定通向"可主题化"这个更大的命题。


七、Next.js + next-themes:别自己造轮子

到这里,你已经能把暗色模式从零搭起来了。但在 Next.js 项目里,强烈推荐直接用 next-themes——它把你前面看到的所有坑(三态模型、FOUC、hydration 适配、SSR 兼容、系统监听)全部封装好了。

7.1 安装与 Provider

bash
npm install next-themes

在 App Router 里,新建一个 Client Provider 包住整棵树:

tsx
// app/providers.tsx
'use client';

import { ThemeProvider } from 'next-themes';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="class"          // 用 class 策略,与上面讲的对齐
      defaultTheme="system"      // 默认跟随系统
      enableSystem               // 启用系统偏好检测
      disableTransitionOnChange  // 切换时禁用过渡动画(避免每个元素都闪一下)
    >
      {children}
    </ThemeProvider>
  );
}
tsx
// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh" suppressHydrationWarning>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

两个细节别漏:

  • <html> 上要加 suppressHydrationWarning。因为 next-themes 会在 hydration 前改 <html> 的 class,React 会以为有人偷偷改了 DOM 而报警——加这个 prop 告诉 React"这块我知道,别管"。
  • disableTransitionOnChange 很实用——切主题时,如果不禁用过渡,页面上每个用 transition 的元素都会各自动画一下,视觉上非常杂乱。这个选项会在切换瞬间临时禁用所有 transition。

7.2 切换组件

tsx
// components/ThemeToggle.tsx
'use client';

import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';

export function ThemeToggle() {
  const { theme, setTheme, resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() => setMounted(true), []);

  if (!mounted) {
    // 防 hydration mismatch,挂载前渲染占位
    return <div className="w-9 h-9" />;
  }

  const isDark = resolvedTheme === 'dark';

  return (
    <button
      onClick={() => setTheme(isDark ? 'light' : 'dark')}
      aria-label="切换主题"
    >
      {isDark ? '☀️' : '🌙'}
    </button>
  );
}

注意 useTheme() 返回的 theme 是用户存的意图(可能是 'system'),resolvedTheme 才是实际应用的主题(一定是 'light' 或 'dark')。判断当前显示什么图标,要用 resolvedTheme。

7.3 为什么推荐 next-themes 而不是手写

把前面六节的所有逻辑——三态 resolve、localStorage 持久化、防 FOUC 的 inline 脚本注入、系统监听、SSR hydration——你手写至少 100 行,每个边界条件都是坑。next-themes 把这些全封装了,并且:

  • 自动在 <head> 注入同步阻塞脚本(解决 FOUC,正是第四节的方案)。
  • 与 React 18/19、App Router、Server Components 兼容。
  • 跨 tab 同步(一个 tab 切主题,其他 tab 自动跟)。
  • 支持自定义 storage key、自定义 class 名、自定义主题列表。

一个判断标准:如果你的主题逻辑超过 30 行手写代码,或者你开始思考"FOUC 怎么办"——停下来,去装 next-themes。这是它存在的全部理由。

当然,理解它内部干了什么(前面七节讲的就是它的源码逻辑)依然重要——出问题时你能定位,需要定制时你能改。用工具不等于不需要懂原理。


八、测试清单:四个必须过的场景

暗色模式写完不算完,必须过这四个测试场景,缺一个都可能上线翻车。

8.1 场景一:系统切换实时跟随

  • 系统设为暗色,打开网站 → 应直接暗色(无白闪)。
  • 在网站打开状态下,切系统主题 → 网站应实时跟随,无需刷新。
  • 这测的是 prefers-color-scheme 的 change 监听是否生效。

8.2 场景二:手动切换持久化

  • 用户在网站手动切到暗色。
  • 关闭浏览器、重新打开网站 → 应保持暗色。
  • 这测的是 localStorage 读写。
  • 额外:把系统切到亮色,再开网站 → 仍应是暗色(说明手动选择优先级高于系统,三态正确)。

8.3 场景三:FOUC 测试(最容易被漏掉)

  • 系统设为暗色,清空缓存,慢速网络(Chrome DevTools 里调 Slow 3G)刷新页面。
  • 仔细观察首屏第一帧——应该是暗色,不能有任何白色闪烁。
  • 这测的是 inline 脚本是否真的在首帧前生效。
  • 提示:开 DevTools 的 Performance 面板,录一次加载,看第一帧 paint 时的背景色。

8.4 场景四:跨浏览器与隐私模式

  • Safari(macOS + iOS):prefers-color-scheme 支持最规范,重点测 iOS 的"智能反色"不要干扰你的图。
  • Chrome/Firefox:标准。
  • 隐私模式(Safari Private、Chrome Incognito):localStorage 可能受限,确认 try/catch 兜底,回退到系统偏好,不报错。
  • 配合屏幕阅读器:确认切换按钮有 aria-label,且 aria-pressed/aria-label 反映当前状态。

8.5 自动化测试的小提示

E2E 测试(Playwright/Cypress)里模拟系统主题,靠的是 emulate 媒体特性,而不是改系统设置:

ts
// Playwright
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
await expect(page.locator('html')).toHaveClass(/dark/);

page.emulateMedia 是无脑改 prefers-color-scheme 的正确姿势,CI 里跑很稳。


小结

回头看,暗色模式的核心其实就一句话:让"决定主题"这件事,发生在第一帧绘制之前。所有的工程复杂度,几乎都围绕这一句展开。

提炼几个要点:

  1. 三态模型是地基——用 'light' | 'dark' | 'system' 管理用户意图,永远别用 boolean。localStorage 存意图、不存结果。
  2. class 策略是社区共识——在 <html> 上加 .dark,配 CSS 变量,组件与主题彻底解耦。
  3. 防 FOUC 是必修课——在 <head> 注入同步 inline 脚本,在首帧前定好 class。SSR 框架里不做这一步,必闪。
  4. 语义 token 决定上限——颜色按"用途"命名而非"色相",组件只认 token,加新主题只是加一套变量。
  5. Next.js 项目直接上 next-themes——它把上面这一切封装好了,但你要懂它内部干了什么。

金句:暗色模式的本质不是换配色,而是把"主题"从硬编码里解放成第一公民——一旦你把它做对了,整个 UI 系统的可维护性会跟着上一个台阶。

Related

相关推荐