React 实现暗黑模式切换:localStorage 持久化、SSR 首屏闪烁与跟随系统主题

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 一定是「默认主题」(通常是亮色)。

于是时间线变成这样:

  1. 服务端吐出亮色 HTML,浏览器先画出白底;
  2. JS 加载、React 水合(hydrate),useEffect 才跑,这时才加上 dark class;
  3. 页面从白闪成黑

那一下白光就是 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>
  );
}

进阶:实时响应系统主题变化

如果用户从没手动切过(跟随系统),那当系统在「日落自动切深色」时,你的站应该也跟着变。监听 matchMediachange 事件:

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 用变量 + 根元素 dark class 切换两套主题;React 侧用 classList.toggle + localStorage 做持久化,初始值用惰性初始化避免每次渲染都读。
  • 默认跟随系统用 matchMedia('(prefers-color-scheme: dark)');用户手动切过一次后以 localStorage 为准。
  • SSR 首屏闪烁的根因 :服务端读不到 localStorage,只能吐默认主题,水合后才切换 → 闪一下。useEffect 救不了(它在首屏之后才跑)。
  • 根治 :在 <head> 塞一段同步内联脚本 ,渲染前就把 class 加到 <html>;<html>suppressHydrationWarning 消除水合警告。
  • Context 里 state 初始值和服务端保持一致(false),挂载后用 useEffect 从 DOM 现状同步;监听 matchMediachange 实时跟随系统时,记得只在用户没手动设过时才跟随,并在卸载时清理监听。

一句话记忆:闪烁不是 React 的锅,是「首屏 HTML 已经画完才切主题」,唯一解是在渲染前用一段阻塞脚本抢先把 class 打上。

相关推荐
自动化监测Learner2 小时前
主流 Web 端地图引擎对比:选型指南与优劣分析
javascript
console.log('npc')2 小时前
Git 冲突与 AI 协助指南
前端·人工智能·git·大模型
爱学堂IT分享3 小时前
Cesium可视化系统实战课程-Cesium教程学习
前端
糖墨夕4 小时前
理解大语言模型:Agent 的“大脑”
前端·agent
Rain的Java大神之路4 小时前
JavaWeb开发如何解决跨域问题
java·前端·后端·nginx·web安全·面试·运维开发
cpolar技术支持4 小时前
浏览器也能跑本地 AI:用 Transformers.js + WebGPU 做一个最小推理 Demo,cpolar 给同事远程体验
前端·ai·cpolar·webgpu·transformers.js
计算机魔术师6 小时前
OpenAI 发布 GPT-6 Astra:多项基准刷新纪录, cybersecurity 能力达 Critical 阈值
前端
xy34536 小时前
Axure9.0中继器遮罩实现方法
前端·ui·html·axure·原型·产品设计
风骏时光牛马6 小时前
智能任务自动化协同AI工作流
前端