查看: 158|回复: 0

React暗黑模式切换、SSR首屏闪烁及持久化方案

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
暗黑模式切换表面只是给根元素加 dark class,实际落地要同时处理主题持久化、首屏无闪烁、系统偏好跟随、React 状态与 DOM 同步。纯客户端项目与 Next.js 这类 SSR 项目的关键差异在首屏时序,下面按可落地代码拆解。

一、CSS 变量与根元素 class 是最小骨架
用 CSS 变量定义两套颜色,body 使用变量,dark class 挂在 html 上:
  1. :root {
  2.   --bg: #ffffff;
  3.   --text: #1a1a1a;
  4. }
  5. .dark {
  6.   --bg: #1a1a1a;
  7.   --text: #f0f0f0;
  8. }
  9. body {
  10.   background: var(--bg);
  11.   color: var(--text);
  12. }
复制代码
React 侧最小版本用 state 加 useEffect 调 classList.toggle。它只能完成切换,刷新会回到亮色,系统深色用户进站也默认亮色,因此需要持久化。

二、localStorage 持久化与系统偏好兜底
把主题写入 localStorage,useState 的初始值用函数惰性初始化,避免每次渲染都读存储。SSR 下用 typeof window 判断兜底;没有历史值时用 matchMedia('(prefers-color-scheme: dark)') 跟随系统。用户手动切换后以 localStorage 为准。
  1. import { useState, useEffect } from 'react';
  2. function useDarkMode() {
  3.   const [dark, setDark] = useState(() => {
  4.     if (typeof window === 'undefined') return false;
  5.     const saved = localStorage.getItem('theme');
  6.     if (saved) return saved === 'dark';
  7.     return window.matchMedia('(prefers-color-scheme: dark)').matches;
  8.   });
  9.   useEffect(() => {
  10.     document.documentElement.classList.toggle('dark', dark);
  11.     localStorage.setItem('theme', dark ? 'dark' : 'light');
  12.   }, [dark]);
  13.   return [dark, setDark];
  14. }
复制代码
这段逻辑适合 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:
  1. // app/layout.tsx
  2. export default function RootLayout({ children }: { children: React.ReactNode }) {
  3.   return (
  4.     <html lang="zh" suppressHydrationWarning>
  5.       <head>
  6.         <script
  7.           dangerouslySetInnerHTML={{
  8.             __html: `
  9. (function() {
  10.   try {
  11.     var saved = localStorage.getItem('theme');
  12.     var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  13.     var dark = saved ? saved === 'dark' : systemDark;
  14.     if (dark) document.documentElement.classList.add('dark');
  15.   } catch (e) {}
  16. })();
  17. `,
  18.           }}
  19.         />
  20.       </head>
  21.       <body>{children}</body>
  22.     </html>
  23.   );
  24. }
复制代码
关键点:必须内联 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。
  1. 'use client';
  2. import { createContext, useContext, useEffect, useState } from 'react';
  3. const ThemeContext = createContext<{
  4.   dark: boolean;
  5.   toggle: () => void;
  6. }>({ dark: false, toggle: () => {} });
  7. export function ThemeProvider({ children }: { children: React.ReactNode }) {
  8.   const [dark, setDark] = useState(false);
  9.   useEffect(() => {
  10.     setDark(document.documentElement.classList.contains('dark'));
  11.   }, []);
  12.   const toggle = () => {
  13.     setDark(prev => {
  14.       const next = !prev;
  15.       document.documentElement.classList.toggle('dark', next);
  16.       localStorage.setItem('theme', next ? 'dark' : 'light');
  17.       return next;
  18.     });
  19.   };
  20.   return (
  21.     <ThemeContext.Provider value={{ dark, toggle }}>
  22.       {children}
  23.     </ThemeContext.Provider>
  24.   );
  25. }
  26. export const useTheme = () => useContext(ThemeContext);
复制代码
开关组件只消费 useTheme:
  1. 'use client';
  2. import { useTheme } from './ThemeProvider';
  3. export function ThemeToggle() {
  4.   const { dark, toggle } = useTheme();
  5.   return (
  6.     <button onClick={toggle} aria-label="切换主题">
  7.       {dark ? '🌙' : '☀️'}
  8.     </button>
  9.   );
  10. }
复制代码
这样首屏无闪烁由 head 脚本负责,React state 与 UI 一致由 effect 同步负责,并且避免水合警告。

五、实时跟随系统主题变化
用户从未手动切换时,系统在日落自动切深色,页面也应跟随。监听 matchMedia 的 change 事件,但只有 localStorage 没有 theme 时才跟随,用户手动选过就尊重选择。卸载时 removeEventListener 清理,避免反复挂载累积监听。
  1. useEffect(() => {
  2.   const mq = window.matchMedia('(prefers-color-scheme: dark)');
  3.   const handler = (e: MediaQueryListEvent) => {
  4.     if (!localStorage.getItem('theme')) {
  5.       setDark(e.matches);
  6.       document.documentElement.classList.toggle('dark', e.matches);
  7.     }
  8.   };
  9.   mq.addEventListener('change', handler);
  10.   return () => mq.removeEventListener('change', handler);
  11. }, []);
复制代码
排查时按顺序确认:是否 SSR;首屏 HTML 的 html 上有没有 dark class;head 脚本是否同步执行;Context 初始值是否与服务端一致;水合是否报 className 不一致;监听是否清理。纯客户端渲染用 localStorage 加惰性初始化基本够用;SSR 场景必须把主题初始化提前到 head 阻塞脚本。核心记忆点是:闪烁不是 React 切换逻辑慢,而是首屏 HTML 已经画完才切主题,根治方式是在渲染前把 class 打上。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-24 15:33 , Processed in 0.019966 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部