You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

5.7 KiB

主题系统

文档版本:v1.1
最后更新:2025-01-18


目录

  1. 技术方案
  2. 主题 Store
  3. Theme Provider
  4. 防止主题闪烁
  5. 主题切换组件

1. 技术方案

使用 CSS 变量 + class 切换实现主题系统,支持深色、亮色和跟随系统。


2. 主题 Store

// src/stores/themeStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";

type Theme = "dark" | "light" | "system";
type ResolvedTheme = "dark" | "light";

interface ThemeState {
  theme: Theme;
  resolvedTheme: ResolvedTheme;
  setTheme: (theme: Theme) => void;
}

// 获取系统主题偏好
const getSystemTheme = (): ResolvedTheme => {
  if (typeof window === "undefined") return "dark";
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
};

// 应用主题到 DOM
const applyTheme = (theme: ResolvedTheme) => {
  const root = document.documentElement;
  root.classList.remove("light", "dark");
  root.classList.add(theme);
};

export const useThemeStore = create<ThemeState>()(
  persist(
    (set, get) => ({
      theme: "dark",
      resolvedTheme: "dark",

      setTheme: (theme) => {
        const resolvedTheme = theme === "system" ? getSystemTheme() : theme;
        applyTheme(resolvedTheme);
        set({ theme, resolvedTheme });
      },
    }),
    {
      name: "theme-store",
      onRehydrateStorage: () => (state) => {
        if (state) {
          const resolvedTheme =
            state.theme === "system" ? getSystemTheme() : state.theme;
          applyTheme(resolvedTheme);
          state.resolvedTheme = resolvedTheme;
        }
      },
    },
  ),
);

// 监听系统主题变化
if (typeof window !== "undefined") {
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .addEventListener("change", (e) => {
      const { theme, setTheme } = useThemeStore.getState();
      if (theme === "system") {
        setTheme("system"); // 重新计算 resolvedTheme
      }
    });
}

3. Theme Provider

// src/app/providers/ThemeProvider.tsx
import { useEffect } from "react";
import { useThemeStore } from "@/stores/themeStore";

interface ThemeProviderProps {
  children: React.ReactNode;
  defaultTheme?: "dark" | "light" | "system";
}

export function ThemeProvider({
  children,
  defaultTheme = "dark",
}: ThemeProviderProps) {
  const { theme, setTheme } = useThemeStore();

  // 初始化主题
  useEffect(() => {
    // 防止首次加载时的闪烁
    const savedTheme = localStorage.getItem("theme-store");
    if (!savedTheme) {
      setTheme(defaultTheme);
    }
  }, []);

  // 监听系统主题变化
  useEffect(() => {
    if (theme !== "system") return;

    const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
    const handleChange = () => setTheme("system");

    mediaQuery.addEventListener("change", handleChange);
    return () => mediaQuery.removeEventListener("change", handleChange);
  }, [theme, setTheme]);

  return <>{children}</>;
}

4. 防止主题闪烁

index.html<head> 中添加内联脚本:

<!-- index.html -->
<head>
  <!-- ... -->
  <script>
    (function () {
      try {
        const stored = localStorage.getItem("theme-store");
        const parsed = stored ? JSON.parse(stored) : null;
        const theme = parsed?.state?.theme || "dark";

        let resolvedTheme = theme;
        if (theme === "system") {
          resolvedTheme = window.matchMedia("(prefers-color-scheme: dark)")
            .matches
            ? "dark"
            : "light";
        }

        document.documentElement.classList.add(resolvedTheme);
      } catch (e) {
        document.documentElement.classList.add("dark");
      }
    })();
  </script>
</head>

5. 主题切换组件

// src/components/common/ThemeToggle.tsx
import { Moon, Sun, Monitor } from "lucide-react";
import { useThemeStore } from "@/stores/themeStore";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslation } from "react-i18next";

export function ThemeToggle() {
  const { t } = useTranslation();
  const { theme, setTheme, resolvedTheme } = useThemeStore();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          {resolvedTheme === "dark" ? (
            <Moon className="h-4 w-4" />
          ) : (
            <Sun className="h-4 w-4" />
          )}
          <span className="sr-only">{t("settings.theme.toggle")}</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          <Sun className="mr-2 h-4 w-4" />
          {t("settings.theme.light")}
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          <Moon className="mr-2 h-4 w-4" />
          {t("settings.theme.dark")}
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          <Monitor className="mr-2 h-4 w-4" />
          {t("settings.theme.system")}
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

6. Tailwind 深色模式配置

确保 tailwind.config.js 中配置了 class 策略:

// tailwind.config.js
module.exports = {
  darkMode: ["class"], // 使用 class 策略
  // ...
};

相关文档


最后更新:2025-01-18 | 版本:v1.1