React 与 操作系统深色模式感知:在 React 渲染引擎底层实现对系统主题变更的零延迟响应逻辑

React 与操作系统深色模式感知:在 React 渲染引擎底层实现对系统主题变更的零延迟响应逻辑

各位编程界的同仁,大家好!

今天我们要聊的是一个既关乎“用户体验”,又关乎“底层原理”,甚至还能让你在周五下午的代码审查中显得特别高深的话题——深色模式

你有没有遇到过这种尴尬时刻?深夜两点,你正准备在手机上处理一些紧急工作。你的手机屏幕突然黑了,为了省电,也为了保护你的视网膜,操作系统自动切到了“深色模式”。你满怀期待地打开你的 Web 应用,心想:“太好了,终于不用在黑暗中盯着刺眼的白底黑字了。”

结果呢?你的 Web 应用依然穿着一身亮瞎眼的白西装,优雅地站在那里。你的应用没有意识到,你的操作系统已经“黑化”了。那一刻,你的应用就像是一个穿着燕尾服去参加丧礼的宾客,格格不入。

这不仅仅是审美问题,这是背叛。而我们今天要做的,就是防止这种背叛。

我们要深入到 React 渲染引擎的底层,去探讨如何实现“零延迟”响应系统主题变更。这不仅仅是监听一个事件那么简单,这涉及到浏览器 API、React 的调度机制、CSS 变量以及我们如何欺骗浏览器在绘制之前就完成切换。

准备好了吗?让我们开始这场技术探险。


第一章:浏览器的“间谍”机制——matchMedia

首先,我们要明白一件事:浏览器和操作系统是两个不同的世界。React 是运行在浏览器里的 JavaScript 引擎,而操作系统(无论是 Windows、macOS、Android 还是 iOS)有自己的主题设置。

React 是怎么知道操作系统想换衣服的呢?这全靠浏览器提供的matchMedia API

这个 API 就像是浏览器派往操作系统的间谍。它允许你的网页代码去询问浏览器:“嘿,兄弟,系统现在喜欢深色还是浅色?” 浏览器会返回一个监听器,一旦操作系统的主题发生改变,这个监听器就会触发。

让我们看一个最基础的代码示例:

// 这是一个非常古老的“土办法”
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

console.log('初始主题:', mediaQuery.matches ? '深色' : '浅色');

// 监听变化
mediaQuery.addEventListener('change', (event) => {
  console.log('系统变了!现在是:', event.matches ? '深色' : '浅色');
  // 这里我们要更新我们的应用状态
});

这段代码看起来很正常,对吧?但在 React 的世界里,这仅仅是开始。如果我们在组件里直接这么写,那简直是一场灾难。


第二章:旧时代的悲剧——useEffect 的闪烁

在 React 18 之前,我们处理主题切换通常是这样的:

import { useEffect, useState } from 'react';

function MyComponent() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    // 设置初始状态
    setTheme(mediaQuery.matches ? 'dark' : 'light');

    // 添加监听器
    const handleChange = (e) => setTheme(e.matches ? 'dark' : 'light');
    mediaQuery.addEventListener('change', handleChange);

    // 清理函数
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, []);

  return (
    <div style={{ backgroundColor: theme === 'dark' ? '#333' : '#fff', color: theme === 'dark' ? '#fff' : '#000' }}>
      当前主题: {theme}
    </div>
  );
}

这段代码有什么问题?它有个致命的缺陷:异步

当你的组件第一次渲染时,useEffect 还没执行,所以 theme 状态是 'light'。此时,浏览器根据 React 的渲染结果把页面画了出来——全是白色的。然后,useEffect 执行了,发现系统是深色,于是把 theme 更新为 'dark'。React 再次渲染,页面变成了黑色。

这就是我们常说的 FOUC (Flash of Unstyled Content),或者是主题闪烁。用户会先看到一瞬间的白屏,然后变成黑屏。虽然时间只有几毫秒,但在高端显示器上,这就像是在你的视网膜上画了一道闪电。

而且,这种异步更新导致我们很难在渲染之前就锁定主题,这破坏了 React 试图维护的“数据驱动视图”的纯粹性。


第三章:React 18 的救世主——useSyncExternalStore

直到 React 18 发布,事情才有了转机。React 引入了一个新的 Hooks:useSyncExternalStore

这名字听起来有点像是在说“同步外部存储”。没错,它的核心目的就是为了解决外部状态(比如系统主题)与 React 内部状态不同步的问题。

为什么要同步?因为 React 18 引入了并发渲染和自动批处理。如果你在 useEffect 里更新状态,React 可能会把多次状态更新合并成一次渲染。但 matchMedia 的变化是实时的,是连续的。如果你用 useEffect 去监听,你可能会漏掉中间的几次状态变更,导致主题跳变。

useSyncExternalStore 强迫 React 在读取外部状态时必须是同步的。这意味着,只要 matchMedia 变了,React 就会立即知道,并立即触发渲染。

让我们来看看重构后的代码:

import { useSyncExternalStore } from 'react';

function useSystemTheme() {
  // 1. 定义订阅函数:当主题改变时,告诉 React 去重新渲染
  const subscribe = (callback) => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    mediaQuery.addEventListener('change', callback);
    return () => mediaQuery.removeEventListener('change', callback);
  };

  // 2. 获取当前状态:同步读取
  const getSnapshot = () => {
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  };

  // 3. 获取服务端渲染的快照(稍后详细讲)
  const getServerSnapshot = () => 'light';

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

// 在组件中使用
function App() {
  const theme = useSystemTheme();

  return (
    <div style={{ 
      background: theme === 'dark' ? '#1a1a1a' : '#ffffff',
      color: theme === 'dark' ? '#ffffff' : '#000000',
      minHeight: '100vh',
      padding: '20px',
      transition: 'background 0.3s ease' // 加个过渡动画,显得高级一点
    }}>
      <h1>系统主题感知:{theme.toUpperCase()}</h1>
      <p>现在,无论你怎么切系统,我都立刻知道。</p>
    </div>
  );
}

看到了吗?没有闪烁了。因为我们在 React 的渲染阶段就拿到了主题值,而不是在副作用阶段。


第四章:CSS 变量——渲染引擎的魔法引擎

光有 JavaScript 的状态是不够的,我们还需要把这些状态传给 CSS。这里就要祭出 CSS 变量的神技了。

CSS 变量(Custom Properties)允许我们在 HTML 根元素上定义变量,然后在任何地方引用它。当根元素的属性改变时,所有引用该变量的地方都会自动更新。这不需要 JavaScript 去手动操作 DOM 的 style 属性,性能极高。

让我们构建一个完整的 CSS 变量主题系统。

1. 定义 CSS 变量

:root {
  --bg-color: #ffffff;
  --text-color: #000000;
  --accent-color: #007bff;
}

[data-theme="dark"] {
  --bg-color: #121212;
  --text-color: #e0e0e0;
  --accent-color: #4dabf7;
}

2. 在 React 中应用

import { useEffect } from 'react';

function useTheme() {
  const theme = useSystemTheme();

  useEffect(() => {
    // 当主题改变时,修改 HTML 根元素的属性
    if (theme === 'dark') {
      document.documentElement.setAttribute('data-theme', 'dark');
    } else {
      document.documentElement.removeAttribute('data-theme');
    }
  }, [theme]);
}

function App() {
  useTheme(); // 启动主题监听

  return (
    <div style={{ backgroundColor: 'var(--bg-color)', color: 'var(--text-color)' }}>
      <h1>Hello, System Theme!</h1>
      <button style={{ backgroundColor: 'var(--accent-color)' }}>
        点击我
      </button>
    </div>
  );
}

这代码简洁得令人发指。但是,这里有一个微妙的细节:useEffect 的时机

虽然 useSyncExternalStore 解决了数据读取的同步问题,但我们在 useEffect 里去修改 document.documentElement 依然属于副作用。这会不会导致闪烁?

答案是:有可能

如果在组件第一次渲染时,useEffect 还没跑,data-theme 属性还没有被设置。浏览器会先渲染默认的浅色主题(因为没有 data-theme),然后 useEffect 执行,加上 data-theme="dark",浏览器再重绘一次深色主题。

为了实现真正的“零延迟”,我们需要使用 useLayoutEffect


第五章:useLayoutEffect —— 隐形的画师

useLayoutEffectuseEffect 非常像,唯一的区别是执行时机。

  • useEffect:在浏览器绘制之后执行(异步)。
  • useLayoutEffect:在浏览器绘制之前执行(同步)。

这意味着,当我们切换主题时,useLayoutEffect 会立即修改 DOM 属性,然后浏览器才进行绘制。用户看到的就是“已经更新后的主题”,而不是“先旧后新”。

让我们修改上面的代码:

import { useLayoutEffect } from 'react';

function useTheme() {
  const theme = useSystemTheme();

  // 使用 useLayoutEffect 替代 useEffect
  useLayoutEffect(() => {
    const root = document.documentElement;
    if (theme === 'dark') {
      root.setAttribute('data-theme', 'dark');
    } else {
      root.removeAttribute('data-theme');
    }
  }, [theme]);
}

现在,逻辑闭环了:

  1. 系统变黑。
  2. matchMedia 触发。
  3. useSyncExternalStore 同步更新 React 状态。
  4. React 重新渲染组件。
  5. useLayoutEffect 立即修改 DOM 属性。
  6. 浏览器绘制。

用户感觉不到任何延迟,仿佛系统主题变更是瞬间同步的。


第六章:SSR 的噩梦与救赎

如果你使用 Next.js 或其他服务端渲染(SSR)框架,事情会变得更复杂。

在服务端,没有浏览器,没有 window 对象,也没有 matchMedia。服务端渲染出来的 HTML 默认是浅色主题。但是,当这个 HTML 传到客户端,用户的系统可能是深色模式。

如果你直接在服务端渲染 <div data-theme="light">,然后在客户端用 useLayoutEffect 瞬间改成 dark,虽然不会闪烁,但这会让搜索引擎(SEO)抓取到的页面是浅色主题,而且用户首次加载时也会有一瞬间的主题不符。

为了解决这个问题,useSyncExternalStore 提供了第三个参数:getServerSnapshot

这个函数在服务端渲染时运行,告诉 React:“嘿,在这个快照时刻,主题应该是浅色。”

function useSystemTheme() {
  const subscribe = (callback) => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    mediaQuery.addEventListener('change', callback);
    return () => mediaQuery.removeEventListener('change', callback);
  };

  const getSnapshot = () => {
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  };

  // 关键:告诉 React 服务端渲染时的状态是 light
  const getServerSnapshot = () => 'light';

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

这样,React 就知道服务端渲染的是浅色,客户端初始化时也是浅色。当 useLayoutEffect 执行,把 data-theme 改成深色时,React 会认为这是一个“必要的更新”,从而避免不必要的重新渲染,同时保持 DOM 属性与服务端 HTML 的一致性。


第七章:构建一个健壮的“主题管理器”

光有 useSystemTheme 还不够,用户有时候不喜欢系统默认的主题,他们想自己手动切换。这时候我们需要一个“全局主题管理器”。

我们需要一个 Context 来存储当前的主题状态(系统默认 vs 用户手动选择),并导出 setTheme 方法。

这是一个完整的、工业级的实现方案:

import { createContext, useContext, useSyncExternalStore, useLayoutEffect, useState, useRef } from 'react';

// 1. 创建 Context
const ThemeContext = createContext();

// 2. 订阅主题变化的“仓库”
// 这是一个单例模式,确保所有组件共享同一个监听器
const themeStore = {
  listeners: new Set(),
  state: 'light', // 默认值,会被 getServerSnapshot 覆盖

  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  },

  setState(newState) {
    this.state = newState;
    this.listeners.forEach(listener => listener(this.state));
  }
};

// 3. 自定义 Hook
function useTheme() {
  const value = useSyncExternalStore(
    themeStore.subscribe,
    () => themeStore.state,
    () => 'light' // SSR 快照
  );

  const setTheme = (theme) => {
    themeStore.setState(theme);
  };

  return [value, setTheme];
}

// 4. 包装组件
export function ThemeProvider({ children }) {
  const [theme, setTheme] = useTheme();

  // 同步 DOM 属性
  useLayoutEffect(() => {
    const root = document.documentElement;
    if (theme === 'dark') {
      root.setAttribute('data-theme', 'dark');
    } else {
      root.removeAttribute('data-theme');
    }
  }, [theme]);

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

// 5. 辅助 Hook,用于组件内部
export function useAppTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useAppTheme must be used within ThemeProvider');
  }
  return context;
}

// 6. 在根组件使用
// import { ThemeProvider } from './ThemeContext';
// <ThemeProvider><App /></ThemeProvider>

这个实现非常强大。它不仅监听了系统主题,还允许我们通过 setTheme('dark') 来强制切换主题。而且,所有的组件通过 useAppTheme 获取主题,不需要层层传递 props。


第八章:深入 Fiber —— 零延迟的真相

既然我们谈到了“零延迟”,我们就不能只停留在 Hook 层面。让我们把镜头拉远,看看 React 的渲染引擎——Fiber

React 的核心是一个调度器。当你调用 setState 时,React 不会立即重新渲染,而是把更新任务放入一个队列。

对于深色模式这种高优先级(或者说高频)的状态变化,React 的调度器是如何处理的?

  1. 事件触发:操作系统发送 prefers-color-scheme 变化事件。
  2. 同步读取useSyncExternalStore 读取状态。这里 React 强制同步,不经过调度队列。
  3. 状态更新:React 更新 Fiber 树上的状态节点。
  4. Reconciliation(协调):React 遍历 Fiber 树,决定哪些节点需要更新。
  5. Commit(提交)
    • 如果是 useEffect,更新在提交后执行。
    • 如果是 useLayoutEffect,更新在提交前执行。

这就是“零延迟”的关键所在:我们在 Commit 阶段之前就完成了 DOM 的修改

这意味着,浏览器在执行绘制命令(paint)之前,就已经拿到了正确的 data-theme 属性。浏览器只会画一次,画的就是用户想要的主题。

如果我们在 useEffect 里做这件事,React 会先提交旧的状态,浏览器画一次旧主题,然后 useEffect 执行,React 提交新状态,浏览器再画一次新主题。这就是闪烁的根源。

所以,useLayoutEffect 是实现零延迟响应的最后一道防线。


第九章:性能陷阱与优化

虽然我们追求零延迟,但我们不能为了速度而牺牲性能。这里有几个坑,大家要注意:

1. 避免在 useLayoutEffect 里做重计算

useLayoutEffect 是同步执行的,它会阻塞浏览器的绘制。如果你在 useLayoutEffect 里做了大量的数学计算或者复杂的 DOM 操作,会导致页面出现明显的卡顿。

对于主题切换,我们只修改了一个属性 data-theme,这非常快,所以没问题。但如果你在 useLayoutEffect 里根据主题去计算复杂的布局,那就得不偿失了。

2. CSS 变量的继承开销

虽然 CSS 变量很快,但大量使用 var(--bg-color) 也会增加浏览器的样式解析开销。如果你的页面有几千个元素,每个元素都在引用主题变量,那么每次主题切换,浏览器都需要重新解析几千个样式规则。

优化方案:尽量使用 CSS 类名来控制布局,而不是在 JS 里频繁操作样式。CSS 变量更适合做颜色、字体大小等视觉属性的动态绑定。

3. 减少不必要的重渲染

如果你在一个大组件里使用了 useAppTheme(),那么只要主题变了,这个大组件就会重新渲染。

优化方案:使用 useMemouseCallback 来缓存子组件,或者使用 React 18 的 useTransition 将非紧急的更新标记为过渡状态。

import { useTransition } from 'react';
import { useAppTheme } from './ThemeContext';

function LargeComponent() {
  const [theme, setTheme] = useAppTheme();
  const [isPending, startTransition] = useTransition();

  const toggleTheme = () => {
    // 使用 startTransition 标记这个更新为低优先级
    // 这样浏览器可以在切换主题的同时,优先响应用户的交互(如点击)
    startTransition(() => {
      setTheme(theme === 'light' ? 'dark' : 'light');
    });
  };

  return (
    <div>
      <button onClick={toggleTheme}>{isPending ? '切换中...' : '切换主题'}</button>
      {/* 复杂的渲染内容 */}
    </div>
  );
}

第十章:进阶模式——持久化与自动检测

现在我们有了基础,让我们加点料。通常,用户切换了主题后,他们希望下次打开应用时还是这个主题。

我们需要结合 localStorage

// 在 ThemeProvider 中添加初始化逻辑
useLayoutEffect(() => {
  // 1. 优先读取 localStorage
  const savedTheme = localStorage.getItem('theme');

  // 2. 如果没有保存,则使用系统默认
  const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  const finalTheme = savedTheme || systemTheme;

  themeStore.setState(finalTheme);
}, []);

但是,如果用户在系统设置里把深色模式关掉,但我们的应用还保留着深色模式,这就显得很蠢了。我们需要一个“监听系统变化 + 保持用户偏好”的逻辑。

这是一个更高级的 Hook 实现:

import { useState, useEffect, useLayoutEffect } from 'react';

export function useTheme() {
  const [theme, setTheme] = useState('light');
  const [mounted, setMounted] = useState(false);

  // 检查是否已经挂载(防止 SSR hydration mismatch)
  useEffect(() => setMounted(true), []);

  // 监听系统变化
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    const handleChange = (e) => {
      // 只有当用户没有手动强制指定主题时,才跟随系统
      // 这里假设我们有一个标志位来表示用户是否手动切换过
      // 如果没有这个标志位,我们可以直接跟随系统
      if (!localStorage.getItem('theme')) {
        setTheme(e.matches ? 'dark' : 'light');
      }
    };

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

  const toggleTheme = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  // 同步 DOM
  useLayoutEffect(() => {
    const root = document.documentElement;
    if (theme === 'dark') {
      root.setAttribute('data-theme', 'dark');
    } else {
      root.removeAttribute('data-theme');
    }
  }, [theme]);

  // 如果还没挂载,返回 null 或者默认值,避免 SSR 问题
  if (!mounted) return null;

  return { theme, toggleTheme };
}

这个实现非常稳健。它解决了 SSR 问题,解决了持久化问题,也解决了跟随系统的问题。


第十一章:CSS-in-JS 的视角

如果你使用的是 styled-components 或 Emotion,原理是一样的。它们本质上也是监听状态变化,然后更新 CSS 变量。

例如,在 styled-components 中:

const Container = styled.div`
  background-color: var(--bg-color);
  color: var(--text-color);
  transition: background-color 0.3s, color 0.3s;
`;

// 在组件里
function MyPage() {
  const theme = useSystemTheme();

  // 这里的技巧是:直接在 styled-components 的样式里引用变量
  // 当 data-theme 变化时,CSS 引擎会自动更新所有引用该变量的元素
  // React 只需要负责切换 data-theme 属性,剩下的交给 CSS
  return <Container>我是 styled-components 组件</Container>;
}

CSS-in-JS 的优势在于它可以作用域化,不会污染全局 CSS。但对于主题这种全局性的东西,CSS 变量依然是王者。


第十二章:总结与展望

好了,各位,我们一路走来了。

从最原始的 addEventListener,到 useEffect 的闪烁,再到 React 18 的 useSyncExternalStore,最后通过 useLayoutEffect 实现了真正的零延迟。

核心要点回顾:

  1. 不要在 useEffect 里做主题切换:它会带来闪烁,破坏用户体验。
  2. 使用 useSyncExternalStore:这是 React 18 处理外部状态的标准方式,保证数据流的同步性。
  3. 使用 useLayoutEffect:在浏览器绘制之前修改 DOM 属性,确保用户只看到最终状态。
  4. 利用 CSS 变量:这是性能最高的主题切换方案。
  5. 处理 SSR:提供 getServerSnapshot,避免服务端渲染与客户端状态不一致。

这不仅仅是关于代码。这是关于如何让你的应用像一个有生命的有机体一样,感知周围的环境,做出自然的反应。

当你的应用能像操作系统本身一样,流畅地响应每一次主题切换时,你就真正掌握了 React 渲染引擎的精髓。那一刻,你会觉得,写代码就像是在指挥一场交响乐,每一个 Hook 都是一个音符,每一次渲染都是一次完美的和声。

现在,去检查你的代码吧。是不是还有哪里在闪烁?是不是还有哪里在背叛用户的系统设置?修正它,让代码优雅起来。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注