React

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > React > React暗黑模式切换

基于React实现暗黑模式切换功能

作者:codedevin

暗黑模式看着是个小功能:加个开关,切换 dark class,完事,真自己写一遍才发现全是坑,本文手把手教你从零实现一个能用、不闪、记得住、还能跟随系统的暗黑模式,需要的朋友可以参考下

暗黑模式看着是个小功能:加个开关,切换 dark class,完事。真自己写一遍才发现全是坑:刷新页面主题丢了、用 Next.js 时首屏先闪一下白屏再变黑(那一下白光晃眼睛)、用户明明设了系统深色但你的站默认还是亮色。

这篇把一个「能用、不闪、记得住、还能跟随系统」的暗黑模式从头搭一遍,重点讲清楚那个最烦人的首屏闪烁(FOUC)到底怎么根治。

朴素写法:一个 state 加一个 class

先从最直接的版本开始。用 CSS 变量定义两套颜色,靠根元素上的 dark class 切换:

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

于是时间线变成这样:

  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:

// 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>
  );
}

几个关键点:

这段脚本很小(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 清理,否则组件反复挂载会累积一堆监听器。

小结

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

以上就是基于React实现暗黑模式切换功能的详细内容,更多关于React暗黑模式切换的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文