暗黑模式切换表面只是给根元素加 dark class,实际落地要同时处理主题持久化、首屏无闪烁、系统偏好跟随、React 状态与 DOM 同步。纯客户端项目与 Next.js 这类 SSR 项目的关键差异在首屏时序,下面按可落地代码拆解。
一、CSS 变量与根元素 class 是最小骨架
用 CSS 变量定义两套颜色,body 使用变量,dark class 挂在 html 上:- :root {
- --bg: #ffffff;
- --text: #1a1a1a;
- }
- .dark {
- --bg: #1a1a1a;
- --text: #f0f0f0;
- }
- body {
- background: var(--bg);
- color: var(--text);
- }
复制代码 React 侧最小版本用 state 加 useEffect 调 classList.toggle。它只能完成切换,刷新会回到亮色,系统深色用户进站也默认亮色,因此需要持久化。
二、localStorage 持久化与系统偏好兜底
把主题写入 localStorage,useState 的初始值用函数惰性初始化,避免每次渲染都读存储。SSR 下用 typeof window 判断兜底;没有历史值时用 matchMedia('(prefers-color-scheme: dark)') 跟随系统。用户手动切换后以 localStorage 为准。- import { useState, useEffect } from 'react';
- function useDarkMode() {
- const [dark, setDark] = useState(() => {
- if (typeof window === 'undefined') return false;
- 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];
- }
复制代码 这段逻辑适合 CRA、Vite 等纯客户端渲染。到了服务端渲染,刷新持久化仍然有效,但首屏闪烁问题会暴露出来。
三、SSR 首屏闪烁 FOUC 的根因
Next.js 先在服务端输出 HTML,服务端读不到 localStorage 和系统偏好,只能先吐默认主题,常见是亮色。浏览器先画出白底,JS 加载并完成 React 水合后,useEffect 才执行并加 dark class,页面从白闪成黑。useEffect 无法根治,因为它在浏览器画完首屏之后才运行。解决路径是在 React 之前、body 解析之前,用 head 中同步内联脚本读 localStorage 或系统偏好,并立即给 html 加 class。Next.js App Router 可放在 app/layout.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 处理服务端与客户端 className 差异;try/catch 包住 localStorage 访问,隐私模式可能抛异常。脚本 gzip 后几百字节,阻塞成本可忽略,换来零闪烁。next-themes 这类库内部也采用注入脚本思路。
四、Context 共享状态与首帧同步
组件树中多处需要读当前主题,用 Context 分发 toggle 和 dark。注意 Context 的 state 初始值要与服务端一致,统一 false;真实主题由 head 脚本先落到 DOM,挂载后在 useEffect 中从 document.documentElement.classList.contains('dark') 读回。toggle 时同步更新 class 和 localStorage。- '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 }) {
- const [dark, setDark] = useState(false);
- useEffect(() => {
- 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);
复制代码 开关组件只消费 useTheme:- 'use client';
- import { useTheme } from './ThemeProvider';
- export function ThemeToggle() {
- const { dark, toggle } = useTheme();
- return (
- <button onClick={toggle} aria-label="切换主题">
- {dark ? '🌙' : '☀️'}
- </button>
- );
- }
复制代码 这样首屏无闪烁由 head 脚本负责,React state 与 UI 一致由 effect 同步负责,并且避免水合警告。
五、实时跟随系统主题变化
用户从未手动切换时,系统在日落自动切深色,页面也应跟随。监听 matchMedia 的 change 事件,但只有 localStorage 没有 theme 时才跟随,用户手动选过就尊重选择。卸载时 removeEventListener 清理,避免反复挂载累积监听。- useEffect(() => {
- const mq = window.matchMedia('(prefers-color-scheme: dark)');
- const handler = (e: MediaQueryListEvent) => {
- if (!localStorage.getItem('theme')) {
- setDark(e.matches);
- document.documentElement.classList.toggle('dark', e.matches);
- }
- };
- mq.addEventListener('change', handler);
- return () => mq.removeEventListener('change', handler);
- }, []);
复制代码 排查时按顺序确认:是否 SSR;首屏 HTML 的 html 上有没有 dark class;head 脚本是否同步执行;Context 初始值是否与服务端一致;水合是否报 className 不一致;监听是否清理。纯客户端渲染用 localStorage 加惰性初始化基本够用;SSR 场景必须把主题初始化提前到 head 阻塞脚本。核心记忆点是:闪烁不是 React 切换逻辑慢,而是首屏 HTML 已经画完才切主题,根治方式是在渲染前把 class 打上。 |