React 实现暗黑模式切换:localStorage 持久化、SSR 首屏闪烁与跟随系统主题
React 实现暗黑模式切换:localStorage 持久化、SSR 首屏闪烁与跟随系统主题暗黑模式看着是个小功能:加个开关,切换darkclass,完事。真自己写一遍才发现全是坑:刷新页面主题丢了、用 Next.js 时首屏先闪一下白屏再变黑(那一下白光晃眼睛)、用户明明设了系统深色但你的站默认还是亮色。这篇把一个「能用、不闪、记得住、还能跟随系统」的暗黑模式从头搭一遍,重点讲清楚那个最烦人的首屏闪烁(FOUC)到底怎么根治。朴素写法:一个 state 加一个 class先从最直接的版本开始。用 CSS 变量定义两套颜色,靠根元素上的darkclass 切换:/* globals.css */:root{--bg:#ffffff;--text:#1a1a1a;}.dark{--bg:#1a1a1a;--text:#f0f0f0;}body{background:var(--bg);color:var(--text);}React 里用 state 控制: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: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:// app/layout.tsx export default function RootLayout({ children }: { children: React.ReactNode }) { return ( html langzh 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: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 同步的),还不触发水合警告。开关组件:use client; import { useTheme } from ./ThemeProvider; export function ThemeToggle() { const { dark, toggle } useTheme(); return ( button onClick{toggle} aria-label切换主题 {dark ? : ☀️} /button ); }进阶:实时响应系统主题变化如果用户从没手动切过(跟随系统),那当系统在「日落自动切深色」时,你的站应该也跟着变。监听matchMedia的change事件: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.togglelocalStorage做持久化,初始值用惰性初始化避免每次渲染都读。默认跟随系统用matchMedia((prefers-color-scheme: dark));用户手动切过一次后以 localStorage 为准。SSR 首屏闪烁的根因:服务端读不到 localStorage,只能吐默认主题,水合后才切换 → 闪一下。useEffect救不了(它在首屏之后才跑)。根治:在head塞一段同步内联脚本,渲染前就把 class 加到html;html加suppressHydrationWarning消除水合警告。Context 里 state 初始值和服务端保持一致(false),挂载后用useEffect从 DOM 现状同步;监听matchMedia的change实时跟随系统时,记得只在用户没手动设过时才跟随,并在卸载时清理监听。一句话记忆:闪烁不是 React 的锅,是「首屏 HTML 已经画完才切主题」,唯一解是在渲染前用一段阻塞脚本抢先把 class 打上。