React 实现暗黑模式切换:localStorage 持久化、SSR 首屏闪烁与跟随系统主题
暗黑模式看着是个小功能:加个开关,切换 dark class,完事。真自己写一遍才发现全是坑:刷新页面主题丢了、用 Next.js 时首屏先闪一下白屏再变黑(那一下白光晃眼睛)、用户明明设了系统深色但你的站默认还是亮色。
这篇把一个「能用、不闪、记得住、还能跟随系统」的暗黑模式从头搭一遍,重点讲清楚那个最烦人的首屏闪烁(FOUC)到底怎么根治。
朴素写法:一个 state 加一个 class
先从最直接的版本开始。用 CSS 变量定义两套颜色,靠根元素上的 dark class 切换:
css
/* globals.css */
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
.dark {
--bg: #1a1a1a;
--text: #f0f0f0;
}
body {
background: var(--bg);
color: var(--text);
}
React 里用 state 控制:
jsx
import { useState, useEffect } from 'react';
function App() {
const [dark, setDark] = useState(false);
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
}, [dark]);
return (
<button onClick={() => setDark(d => !d)}>
{dark ? '切到亮色' : '切到暗色'}
</button>
);
}
能切了,但有两个明显问题:刷新页面就回到亮色 (state 没持久化),而且每次进站默认亮色,不管用户系统是不是深色。
第一步:持久化到 localStorage
把选择存进 localStorage,初始化时读回来。注意初始值要用惰性初始化 (给 useState 传函数),否则每次渲染都会读一遍 localStorage:
jsx
function useDarkMode() {
const [dark, setDark] = useState(() => {
// 惰性初始化:只在首次挂载时执行一次
if (typeof window === 'undefined') return false; // SSR 兜底
const saved = localStorage.getItem('theme');
if (saved) return saved === 'dark';
// 没存过就跟随系统偏好
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem('theme', dark ? 'dark' : 'light');
}, [dark]);
return [dark, setDark];
}
这里已经顺手做了两件事:读 localStorage 里的历史选择;没有历史选择时用 matchMedia('(prefers-color-scheme: dark)') 跟随系统。用户手动切过一次就以他的选择为准(存进 localStorage),没切过就跟系统走------这是符合直觉的行为。
现在刷新不丢主题了。但如果你用的是纯客户端渲染(CRA、Vite),到这里基本够用。真正的麻烦出在服务端渲染(Next.js)场景。
核心难题:SSR 的首屏闪烁(FOUC)
Next.js 会先在服务端把 HTML 渲染好发给浏览器。问题是:服务端根本读不到 localStorage,也读不到用户的系统偏好------那些都是浏览器端的东西。所以服务端渲染出来的 HTML 一定是「默认主题」(通常是亮色)。
于是时间线变成这样:
- 服务端吐出亮色 HTML,浏览器先画出白底;
- JS 加载、React 水合(hydrate),
useEffect才跑,这时才加上darkclass; - 页面从白闪成黑。
那一下白光就是 FOUC(Flash of Unstyled Content)。对深色模式用户尤其难受,大晚上开个页面先闪一道白光。
useEffect 天生救不了它------effect 一定在浏览器画完首屏之后才执行。要根治,必须在 React 之前、在浏览器解析 body 之前就把 class 加上。
根治闪烁:在 <head> 里插一段阻塞脚本
标准解法是塞一段极小的内联脚本到 <head>,让它在页面渲染前同步 执行,读 localStorage/系统偏好并立刻给 <html> 打上 class。因为是同步阻塞脚本,浏览器会先跑完它再画 body,首屏就直接是正确颜色,没有闪烁。
Next.js App Router 里放进 app/layout.tsx:
tsx
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var saved = localStorage.getItem('theme');
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var dark = saved ? saved === 'dark' : systemDark;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {}
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
几个关键点:
- 必须是内联
<script>,不能用next/script的默认策略------那些会异步加载,来不及在首屏前执行。这里就要它同步阻塞。 <html>上加suppressHydrationWarning。因为脚本会在客户端改<html>的 className,而服务端渲染时没这个 class,React 水合时会警告「服务端/客户端不一致」。这个属性告诉 React:这个元素的属性差异是预期的,别报警。try/catch包住:某些隐私模式下localStorage访问会抛异常,包一下防止整段脚本挂掉。
这段脚本很小(gzip 后几百字节),阻塞时间可以忽略,换来的是零闪烁。这也是 next-themes 这类成熟库内部的做法------它本质上就是帮你注入这段脚本。
把 Context 加上:全站共享主题状态
组件里到处要读「当前是不是暗色」,用 Context 分发比层层传 props 干净。注意这里 state 的初始值仍然要处理 SSR:
tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext<{
dark: boolean;
toggle: () => void;
}>({ dark: false, toggle: () => {} });
export function ThemeProvider({ children }: { children: React.ReactNode }) {
// 服务端与首帧统一用 false,避免水合不一致;真实值由 head 脚本先落到 DOM 上
const [dark, setDark] = useState(false);
useEffect(() => {
// 挂载后从 DOM 现状同步(head 脚本已经把 class 加好了)
setDark(document.documentElement.classList.contains('dark'));
}, []);
const toggle = () => {
setDark(prev => {
const next = !prev;
document.documentElement.classList.toggle('dark', next);
localStorage.setItem('theme', next ? 'dark' : 'light');
return next;
});
};
return (
<ThemeContext.Provider value={{ dark, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
这里有个容易忽略的细节:useState(false) 的初始值和服务端保持一致(都是 false),真正的主题由 head 脚本先加到 DOM 上 ,组件挂载后再用 useEffect 从 DOM 现状「读回来」同步给 state。这样既保证了首屏无闪烁(head 脚本管的),又保证了 React state 和 UI 一致(effect 同步的),还不触发水合警告。
开关组件:
tsx
'use client';
import { useTheme } from './ThemeProvider';
export function ThemeToggle() {
const { dark, toggle } = useTheme();
return (
<button onClick={toggle} aria-label="切换主题">
{dark ? '🌙' : '☀️'}
</button>
);
}
进阶:实时响应系统主题变化
如果用户从没手动切过(跟随系统),那当系统在「日落自动切深色」时,你的站应该也跟着变。监听 matchMedia 的 change 事件:
tsx
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
// 仅当用户没手动设过(localStorage 为空)才跟随系统
if (!localStorage.getItem('theme')) {
setDark(e.matches);
document.documentElement.classList.toggle('dark', e.matches);
}
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler); // 卸载时清理,别泄漏监听
}, []);
判断条件 !localStorage.getItem('theme') 很关键:用户手动选过就尊重他的选择,别让系统切换覆盖掉用户的主动设置。记得在 return 里 removeEventListener 清理,否则组件反复挂载会累积一堆监听器。
小结
- CSS 用变量 + 根元素
darkclass 切换两套主题;React 侧用classList.toggle+localStorage做持久化,初始值用惰性初始化避免每次渲染都读。 - 默认跟随系统用
matchMedia('(prefers-color-scheme: dark)');用户手动切过一次后以 localStorage 为准。 - SSR 首屏闪烁的根因 :服务端读不到 localStorage,只能吐默认主题,水合后才切换 → 闪一下。
useEffect救不了(它在首屏之后才跑)。 - 根治 :在
<head>塞一段同步内联脚本 ,渲染前就把 class 加到<html>;<html>加suppressHydrationWarning消除水合警告。 - Context 里 state 初始值和服务端保持一致(false),挂载后用
useEffect从 DOM 现状同步;监听matchMedia的change实时跟随系统时,记得只在用户没手动设过时才跟随,并在卸载时清理监听。
一句话记忆:闪烁不是 React 的锅,是「首屏 HTML 已经画完才切主题」,唯一解是在渲染前用一段阻塞脚本抢先把 class 打上。