第二章、全局配置项目主题色(主题切换+跟随系统)

全局配置项目主题色(主题切换)

React + TypeScript 项目中实现 全局主题切换(暗黑/亮色模式) 通常有三种常见方案:

1️⃣ CSS变量方案(推荐,简单易维护) 适合需要动态主题切换和性能要求高的项目;

2️⃣ CSS-in-JS(如 styled-components、Emotion)方案

3️⃣ UI库自带方案(如 Ant Design、MUI 的 ThemeProvider)

一、总体思路

全局主题切换的核心是:

不重新渲染页面,通过切换 data-themeclass 来控制 CSS 变量,从而动态改变颜色。

目录结构

css 复制代码
src/
 ├─ context/
 │   └─ ThemeContext.tsx
 ├─ styles/
 │   └─ globals.css
 ├─ index.css
 ├─ App.tsx
 ├─ index.tsx
themes/
 ├─ dark.css
 ├─ light.css

二、实现步骤

1️⃣ 定义全局主题变量

创建 themes/dark.css

css 复制代码
html[data-theme="dark"] {
    --primary: #5dade2;
    --secondary: #58d68d;
    --accent: #e74c3c;
    --background: #121212;
    --surface: #1e1e1e;
    --text: #e0e0e0;
    --text-secondary: 

创建 themes/light.css

css 复制代码
html[data-theme="light"] {
    --primary: #2980b9;
    --secondary: #1abc9c;
    --accent: #f39c12;
    --background: #ecf0f1;
    --surface: #ffffff;
    --text: #2c3e50;
    --text-secondary:

创建 src/styles/globals.css

css 复制代码
@import '../themes/dark.css';
@import '../themes/light.css';

body {
    font-family: sans-serif;
    background-color: var(--background);
    padding: 20px;
    color: var(--text);
}
button {
    background-color: var(--primary);
    color: var(--surface);
}
:root {
    --primary: #3498db;
    --secondary: #2ecc71;
    --background: #f8f9fa;
    --surface: #ffffff;
      --text: #333333;
    --border: #e0e0e0;
}

2️⃣ 在全局样式中使用变量

src/index.css中:

css 复制代码
body {
    font-family: sans-serif;
    background-color: var(--background);
    padding: 20px;
    color: var(--text);
}
button {
    background-color: var(--primary);
    color: var(--surface);
}

3️⃣ 在 React 中控制主题

实现思路

  1. 定义一个 ThemeContext,保存当前主题(light/dark)和修改函数。
  2. ThemeProvider 组件包裹整个应用。
  3. 在任何组件中通过 useContext 获取当前主题和切换方法。
  4. 通过设置 <html data-theme=""> 属性或 CSS 变量来动态切换主题。

src/context/ThemeContext.tsx中, 创建 ThemeContext

tsx 复制代码
import React, { createContext, useContext, useState, useEffect } from "react";

type Theme = "light" | "dark";

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
  setTheme: (theme: Theme) => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [theme, setThemeState] = useState<Theme>(
    (localStorage.getItem("theme") as Theme) || "light"
  );

  // 切换主题
  const toggleTheme = () => {
    const newTheme = theme === "light" ? "dark" : "light";
    setThemeState(newTheme);
    document.documentElement.setAttribute("data-theme", newTheme);
    localStorage.setItem("theme", newTheme);
  };

  // 设置主题
  const setTheme = (newTheme: Theme) => {
    setThemeState(newTheme);
    document.documentElement.setAttribute("data-theme", newTheme);
    localStorage.setItem("theme", newTheme);
  };

  // 初始加载时同步
  useEffect(() => {
    document.documentElement.setAttribute("data-theme", theme);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

// 封装一个 Hook 方便使用
export const useTheme = (): ThemeContextType => {
  const context = useContext(ThemeContext);
  if (!context) throw new Error("useTheme must be used within a ThemeProvider");
  return context;
};

4️⃣在入口文件包裹 Provider

src/index.tsx

tsx 复制代码
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ThemeProvider } from "./context/ThemeContext";
import "./styles/globals.css";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
    <ThemeProvider>
      <App />
    </ThemeProvider>
);

5️⃣在组件中使用主题切换

tsx 复制代码
import React from "react";
import { useTheme } from "./context/ThemeContext";

const App: React.FC = () => {
  const { theme, toggleTheme } = useTheme();

  return (
    <div style={{ padding: 20 }}>
      <h1>🌗 当前主题:{theme}</h1>
      <button onClick={toggleTheme}>
        切换为 {theme === "light" ? "暗黑模式" : "亮色模式"}
      </button>
      <p>这是示例文本,会随主题颜色变化。</p>
    </div>
  );
};

export default App;

说明:

  • 初次加载时会根据 localStorage 读取上次主题。

  • 点击按钮后会:

    1. 修改 React state
    2. 更新 <html data-theme="dark">
    3. 自动切换 CSS 变量样式

整个应用的主题瞬间切换,无需刷新 。

三、支持系统偏好(跟随系统主题)

系统主题检测说明

js 复制代码
const media = window.matchMedia("(prefers-color-scheme: dark)");
  • window.matchMedia() 是浏览器API,用于检测媒体查询
  • (prefers-color-scheme: dark) 查询系统是否启用深色模式
  • media.matches 返回布尔值:true表示系统是深色模式,false表示浅色模式

src/context/ThemeContext.tsx

tsx 复制代码
import React, {
  createContext,
  useContext,
  useEffect,
  useState,
  useCallback,
} from "react";

type Theme = "light" | "dark";

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
  setTheme: (theme: Theme) => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const getInitialTheme = (): Theme => {
    const local = localStorage.getItem("theme") as Theme | null;
    if (local) return local;

    // 没有保存的主题时,根据系统偏好
    const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
    return prefersDark ? "dark" : "light";
  };

  const [theme, setThemeState] = useState<Theme>(getInitialTheme);

  /** 设置主题(含保存与更新 DOM) */
  const setTheme = useCallback((newTheme: Theme) => {
    setThemeState(newTheme);
    document.documentElement.setAttribute("data-theme", newTheme);
    localStorage.setItem("theme", newTheme);
  }, []);

  /** 切换主题 */
  const toggleTheme = useCallback(() => {
    setTheme(theme === "light" ? "dark" : "light");
  }, [theme, setTheme]);

  /** 初始加载时同步 */
  useEffect(() => {
    document.documentElement.setAttribute("data-theme", theme);
  }, [theme]);//必须监听 theme,目的是同步 DOM 与 React 状态

  /** 监听系统主题变化 */
  useEffect(() => {
    const media = window.matchMedia("(prefers-color-scheme: dark)");

    const handleChange = (e: MediaQueryListEvent) => {
      const systemTheme: Theme = e.matches ? "dark" : "light";
      const savedTheme = localStorage.getItem("theme");
      // 仅当用户没有手动设置主题时,才跟随系统
      if (!savedTheme) {
        setTheme(systemTheme);
      }
    };

    media.addEventListener("change", handleChange);
    return () => media.removeEventListener("change", handleChange);
  }, [setTheme]);//如果未来 `setTheme` 改变引用(例如 ThemeProvider 重新创建),effect 会重新绑定监听,保持逻辑一致。

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

export const useTheme = (): ThemeContextType => {
  const context = useContext(ThemeContext);
  if (!context) throw new Error("useTheme must be used within a ThemeProvider");
  return context;
};

四、三种模式(跟随系统 / 亮色 / 暗色)的版本

tsx 复制代码
import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
} from "react";

type ThemeMode = "light" | "dark" | "system";

interface ThemeContextType {
  mode: ThemeMode; // 当前模式
  theme: "light" | "dark"; // 实际应用的主题
  setMode: (mode: ThemeMode) => void;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const getInitialMode = (): ThemeMode => {
    const saved = localStorage.getItem("themeMode") as ThemeMode | null;
    return saved || "system";
  };

  const [mode, setModeState] = useState<ThemeMode>(getInitialMode);
  const [theme, setTheme] = useState<"light" | "dark">("light");

  /** 根据 mode 和系统设置决定最终主题 */
  const applyTheme = useCallback(
    (currentMode: ThemeMode) => {
      if (currentMode === "light") {
        setTheme("light");
        document.documentElement.setAttribute("data-theme", "light");
      } else if (currentMode === "dark") {
        setTheme("dark");
        document.documentElement.setAttribute("data-theme", "dark");
      } else {
        const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
        const systemTheme = prefersDark ? "dark" : "light";
        setTheme(systemTheme);
        document.documentElement.setAttribute("data-theme", systemTheme);
      }
    },
    []
  );

  /** 设置模式(用户选择) */
  const setMode = useCallback(
    (newMode: ThemeMode) => {
      setModeState(newMode);
      localStorage.setItem("themeMode", newMode);
      applyTheme(newMode);
    },
    [applyTheme]
  );

  /** 手动切换亮/暗模式(不影响系统模式) */
  const toggleTheme = useCallback(() => {
    if (mode === "system") {
      // 用户在系统模式下切换 → 固定到相反的主题(如果不做mode值区分,setMode("system" === "light" ? "dark" : "light");第一次切换一定是light)
      setMode(theme === "light" ? "dark" : "light");
    } else {
      setMode(mode === "light" ? "dark" : "light");
    }
  }, [mode, theme, setMode]);

  /** 初始化:同步主题 */
  useEffect(() => {
    applyTheme(mode);
  }, [mode, applyTheme]);

  /** 系统主题变化时,若为 system 模式则自动跟随 */
  useEffect(() => {
    const media = window.matchMedia("(prefers-color-scheme: dark)");
    const handleChange = (e: MediaQueryListEvent) => {
      if (mode === "system") {
        const systemTheme = e.matches ? "dark" : "light";
        setTheme(systemTheme);
        document.documentElement.setAttribute("data-theme", systemTheme);
      }
    };

    media.addEventListener("change", handleChange);
    return () => media.removeEventListener("change", handleChange);
  }, [mode]);

  return (
    <ThemeContext.Provider value={{ mode, theme, setMode, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

export const useTheme = (): ThemeContextType => {
  const context = useContext(ThemeContext);
  if (!context)
    throw new Error("useTheme must be used within a ThemeProvider");
  return context;
};

App.tsx

tsx 复制代码
import React from "react";
import { useTheme } from "./context/ThemeContext";

const App: React.FC = () => {
  const { mode, theme, setMode, toggleTheme } = useTheme();

  return (
    <div style={{ padding: "2rem" }}>
      <h1>🌗 React 三种主题模式</h1>
      <p>当前模式:{mode}</p>
      <p>当前实际主题:{theme}</p>

      <div style={{ display: "flex", gap: "10px", marginBottom: "20px" }}>
        <button onClick={() => setMode("light")}>亮色模式</button>
        <button onClick={() => setMode("dark")}>暗色模式</button>
        <button onClick={() => setMode("system")}>跟随系统</button>
        <button onClick={toggleTheme}>手动切换主题</button>
      </div>

      <p>示例文字会根据主题自动变色。</p>
    </div>
  );
};

export default App;

五、其他说明

1. html[data-theme="dark"][data-theme="dark"] 区别

2.@importimport 区别

3.设置主题和切换主题为什么要用 useCallback?可以不用吗?

useCallback 是为了避免不必要的重新渲染。因为这两个函数会被传递给 Context,如果不使用 useCallback,每次组件重新渲染时都会创建新的函数,导致消费 Context 的子组件不必要的重新渲染。使用 useCallback 可以缓存函数,只有在依赖项变化时才更新函数。

使用 useCallback 的原因

useCallback 可以 缓存函数引用 ,避免每次渲染时都创建新函数 (创建新函数会导致使用了useTheme()子组件重新渲染),防止因引用变化导致子组件不必要地重新渲染。

  1. 性能优化:避免不必要的子组件重渲染
  2. 依赖管理:在 useEffect 中作为依赖时保持引用稳定
  3. 最佳实践:对于传递给 context 的函数,保持稳定引用

👉 为什么有用?

  • 如果你的 ThemeContext 被很多组件订阅,
    那么没有 useCallback 时,每次 ThemeProvider 重新渲染,
    toggleTheme / setTheme 都会是新的函数引用,
    导致所有 useTheme() 的组件都重渲染。

在性能敏感或 Context 使用广泛的项目中,useCallback 是非常推荐的。

⚙️ 如果不写 useCallback 会怎样?

实际上也不会出错,主题切换功能仍然能正常工作。

只是:

  • 每次 ThemeProvider 渲染都会创建新函数引用;
  • 所有用到 useTheme() 的组件都会检测到 context 值变化;
  • 这可能导致 性能浪费(小项目影响不大,大项目明显)

4.切换主题需要监听 setTheme 吗?

  1. 这里的setTheme是一个useCallback 生成的函数,并不是useState中改变状态的setState;同时useCallback监听依赖性变化是重新创建函数,而不是像useEffect一样去执行一遍!
  2. useCallback 的依赖数组是 [theme, setTheme]。这意味着当 themesetTheme 发生变化时,toggleTheme 函数会被重新创建
  3. 为什么需要依赖 theme
    因为函数内部使用了 theme 的值,如果不在依赖数组中包含 theme,那么当 theme 变化时,toggleTheme 函数闭包中的 theme 还是旧值,导致切换逻辑错误。
  4. 为什么需要依赖 setTheme
    因为 setTheme 是稳定的(比如用 useCallback 包装且依赖为空),但为了确保总是调用最新的 setTheme,将其作为依赖是安全的。
相关推荐
sorryhc5 小时前
开源的SSR框架都是怎么实现的?
前端·javascript·架构
前端架构师-老李5 小时前
npm、yarn、pnpm的对比和优略
前端·npm·node.js·pnpm·yarn
fox_5 小时前
别再混淆 call/apply/bind 了!一篇讲透用法、场景与手写逻辑(二)
前端·javascript
潜心编码5 小时前
基于vue的停车场管理系统
前端·javascript·vue.js
神奇的小猴程序员5 小时前
Mutantcat Web Pdf Reader —— 开源、轻量、安全的网页 PDF 阅读器
前端·pdf
三小河5 小时前
React Vite 中动态批量导入路由
前端·vue.js
Qinana6 小时前
📚 论如何用代码谈一场不露脸的恋爱
前端·前端框架·html
Forfun_tt6 小时前
xss-labs pass-10
java·前端·xss
T___T6 小时前
从 "送花被拒" 到 "修成正果":用 JS 揭秘恋爱全流程中的对象与代理魔法
前端·javascript