skip to content
月与羽

Next.js 主题切换实现

/ 30 min read

整体架构概览

主题切换系统由三个核心部分组成,它们各司其职,共同协作:

  1. **ThemeProvider.tsx** (大脑与心脏):这是系统的核心。它负责创建全局状态(当前主题),管理主题的变更逻辑,与浏览器(localStorage 和 DOM)交互,并通过 React Context 将这一切提供给整个应用。
  2. **useTheme.ts** (神经系统):这是一个自定义 Hook,是连接“大脑”和“身体”(UI 组件)的桥梁。它让任何组件都能轻松、安全地访问和控制主题状态,而无需关心底层的实现细节。
  3. **ThemeSelector.tsx** (用户界面):这是一个具体的 UI 组件,是用户与主题系统交互的入口。它使用 useTheme Hook 来显示当前主题并触发主题切换。 接下来,我们深入每一步的细节。

第一步:ThemeProvider.tsx - 状态管理与提供者

这是所有魔法的起点。我们来逐段分析它的作用。

1. 初始化和上下文创建

'use client';
import React, { createContext, useState, useEffect, useMemo } from 'react';
// ... 其他导入
// 创建一个 React 上下文(Context)
export const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
  • **'use client'**: 这是 Next.js App Router 的指令,告诉框架这个组件及其子组件是“客户端组件”。这是必需的,因为我们要使用 useState, useEffect 等 Hooks,并且需要访问浏览器特有的 windowlocalStorage 对象。
  • **createContext**: 这里创建了一个名为 ThemeContext 的“数据管道”。现在它只是一个空的管道,初始值为 undefined。之后,我们会用 Provider 往这个管道里填充数据。

2. ThemeProvider 组件 - 核心逻辑

export default function ThemeProvider({ children }: ThemeProviderProps) {
// 状态定义
const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
const [isInitialized, setIsInitialized] = useState<boolean>(false);
// ... (其他代码)
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
}
  • **useState**:
    • currentTheme: 存储当前主题的名称(例如 'light', 'dark')。它的初始值是预设的 DEFAULT_THEME
    • isInitialized: 这是一个非常重要的状态!它用来标记主题是否已经从 localStorage成功初始化。这可以防止SSR (服务器端渲染) 和客户端水合 (Hydration) 之间的不匹配问题。服务器渲染时不知道用户本地存了什么主题,只能用默认主题;而客户端首次渲染时会去读 localStorage。如果立即使用 localStorage 的主题,会导致客户端渲染的 HTML 与服务器发来的不一致,React 会报错。isInitialized 就是解决这个问题的关键。

3. 首次加载时的初始化 (useEffect)

// 初始化主题(仅在客户端执行)
useEffect(() => {
if (typeof window !== 'undefined') {
// 1. 从 localStorage 读取保存的主题
const savedTheme = localStorage.getItem('theme');
// 2. 验证主题有效性,无效则用默认主题
const initialTheme = savedTheme && THEMES.find(t => t.name === savedTheme)
? savedTheme
: DEFAULT_THEME;
// 3. 更新 React 状态
setCurrentTheme(initialTheme);
// 4. 更新 <html> 标签的 data-theme 属性,让 CSS 生效
document.documentElement.setAttribute('data-theme', initialTheme);
// 5. 标记为已初始化
setIsInitialized(true);
}
}, []); // 空依赖数组 [] 表示此 effect 只在组件首次挂载时运行一次

这个 useEffect 是组件的“启动程序”。

  1. 它首先检查 window 是否存在,确保代码只在浏览器环境中运行。
  2. 它尝试从 localStorage 中读取用户上次选择的主题。
  3. 如果找到了,并且这个主题是有效的(存在于你的 THEMES 列表里),就使用它。否则,就退回到默认主题。
  4. 然后,它同时更新 React 内部状态(setCurrentTheme)和外部的 DOM(document.documentElement.setAttribute('data-theme', ...))。这个 data-theme 属性是让你的 CSS(特别是像 DaisyUI 这样的框架)能够根据主题应用不同样式变量的关键。
  5. 最后,它将 isInitialized 设置为 true,通知所有消费该状态的组件:“初始化完成了,现在可以安全地显示真实的主题了!”

4. 主题切换处理函数 (handleSetTheme)

const handleSetTheme = (theme: string) => {
// ... 验证主题 ...
setCurrentTheme(theme);
// 更新 DOM
document.documentElement.setAttribute('data-theme', theme);
// 保存到 localStorage
localStorage.setItem('theme', theme);
};

当用户选择一个新主题时,这个函数会被调用。它的职责很清晰:

  1. 更新 React 状态: setCurrentTheme(theme) 会触发所有使用此状态的组件重新渲染。
  2. 更新 DOM: 立即更改 <html> 上的 data-theme,让 CSS 马上响应,实现视觉上的无缝切换。
  3. 持久化: localStorage.setItem('theme', theme) 将用户的选择保存起来,这样下次刷新或打开页面时,useEffect 初始化逻辑就能读取到这个新选择。

5. 性能优化与 Context 提供

const contextValue = useMemo<ThemeContextType>(() => ({
currentTheme,
setTheme: handleSetTheme,
themes: THEMES,
currentThemeInfo,
isInitialized,
}), [currentTheme, currentThemeInfo, isInitialized]);
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
  • **useMemo**: 这是一个性能优化。Context 的 value 如果是一个在每次渲染时都重新创建的对象 {}, 会导致所有消费这个 Context 的子组件(无论它们是否真的关心值的变化)都重新渲染。useMemo 会缓存这个 contextValue 对象,只有当它的依赖项(currentTheme, isInitialized 等)发生变化时,才会重新计算。这避免了不必要的渲染,提升了性能。
  • **ThemeContext.Provider**: 这就是“数据提供者”。它将精心准备好的 contextValue 对象放入我们之前创建的 ThemeContext 管道中,让所有被它包裹的子组件 ({children}) 都能通过 useContext 或我们的自定义 Hook useTheme 来访问这些数据。

第二步:useTheme.ts - 便捷的消费钩子

这个文件虽然简短,但极大地提升了“开发者体验”。

'use client';
import { useContext } from 'react';
// ...
export function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error(
'useTheme must be used within a ThemeProvider. ' +
'Make sure to wrap your component tree with <ThemeProvider>.'
);
}
return context;
}

它的作用是:

  1. 简化使用: 在组件中,你只需要写 const { currentTheme, setTheme } = useTheme(),而不用写 const { currentTheme, setTheme } = useContext(ThemeContext)。这更简洁,更具可读性。
  2. 增加安全性: 最重要的部分是 if (context === undefined) 这个检查。如果某个开发者忘记在应用的上层包裹 <ThemeProvider>useContext(ThemeContext) 会返回 undefined。这个检查会立即抛出一个非常明确的错误信息,告诉开发者问题出在哪里。这能节省大量的调试时间。

第三步:ThemeSelector.tsx - 交互与实践

这个组件是整个系统的“用户端”,展示了如何消费我们创建的 Context 和 Hook。

1. 获取主题状态

'use client';
// ...
import { useTheme } from '@/app/lib/hooks/useTheme';
export default function ThemeSelector() {
// ...
const { currentTheme, setTheme, themes, isInitialized } = useTheme();
// ...
}

看,使用 useTheme() 是多么的简单!一行代码,我们就拿到了所有需要的数据和函数:

  • currentTheme: 用于判断哪个主题应该被选中(checked)。
  • setTheme: 用于在用户点击时调用,以切换主题。
  • themes: 主题列表,用于渲染所有可选项。
  • isInitialized: 用于决定是显示“骨架屏”还是真实内容。

2. 处理初始化状态(骨架屏)

if (!isInitialized) {
return (
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle">
<span className="skeleton h-6 w-6"></span>
</div>
</div>
);
}

这就是 isInitialized 状态的用武之地。在 ThemeProvideruseEffect 完成之前,isInitializedfalse。此时,我们显示一个骨架屏(skeleton)。这可以防止用户看到一个默认主题然后突然闪烁变成他们保存的主题,提供了更平滑的加载体验。

3. 渲染与交互

const handleThemeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTheme(event.target.value);
};
return (
// ...
<ul key={currentTheme} /* ... */>
{themes.map((theme: Theme) => (
<li key={theme.name}>
<input
type="radio"
// ...
checked={currentTheme === theme.name}
onChange={handleThemeChange}
/>
</li>
))}
</ul>
// ...
);
  • **handleThemeChange**: 当用户点击某个主题的 input 时,这个函数被触发。它做的事情非常简单:从事件中获取新主题的 value,然后调用从 useTheme Hook 中得到的 setTheme 函数。它完全不需要知道 setTheme 背后做了什么(更新 DOM、存 localStorage 等),这就是逻辑分离的好处。
  • **key={currentTheme}** 的妙用:
    这是一个非常聪明且实用的技巧!
    • 问题: 有时,特别是使用像 DaisyUI 这样的 CSS 框架时,仅通过 classNamechecked 属性的改变,React 可能无法完美地触发所有预期的样式更新或动画。
    • 解决方案: 给 <ul> 元素设置一个 key={currentTheme}。在 React 中,当一个元素的 key 发生变化时,React 不会去“更新”这个元素,而是会认为它是一个全新的元素。它会销毁旧的 **<ul>** 及其所有子元素,然后重新创建一个全新的 **<ul>**
    • 效果: 这强制了整个主题列表的完全重新渲染,确保所有 checked 状态和 btn-active class 都被干净、正确地应用,有效避免了任何潜在的渲染怪癖。

总结:完整的工作流程

  1. 首次访问页面:
    • 服务器渲染 ThemeProviderThemeSelector(显示骨架屏,因为 isInitializedfalse)。
    • 客户端 JS 加载,React 水合。
    • ThemeProvideruseEffect 运行,读取 localStorage,设置真实主题,更新 <html>data-theme,并将 isInitialized 设为 true
    • ThemeSelector 因为 isInitialized 变为 true 而重新渲染,显示出完整的主题选择器,并正确高亮当前主题。
  2. 用户切换主题:
    • 用户在 ThemeSelector 中点击一个新主题。
    • inputonChange 事件触发 handleThemeChange
    • handleThemeChange 调用 setTheme('new-theme-name')
    • setTheme(实际上是 ThemeProvider 中的 handleSetTheme)被执行。
    • handleSetTheme 同时做三件事:
      a. 更新 localStorage
      b. 更新 <html>data-theme 属性(CSS 立即响应,页面变色)。
      c. 调用 setCurrentTheme 更新 React 状态。
    • React 状态 currentTheme 的改变,导致 ThemeProviderThemeSelector 重新渲染。
    • ThemeSelectorul 因为 key={currentTheme} 的值改变而被完全重建,确保 UI 显示正确无误。 你的这套实现堪称典范,它考虑了性能、开发体验、用户体验和 SSR 兼容性,结构清晰,逻辑严谨。做得非常出色!

Hydration (水合)

好的,这是一个非常核心且重要的问题。理解了 SSR 与客户端 Hydration 的不匹配问题,就等于掌握了现代前端框架(如 Next.js, Remix)中一个关键的渲染概念。 我们来详细拆解这个问题。

1. 什么是 SSR (服务器端渲染) 和 Hydration (水合)?

为了理解问题,我们先要清楚这两个概念。

SSR (Server-Side Rendering)

  • 过程: 当你的浏览器请求一个页面时,服务器(而不是用户的浏览器)会运行 React 代码,生成该页面的完整 HTML 字符串。然后,服务器将这个静态的 HTML 文件发送给浏览器。
  • 优点:
    1. 首屏加载快: 浏览器一收到 HTML 就可以立即显示内容,用户能非常快地看到页面,而不需要等待 JS 下载和执行。
    2. SEO 友好: 搜索引擎的爬虫可以直接抓取和索引服务器返回的完整 HTML 内容。 这个从服务器发来的 HTML 是“死的”、“静态的”。它虽然看起来像个网页,但上面的按钮点击了没反应,因为它还没有 JavaScript 的交互能力。

Hydration (客户端水合)

  • 过程: 在浏览器显示了静态 HTML 之后,相关的 JavaScript 文件(包含 React 库和你的组件代码)才开始在后台下载和执行。
  • 目标: React 会接管这个静态的 HTML。它会遍历服务器渲染的 DOM 树,将事件监听器 (event listeners) 和交互逻辑“附加”上去,让这个静态页面“活过来”,变成一个功能完整的单页应用 (SPA)。
  • 比喻: 想象一下,服务器送来一个精美的汽车外壳(HTML)。“水合”就是客户端的 React 把发动机、方向盘、电路(JavaScript 逻辑和状态)安装到这个外壳里的过程,让它能真正开动起来。

2. “不匹配 (Mismatch)” 问题是什么?为什么会发生?

核心原则: 为了让“水合”过程顺利进行,客户端 React 在其第一次渲染时生成的虚拟 DOM,必须与从服务器接收到的静态 HTML 结构完全一致。 如果两者不一致,React 就会感到困惑。它不知道该如何正确地附加事件监听器。这就像你拿到一份汽车外壳的图纸(服务器 HTML),但你手里的发动机(客户端 JS)却说:“不对,图纸上这里应该是个天窗,而不是行李架!” React 为了避免产生不可预知的 bug,会选择放弃水合,并抛出一个警告或错误。 这就是“不匹配 (Mismatch)”问题。

为什么会发生?以你的“主题切换”为例

这是一个完美的例子,因为它依赖于一个只存在于客户端的信息源:localStorage。 我们来走一遍发生不匹配的流程:

  1. 在服务器上 (Server-Side Render):
    • 服务器接收到页面请求。
    • 它开始执行你的 ThemeProvider 组件。
    • 服务器没有 window 对象,也没有 localStorage。它根本不知道用户在浏览器里存了什么主题。
    • 因此,它只能使用你在代码里写的初始状态:const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
    • 服务器会用默认主题(比如 ‘light’)来渲染整个页面。它生成的 HTML 会是这样:<html data-theme="light">...</html>
    • 服务器把这个渲染好的、基于默认主题的 HTML 发送给用户的浏览器。
  2. 在客户端上 (Client-Side First Render for Hydration):
    • 用户的浏览器收到了来自服务器的 HTML,并立刻显示出来。用户看到的是一个亮色主题的页面。

    • 同时,浏览器开始下载和执行你的 React/JS 代码。

    • 假设这位用户之前选择了 ‘dark’ 主题,并且这个选择已经保存在他的 localStorage 中 (localStorage.getItem('theme') 会返回 'dark')。

    • 现在,客户端的 React 开始进行它的第一次渲染,准备去“水合”服务器送来的 HTML。

    • 它执行 ThemeProvider 组件。如果你的代码是这样写的(错误示范):

      // 这是一个会导致错误的简化版本
      const savedTheme = localStorage.getItem('theme') || DEFAULT_THEME;
      const [currentTheme, setCurrentTheme] = useState(savedTheme);
    • 客户端的 React 会立即从 localStorage 读到 'dark',所以它的第一次渲染结果是基于黑暗主题的。它期望的 HTML 是 <html data-theme="dark">...</html>

  3. 冲突发生!
    • 服务器说: 页面应该是 <html data-theme="light">

    • 客户端的首次渲染说: 页面应该是 <html data-theme="dark">

    • React 发现两者不一致,于是它在开发者控制台抛出著名的错误:

      Warning: Prop data-theme did not match. Server: “light” Client: “dark”Error: Hydration failed because the initial UI does not match what was rendered on the server.


3. 如何解决这个问题?—— 你的代码正是完美答案

你的代码通过一个非常经典且有效的策略——“两阶段渲染 (Two-Pass Rendering)”——解决了这个问题。 这个策略的核心思想是:“先欺骗,后更新”

  1. 第一阶段:确保首次渲染一致(欺骗 React)

    你的代码通过 isInitialized 状态完美地实现了这一点。

    const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
    const [isInitialized, setIsInitialized] = useState<boolean>(false);
    • 在服务器上: isInitialized 的初始值是 false。因此,ThemeSelector 组件会渲染骨架屏(skeleton)。
    • 在客户端首次渲染时: useState 的初始值永远是代码里写死的那个。所以 isInitialized必然false。因此,客户端的首次渲染也会生成一个骨架屏。

    结果: 服务器和客户端的首次渲染结果完全一致(都是骨架屏)!React 非常满意,水合过程顺利完成,页面变得可交互,并且没有任何错误。

  2. 第二阶段:在客户端更新到真实状态

    一旦水合成功,组件就“挂载 (mount)”到了 DOM 上。这时,useEffect 钩子就会执行。

    useEffect(() => {
    // 这个钩子只在客户端运行,并且在水合之后
    if (typeof window !== 'undefined') {
    const savedTheme = localStorage.getItem('theme'); // 安全地访问 localStorage
    const initialTheme = savedTheme || DEFAULT_THEME;
    setCurrentTheme(initialTheme); // 更新状态
    document.documentElement.setAttribute('data-theme', initialTheme); // 更新 DOM
    setIsInitialized(true); // 标记初始化完成
    }
    }, []); // 空依赖数组确保只运行一次
    • 这个 useEffect 是解决问题的关键。它只在客户端运行
    • useEffect 内部,我们可以安全地访问 localStorage
    • 我们读取到用户保存的主题(比如 ‘dark’),然后调用 setCurrentTheme('dark')setIsInitialized(true)
    • 这两个 setState 调用会触发一次新的渲染
    • 在这次新的渲染中,isInitialized 变成了 trueThemeSelector 不再显示骨架屏,而是根据 currentTheme 的新值(‘dark’)来渲染真实的 UI。

总结

阶段目标实现方式
服务器渲染生成快速加载的静态 HTML使用默认状态 (isInitialized: false, currentTheme: DEFAULT_THEME) 进行渲染。
客户端首次渲染 (用于水合)欺骗 React,让其渲染结果与服务器完全一致使用与服务器相同的默认状态 (isInitialized: false, currentTheme: DEFAULT_THEME)。此时不访问 localStorage
客户端挂载后 (**useEffect**)更新到客户端的真实状态安全地访问 localStorage,然后调用 setState ( setIsInitializedsetCurrentTheme) 来更新 UI。
通过这种方式,你既享受了 SSR 带来的快速首屏和 SEO 好处,又巧妙地绕过了水合不匹配的陷阱,最终在客户端展示了用户个性化的正确状态。这是一个非常优雅且健壮的解决方案。

UseMemo 性能优化

好的,我们来深入探讨 useContextuseMemo 如何在你的主题切换实现中协同工作,以及它们是如何进行性能优化的。 这是一个非常重要的话题,因为在 React 中,不当使用 Context 很容易导致不必要的组件重渲染,从而影响应用性能。

1. useContext 的基本行为和潜在问题

首先,我们得理解 useContext 的工作原理。

  • 工作原理: 当一个组件使用 useContext(MyContext) 时,它就“订阅”了这个 Context 的变化。只要 MyContext.Providervalue 属性发生变化,所有订阅了这个 Context 的组件都会被强制重新渲染

  • 潜在的性能问题: 问题的关键在于 React 如何判断 value 是否“变化”了。React 使用的是 Object.is 算法来比较新旧 value,这基本上等同于严格相等检查 (===)。

    让我们看一个没有优化的例子:

    // 反面教材 - 不要这样做
    function ThemeProvider({ children }) {
    const [currentTheme, setCurrentTheme] = useState('light');
    // ... 其他逻辑 ...
    // 每次 ThemeProvider 重新渲染时,都会创建一个全新的对象
    const contextValue = {
    currentTheme,
    setTheme: handleSetTheme,
    // ...
    };
    return (
    <ThemeContext.Provider value={contextValue}>
    {children}
    </ThemeContext.Provider>
    );
    }

    在这个例子中,每次 ThemeProvider 因为任何原因(比如它自身的父组件重渲染)而重渲染时,const contextValue = { ... } 都会创建一个全新的对象。即使 currentTheme 的值没有变,新创建的 contextValue 对象和上一次渲染的 contextValue 对象在内存中是两个不同的引用。

    所以,oldValue === newValue 的结果是 false

    后果就是:所有使用了 useTheme() Hook 的子组件,无论它们是否真的关心 currentTheme 的变化,都会被无辜地触发重渲染。想象一下,如果你的应用有几十上百个组件都用到了主题,这会造成巨大的性能浪费。

2. useMemo 如何解决这个问题

useMemo 是 React 提供的一个 Hook,它的作用是“记忆化 (memoization)”一个计算结果。

  • 工作原理: useMemo 接收一个“创建”函数和一个依赖项数组。
    • useMemo(() => { /* 计算逻辑 */ }, [dep1, dep2])
    • React 会执行这个创建函数,并缓存它的返回值。
    • 在后续的渲染中,只有当依赖项数组 [dep1, dep2] 中的某个值发生变化时,React 才会重新执行创建函数以获取新值。
    • 如果依赖项没有变化,useMemo 会直接返回上一次缓存的值,而不会重新计算。

应用到你的 ThemeProvider

现在我们再看你的代码,它正是利用了 useMemo 来解决上述问题:

export default function ThemeProvider({ children }) {
const [currentTheme, setCurrentTheme] = useState(DEFAULT_THEME);
const [isInitialized, setIsInitialized] = useState(false);
// ... 其他代码 ...
const currentThemeInfo = useMemo(() => { /* ... */ }, [currentTheme]);
// Context 值
const contextValue = useMemo<ThemeContextType>(() => ({
currentTheme,
setTheme: handleSetTheme,
themes: THEMES,
currentThemeInfo,
isInitialized,
}), [currentTheme, currentThemeInfo, isInitialized]); // 关键的依赖项数组
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
}

这里发生了什么?

  1. 创建和缓存 **contextValue**: useMemo 会执行第一个参数(那个箭头函数),创建一个包含所有主题信息的对象,并把这个对象缓存起来。
  2. 依赖项检查: 在 ThemeProvider 的后续重渲染中,React 会检查 useMemo 的依赖项数组:[currentTheme, currentThemeInfo, isInitialized]
    • 情况 A: 依赖项未变
      假设 ThemeProvider 的父组件重渲染,导致 ThemeProvider 自身也重渲染,但是 currentThemeisInitialized 的值都没有改变。
      • React 看到依赖项没变。
      • 不会重新执行创建函数,而是直接返回上一次缓存的 **contextValue** 对象
      • 因为返回的是同一个对象引用,所以对于 ThemeContext.Provider 来说,它的 value 属性没有变化 (oldValue === newValuetrue)。
      • 结果: 所有订阅了 ThemeContext 的子组件都不会被触发重渲染。性能优化达成!
    • 情况 B: 依赖项改变
      假设用户切换了主题,setTheme 被调用,导致 currentTheme 的状态从 'light' 变成了 'dark'
      • ThemeProvider 重渲染。
      • React 检查依赖项数组,发现 currentTheme 的值变了。
      • 它会重新执行创建函数,生成一个全新的 **contextValue** 对象,这个新对象包含了新的 currentTheme: 'dark'
      • 因为这是一个新对象,ThemeContext.Providervalue 属性发生了变化。
      • 结果: 所有订阅了 ThemeContext 的子组件都会被触发重渲染,以反映主题的变化。这是我们期望的行为。

3. 一个小细节:函数的稳定性

你可能会注意到,handleSetTheme 函数没有被放入 useMemo 的依赖数组中。为什么可以这样做?

const handleSetTheme = (theme: string) => { /* ... */ };
const contextValue = useMemo(() => ({
// ...
setTheme: handleSetTheme,
// ...
}), [/* ... */]);

在 JavaScript 中,函数也是对象。在 ThemeProvider 每次重渲染时,const handleSetTheme = () => {} 实际上也会创建一个新的函数实例。如果把 handleSetTheme 加入 useMemo 的依赖数组,那么每次都会因为函数引用变化而重新计算 contextValue,导致优化失效。 解决这个问题通常有两种方法:

  1. 使用 **useCallback**:
    useCallback 是专门用来“记忆化”函数的 Hook,它和 useMemo 非常相似。

    import { useCallback } from 'react';
    const handleSetTheme = useCallback((theme: string) => {
    // ... 逻辑 ...
    }, []); // 空依赖数组表示这个函数永远不会改变
    const contextValue = useMemo(() => ({
    // ...
    setTheme: handleSetTheme,
    // ...
    }), [currentTheme, isInitialized, handleSetTheme]);

    通过 useCallbackhandleSetTheme 函数的引用在多次渲染之间保持稳定,这样 useMemo 就不会因为函数而失效。

  2. 你的实现(隐式优化):
    你的代码中,handleSetTheme 没有被 useCallback 包裹,也没有被放入 useMemo 的依赖数组。这在实践中通常是可行的,并且更简洁

    • React 的 useState 返回的 setter 函数(如 setCurrentTheme)是保证引用稳定的。
    • 你的 handleSetTheme 函数内部没有依赖任何 props 或 state,它是一个稳定的函数。
    • 虽然技术上每次渲染都会创建新函数,但只要不把它放进依赖数组,它就不会破坏 useMemo 的缓存。当 currentTheme 改变时,useMemo 无论如何都会重新计算,新的 contextValue 自然会包含最新的 handleSetTheme 函数实例。这是一种简化,在很多场景下是完全没问题的。

    (注:如果 handleSetTheme 内部用到了某个 prop,那最好还是用 useCallback 并把那个 prop 加入依赖数组,以保证函数的行为正确性。)

总结

  • **useContext** 的问题: Provider 的 value 只要引用一变,就会导致所有消费者(Consumer)重渲染。
  • **useMemo** 的解决方案: 通过缓存 value 对象,确保只有在真正需要更新的数据(依赖项)发生变化时,才创建新的 value 对象引用。
  • 你的实现:
    1. **useMemo** 包裹了 contextValue 对象。
    2. 提供了精确的依赖项数组 ([currentTheme, currentThemeInfo, isInitialized])。
    3. 这确保了只有在主题或初始化状态真正改变时,才会触发下游组件的重渲染,从而避免了不必要的性能开销,使得 Context 的使用既方便又高效。