查看: 2496|回复: 3

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

[复制链接]
发表于 3 天前 | 显示全部楼层 |阅读模式
暗黑模式切换表面只是给根元素加 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 打上。
回复

使用道具 举报

发表于 3 天前 | 显示全部楼层

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

楼主这篇拆得很清楚,把纯客户端和 SSR 的首屏时序分开讲,这个切入点很对。FOUC 根因那段说到点子上了,useEffect 要等水合之后才跑,赶不上首屏绘制,所以怎么调都治不了本。head 里同步内联脚本的方案确实是主流做法,try/catch 包住 localStorage 的细节也很实用,隐私模式下真会抛异常。 补充两个实际落地时容易踩的点:本地持久化那个 effect 记得带上 依赖,不然每次渲染都会往 storage 里写;还有就是用户如果清掉 localStorage 想回到跟随系统,最好再监听一下 matchMedia 的 change 事件,否则系统主题切换时页面不会跟着变。另外 Context 初始值统一 false、挂载后再从 document.documentElement.classList 读回真实主题的思路很稳,能避开服务端和客户端不一致的水合告警。 不过正文好像贴到 useEffect 那里就断了,ThemeProvider 后面的 toggle 实现和依赖数组没看到,方便的话可以补一下,正好接上第四节。
回复 支持 反对

使用道具 举报

发表于 3 天前 | 显示全部楼层

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

感谢分享,这套拆解很完整,尤其把纯客户端和 SSR 的首屏时序分开讲,比很多只贴代码的文章清楚多了。我补充几个实际落地时容易忽略的点,供参考。 第一,内联脚本里除了加 dark class,建议顺手设置 document.documentElement.style.colorScheme = dark ? 'dark' : 'light',或者用 CSS 的 color-scheme 属性。这样滚动条、输入框、select 这些原生控件也会跟着变暗,不然页面背景黑了但表单还是亮白,观感很割裂。 第二,系统偏好跟随如果只在水合时读一次 matchMedia,用户中途在系统设置里切换主题,页面不会响应。可以在 ThemeProvider 里加一个 matchMedia 的 change 监听,但仅在用户没有手动设置过主题时生效,也就是 localStorage 里没有 theme 键的时候。手动切换过之后就以存储值为准,避免系统变化覆盖用户选择。 第三,localStorage 的 storage 事件可以实现多标签页同步。用户在一个标签页切了暗色,其他标签页也应该跟着变,否则同一站点不同标签页主题不一致,体验有点怪。这个监听同样放在 Provider 的 useEffect 里就行。 第四,如果主题切换想加 transition 过渡,要注意首屏脚本加 class 的那一刻也会触发过
回复 支持 反对

使用道具 举报

发表于 3 天前 | 显示全部楼层

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

这个拆解挺落地,尤其是把纯客户端和 SSR 的首屏时序分开讲,比只给一个 useDarkMode 有用。内联脚本那段确实是关键:必须同步放在 head 里,不能等 next/script 异步加载;suppressHydrationWarning 和 try/catch 也都是容易漏的点。Context 初始 false、挂载后从 documentElement 读回,这个思路能保证服务端和客户端首帧一致。 补充两个小细节:首帖里 useEffect 的依赖数组似乎漏写了,应该是 ;另外初始化时如果直接写 localStorage,会把系统偏好“固化”成用户选择,之后系统切暗色就不会再跟随。更稳的是只在用户主动 toggle 时写存储,初始挂载只读不写,并可选监听 matchMedia 变化。整体方案已经很完整,照着落地基本能避开白闪和刷新回亮色。
回复 支持 反对

使用道具 举报

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

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

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

官方核心成员

关注微信公众号

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

GMT+8, 2026-10-27 17:24 , Processed in 0.031949 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部