暗黑模式早已不是锦上添花的功能,而是现代Web应用的基本配置。在React项目中实现暗黑模式,核心要解决两件事:一是如何感知用户系统层面的深色偏好,二是如何在应用内提供手动切换能力并持久化用户选择。这篇文章会从prefers-color-scheme这个媒体查询入手,一步步给出完整的React实现方案。

一、理解prefers-color-scheme的检测原理
prefers-color-scheme是CSS Media Queries Level 5引入的标准媒体特性,它允许页面感知操作系统级别的主题设置。当用户在Windows、macOS、iOS或Android的设置中切换深色模式时,浏览器会更新这个媒体查询的匹配状态,值为light或dark,部分浏览器还支持no-preference。需要注意的是,这个检测的是系统偏好,而不是浏览器自身的设置,用户必须先在系统层面开启深色模式才会生效。
在纯CSS中可以直接这样使用:
@media (prefers-color-scheme: dark) {
body {
background-color: #1a1a1a;
color: #e0e0e0;
}
}但纯CSS方案有个明显短板:无法与React状态联动。如果用户希望在应用内手动切换主题,或者需要在JS中根据主题动态加载图表配色,就必须借助JavaScript的matchMedia API来读取和监听这个媒体查询。matchMedia接收一个媒体查询字符串,返回一个MediaQueryList对象,通过它的matches属性可以判断当前是否匹配,通过addEventListener监听change事件则能在系统主题变化时收到回调,这是实现主题响应式检测的基石。
二、在React中监听系统主题变化
下面封装一个自定义Hook,把matchMedia的监听逻辑封装成可复用的React状态。这里有几个容易被忽略的细节:监听器必须在组件卸载时清理,否则会造成内存泄漏;部分老浏览器使用addListener而非addEventListener,可以做一个兼容判断;初始值应该直接从matches读取而不是假设为light。
import { useEffect, useState } from 'react';
function useSystemTheme() {
const getInitialTheme = () => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
};
const [systemTheme, setSystemTheme] = useState(getInitialTheme);
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => setSystemTheme(e.matches ? 'dark' : 'light');
if (media.addEventListener) {
media.addEventListener('change', handler);
} else {
media.addListener(handler); // 兼容旧版Safari
}
return () => {
if (media.removeEventListener) {
media.removeEventListener('change', handler);
} else {
media.removeListener(handler);
}
};
}, []);
return systemTheme;
}这个Hook返回的systemTheme会随系统设置实时变化。比如用户在macOS的控制中心切换外观,页面会立即收到change事件并触发重渲染。这种方案的优点是完全响应式,缺点是用户在应用内没有主动权,所以接下来需要叠加一层手动切换逻辑。
三、手动切换与持久化的完整实现
手动切换的经典策略是三态模型:light、dark、system。用户选择system时跟随系统,选择另外两个则强制固定。用户的选择存在localStorage中,而实际生效的主题则通过给document.documentElement添加或移除dark类名来控制,具体的样式变化交给CSS变量完成。
import { useEffect, useState, useCallback } from 'react';
const STORAGE_KEY = 'theme-preference';
function useTheme() {
const [theme, setTheme] = useState(() => {
return localStorage.getItem(STORAGE_KEY) || 'system';
});
const resolvedTheme = theme === 'system'
? useSystemTheme()
: theme;
useEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', resolvedTheme === 'dark');
localStorage.setItem(STORAGE_KEY, theme);
}, [resolvedTheme, theme]);
const setThemeSafe = useCallback((t) => setTheme(t), []);
return { theme, resolvedTheme, setTheme: setThemeSafe };
}CSS侧配合变量和类名切换,样式集中在:root与.dark两个作用域下维护,组件只引用变量名,完全不感知主题逻辑:
:root {
--bg-color: #ffffff;
--text-color: #222222;
}
.dark {
--bg-color: #1a1a1a;
--text-color: #eeeeee;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}切换开关组件则非常简单,一个按钮循环切换三种状态即可。建议在按钮上同时展示当前模式和实际生效的主题,让用户清楚system模式下到底跟随的是浅色还是深色,这个小细节能避免不少困惑。
四、首屏防闪烁与常见坑
暗黑模式最影响体验的问题之一是首屏白闪:用户明明选了深色,页面加载瞬间却先渲染出白色背景再切换回去。原因在于React的JS执行晚于HTML渲染,localStorage里的主题偏好来不及应用。解决办法是在index.html的head里塞一段内联脚本,在React挂载之前就把类名设置好:
<script>
(function () {
var stored = localStorage.getItem('theme-preference');
var dark = stored === 'dark' ||
((!stored || stored === 'system') &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
})();
</script>这段脚本必须内联且尽量靠前,不能以外部文件引入,否则网络延迟依然会导致闪烁。另外还有几个常见坑值得留意:一是Tailwind CSS用户需要配置darkMode为class策略才能配合上述类名方案;二是如果项目中使用了CSS in JS方案如styled-components,可以通过ThemeProvider传递主题对象而非类名;三是避免在useEffect中直接操作body样式而不用清理逻辑,这在StrictMode双调用下容易出问题;四是尊重系统的reduced-motion偏好,主题切换的过渡动画应该克制,0.2到0.3秒的过渡已经足够顺滑。
综合来看,一个体验完善的暗黑模式方案等于系统检测加手动切换加持久化加防闪烁四者的组合。useTheme这个自定义Hook可以进一步抽取到独立文件中复用,配合Context还能在大型应用的任意组件树里方便地读取和修改主题。把这套方案落地后,用户既能享受跟随系统的智能默认值,也保留了随时手动覆盖的自由度。
React暗黑模式prefers-color-scheme主题切换修改时间:2026-09-11 19:58:33