发布于: -/最后更新: -/4 分钟/

暗色模式的正确打开方式:告别闪烁

摘要

实现暗色模式时,通常会使用ThemeProvider和useTheme hook来管理主题,并将主题偏好存储在localStorage中。但是,这种方法可能会导致主题闪烁问题,即页面刷新时,会先显示默认主题,然后快速切换到用户之前选定的主题。解决这个问题的方法包括使用inline script在页面渲染之前设置主题class,以及使用CSS选择器读取主题class来决定显示哪个图标。通过这些方法,可以避免主题闪烁问题,实现更好的用户体验。

大伙儿一般是怎么实现暗色模式的呢?

多半是搞一个 ThemeProvider,用 React 状态来管理当前主题,再暴露一个 useTheme hook 供组件使用,主题偏好存到 localStorage 里持久化。

这套方案逻辑上没毛病,但实际用起来会遇到一个经典问题——主题闪烁

闪烁从何而来?

刷新页面时,你可能会看到页面先是默认主题(比如亮色),然后"闪"一下变成用户之前选的暗色。

原因很简单:React 的加载时机太晚了

浏览器渲染 HTML 和 CSS 的速度远快于 JavaScript 的解析执行。等到 React 跑起来、从 localStorage 读到用户主题、再更新 DOM 的时候,页面早就渲染完第一帧了。这一帧用的是默认主题,下一帧才切到正确主题,于是就"闪"了。

解法一:Inline Script 抢跑

既然 React 太慢,那就让原生 JS 先跑。

核心思路是:在 <head> 里注入一段同步执行的 inline script,它会在页面渲染之前读取 localStorage 并设置好主题 class。

TSX
const themeScript = `
  (function() {
    try {
      const stored = localStorage.getItem("ui-theme") || "system";
      const theme = ["light", "dark", "system"].includes(stored) ? stored : "system";

      if (theme === "system") {
        const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
          ? "dark"
          : "light";
        document.documentElement.classList.add(systemTheme, "system");
      } else {
        document.documentElement.classList.add(theme);
      }
    } catch {
      // localStorage 不可用时,fallback 到系统主题
      const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark"
        : "light";
      document.documentElement.classList.add(systemTheme, "system");
    }
  })();
`;

在 TanStack Start 中,可以用 ScriptOnce 组件来注入:

TSX
import { ScriptOnce } from "@tanstack/react-router";

export function ThemeProvider({ children }: { children: ReactNode }) {
  // ...状态管理逻辑

  return (
    <ThemeContext value={{ userTheme, appTheme, setTheme }}>
      <ScriptOnce children={themeScript} />
      {children}
    </ThemeContext>
  );
}

这样,页面首次渲染时 <html> 上就已经有了正确的 class(如 class="dark"),CSS 直接生效,不闪。

解法二:CSS 读取主题状态

Inline script 解决了设置主题的时机问题,但还有个细节容易被忽略——主题切换按钮的图标

假设你的按钮要根据当前主题显示不同图标:亮色显示太阳、暗色显示月亮、跟随系统显示显示器。如果用 React 状态来控制图标渲染,同样会有闪烁问题,因为 React 还是那个"慢一拍"的 React。

解决方法出奇地简单:让 CSS 来决定显示哪个图标

CSS 的解析速度比 JS 快得多,而且我们的 inline script 已经保证了 <html> 上有正确的 class。只需要用 CSS 选择器匹配这些 class,就能在第一帧就显示正确的图标。

在按钮组件里,用 Tailwind 的任意选择器语法让 CSS 控制图标显隐:

TSX
import { Monitor, Moon, Sun } from "lucide-react";

export function ThemeToggle() {
  const { userTheme, setTheme } = useTheme();
  // ...切换逻辑

  return (
    <button onClick={toggleTheme} title={`Theme: ${userTheme}`}>
      <div className="relative flex items-center justify-center w-4 h-4">
        {/* 亮色模式(非跟随系统):显示太阳 */}
        <span className="hidden [.light:not(.system)_&]:block">
          <Sun size={14} />
        </span>

        {/* 暗色模式(非跟随系统):显示月亮 */}
        <span className="hidden [.dark:not(.system)_&]:block">
          <Moon size={14} />
        </span>

        {/* 跟随系统:显示显示器 */}
        <span className="hidden [.system_&]:block">
          <Monitor size={14} />
        </span>
      </div>
    </button>
  );
}

这里用到了 Tailwind 的任意值语法 [selector],可以直接写原生 CSS 选择器。[.light:not(.system)_&]:block 的意思是:当祖先元素有 .light 类但没有 .system 类时,应用 display: block

CSS
@custom-variant dark (&:is(.dark *));
@custom-variant light (&:is(.light *));
@custom-variant system (&:is(.system *));

三个图标始终都在 DOM 里,但只有一个是 block,其余都是 hidden。CSS 根据 <html> 上的 class 瞬间决定谁显示,零闪烁。

小结

问题

原因

解法

页面主题闪烁

React 加载晚于首次渲染

Inline script 提前设置主题 class

按钮图标闪烁

React 状态更新晚

CSS 选择器读取主题 class

核心思想就一句话:能用 CSS 解决的,就别等 JS

正文结束