CSS 暗色模式实现:prefers-color-scheme 与 class 切换
暗色模式不是简单反转颜色——要处理系统偏好跟随、手动切换、持久化、闪烁(FOUC)、图片/图标适配。本文讲清 prefers-color-scheme 媒体查询、class 切换策略、localStorage 持久化、防闪烁脚本、CSS 变量主题系统,给一套完整的暗色模式方案。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
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 都不用写:
: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:
/* 用户未表达偏好(少数系统/无障碍设置下会出现) */
@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:
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。
为什么?三个理由:
- 选择器性能/简单性最好:
.dark .card { ... }比属性选择器[data-theme="dark"] .card略快,可读性也更好。 - 与 Tailwind / shadcn 等生态天然兼容:Tailwind 的
dark:变体默认就是 class 策略(darkMode: 'class')。 - CSS 变量覆盖最自然:见第五节,
.dark { --bg: ... }一行就能覆盖整套变量。
/* 默认(亮色)变量定义在 :root */
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
/* 暗色覆盖:只要 html 上有 .dark class,整站变量就切了 */
.dark {
--bg: #0a0a0a;
--text: #ededed;
}
2.2 三态模型:别用 boolean,用 'light' | 'dark' | 'system'
新手最常犯的错误是:用一个 boolean isDark 来管理主题。这会丢掉"跟随系统"这个状态。
正确的模型是三态:
type Theme = 'light' | 'dark' | 'system';
light:强制亮色,无视系统。dark:强制暗色,无视系统。system:跟随系统(默认值,通过prefers-color-scheme判断)。
用户的选择存的是这三个值之一,而实际应用到 DOM 上的 class,是 resolved 之后的结果。这点非常关键,后面持久化时还会用到。
2.3 切换逻辑:把三态 resolve 成实际的 class
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 里也兜一道 */
:root { color-scheme: light; }
.dark { color-scheme: dark; }
2.4 切换按钮:一个坑——hydration 之前别渲染当前态
在 React/Next.js 里写切换按钮时,另一个坑是:服务端不知道当前主题,首屏渲染的图标(🌙 还是 ☀️)很可能和客户端不一致,导致 hydration mismatch 警告。
'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 完整的读取与降级
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。
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 为例,时序是这样的:
- 服务端渲染 HTML,服务端不知道用户主题,只能按默认(亮色)渲染。
- 浏览器收到 HTML,先按亮色画出第一帧。
- 接着 JS bundle 下载、解析、执行,
useEffect里读localStorage、加darkclass。 - 页面从亮变暗——用户眼前闪了一下白光。
对暗色用户来说,每次刷新都闪一下白光,这是极其伤眼的体验(夜间尤为明显)。这个问题的根源是:决定主题的逻辑,跑在了第一帧绘制之后。
4.2 解法:在 CSS 应用之前、首帧绘制之前,注入一段同步阻塞脚本
思路是——在 <head> 里塞一段同步(非 async/defer)的 inline <script>,它在浏览器解析到 <body> 之前就执行完毕,提前把 dark class 加到 <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 挡了),你也可以让浏览器先用系统偏好兜底,避免"雪白一片":
<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 反面教材:在组件里硬编码颜色
/* 千万别这么写 */
.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——给颜色起"按用途"的名字,而不是"按色相"的名字:
: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,对亮暗色一无所知:
.card {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
}
收益是结构性的:
- 切主题 = 换一套变量,组件 CSS 一行不改。
- 改主题 = 改变量定义,一处生效全局生效。
- 想做第三套主题(比如"高对比度模式"),只要再加一套变量,组件代码零改动。
5.3 暗色不是把颜色取反——要注意对比度和层级
暗色模式最容易翻车的是对比度和层级感。
几个经验法则:
- 暗色背景别用纯黑
#000000。纯黑会让阴影完全失效(黑色上看不到更黑的阴影),层级感全无。推荐#0a0a0a~#1a1a1a这种"接近黑但留出阴影空间"的深灰。 - 暗色下主色(primary)通常要提亮。同一个蓝色
#2563eb在白底上对比度够,放到黑底上会显得发暗、发脏,往往要往亮调(如#3b82f6)。 - 层级靠"微微提亮"而非阴影。暗色下卡片背景通常比页面背景亮一点点(如 bg
#0a0a0a、surface#161616),形成自然分层,比单纯堆阴影更和谐。 - 正文别用纯白
#ffffff。纯白文字配深色背景对比过强,长文阅读刺眼,推荐#ededed~#e0e0e0。
一个心法:暗色模式的设计不是"反色",而是"重新调色"。每个颜色都要重新评估在深色背景下的表现。
5.4 配合 Tailwind:一行配置接入
如果用 Tailwind(v3+),把 token 接进来即可,dark: 变体会自动套用 .dark 下的值:
// 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)',
},
},
},
};
<div className="bg-surface text-text border border-border">
卡片内容
</div>
这样连 dark: 前缀都很少需要写——因为颜色本身就是变量,自动跟着 class 切换。
六、图片与图标适配:暗色下的视觉资产
文字和背景搞定了,但还有一个常被遗忘的战场——图片、图标、Logo。
6.1 内容图片:暗色下降亮度
产品截图、Banner 这类内容图,在暗色页面上常常"亮得刺眼"。最简单的兜底是给 <img> 套一层降亮:
.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> 元素是最优雅的方案:
<picture>
<source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)" />
<img src="/logo-light.svg" alt="Logo" />
</picture>
但注意——<source> 的 media 只认系统偏好,不认你手动加的 .dark class!这意味着三态模型下,用户手动切暗色(但系统是亮色)时,<picture> 不会换图。
工程上的解法有两种:
- JS 监听主题变化,动态换
src(最可靠):tsx const logo = isDark ? '/logo-dark.svg' : '/logo-light.svg'; return <img src={logo} alt="Logo" />; - 两套图都渲染,用 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",颜色自动跟随文字色,亮暗切换零成本:
<svg fill="currentColor" viewBox="0 0 24 24">
<path d="..." />
</svg>
那些没法改源码、又只有亮色版本的第三方图标,可以临时用 invert:
/* 暗色下,亮色图标反转——只在万不得已时用,颜色会失真 */
.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
npm install next-themes
在 App Router 里,新建一个 Client Provider 包住整棵树:
// 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>
);
}
// 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 切换组件
// 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 媒体特性,而不是改系统设置:
// Playwright
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
await expect(page.locator('html')).toHaveClass(/dark/);
page.emulateMedia 是无脑改 prefers-color-scheme 的正确姿势,CI 里跑很稳。
小结
回头看,暗色模式的核心其实就一句话:让"决定主题"这件事,发生在第一帧绘制之前。所有的工程复杂度,几乎都围绕这一句展开。
提炼几个要点:
- 三态模型是地基——用
'light' | 'dark' | 'system'管理用户意图,永远别用 boolean。localStorage 存意图、不存结果。 - class 策略是社区共识——在
<html>上加.dark,配 CSS 变量,组件与主题彻底解耦。 - 防 FOUC 是必修课——在
<head>注入同步 inline 脚本,在首帧前定好 class。SSR 框架里不做这一步,必闪。 - 语义 token 决定上限——颜色按"用途"命名而非"色相",组件只认 token,加新主题只是加一套变量。
- Next.js 项目直接上
next-themes——它把上面这一切封装好了,但你要懂它内部干了什么。
金句:暗色模式的本质不是换配色,而是把"主题"从硬编码里解放成第一公民——一旦你把它做对了,整个 UI 系统的可维护性会跟着上一个台阶。
Related