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

 更新时间:2026年09月08日 09:42:19   作者: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>
  );
}

几个关键点:

  • 必须是内联 <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 用变量 + 根元素 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 现状同步;监听 matchMedia 的 change 实时跟随系统时,记得只在用户没手动设过时才跟随,并在卸载时清理监听。

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

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

相关文章

  • React Router中Link和NavLink的学习心得总结

    React Router中Link和NavLink的学习心得总结

    这篇文章主要介绍了React Router中Link和NavLink的学习心得总结,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2022-12-12
  • 简单介绍react redux的中间件的使用

    简单介绍react redux的中间件的使用

    这篇文章主要介绍了简单介绍redux的中间件的使用,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-04-04
  • 详解React自定义Hook

    详解React自定义Hook

    在React项目中,我们经常会使用到React自带的几个内置Hooks,如 useState,useContext和useEffect。虽然在React中找不到这些 Hooks,但React提供了非常灵活的方式让你为自己的需求来创建自己的自定义Hooks,想了解更多的,欢迎阅读本文
    2023-04-04
  • react数组循环渲染过程

    react数组循环渲染过程

    在React中,我们使用map方法对数组进行循环渲染,并生成对应的jsx结构,为了确保React的diff算法可以正确地处理dom对象,我们需要为每个生成的元素添加key属性,注意:虽然不添加key属性代码可以运行,但会报错
    2026-05-05
  • React Context详解使用过程

    React Context详解使用过程

    在Reactor中提供了Context来替代ThreadLocal,可以实现一个跨线程的共享变量的透明方式。本文主要为大家介绍了Context的用法的用法,感兴趣的可以了解一下
    2023-03-03
  • React特征Form 单向数据流示例详解

    React特征Form 单向数据流示例详解

    这篇文章主要为大家介绍了React特征Form 单向数据流示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-09-09
  • React中useReducer和useState的实现示例

    React中useReducer和useState的实现示例

    本文主要介绍了React中useReducer和useState的实现示例,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2026-08-08
  • React中实现 RTF 与 PDF、HTML 的文档转换功能

    React中实现 RTF 与 PDF、HTML 的文档转换功能

    在日常的文档处理工作中,RTF凭借其良好的兼容性,常被用作跨软件交换文档的中间格式,本文介绍一种在 React 环境中将 RTF 文件转换为 PDF 和 HTML 的实现方案,有需要的可以了解下
    2026-09-09
  • create-react-app常用自定义配置教程示例

    create-react-app常用自定义配置教程示例

    这篇文章主要为大家介绍了create-react-app常用自定义配置教程示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-06-06
  • 关于react hook useState连续更新对象的问题

    关于react hook useState连续更新对象的问题

    这篇文章主要介绍了关于react hook useState连续更新对象的问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2024-03-03

最新评论