React 组件层级太深怎么办?用 useContext 告别 Props 逐层传递

11 阅读6分钟

在 React 里,父组件通过 Props 向子组件传递数据,是最自然、也最容易理解的通信方式。

但组件树一旦变深,事情就会开始变得别扭。

假设 App 管理页面主题,真正使用主题的是更深层的 Child

App
└── Page
    └── Child

如果只使用 Props,数据需要经过 Page 才能到达 Child

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

  return <Page theme={theme} setTheme={setTheme} />;
}

function Page({ theme, setTheme }) {
  return <Child theme={theme} setTheme={setTheme} />;
}

function Child({ theme, setTheme }) {
  // 真正使用主题的组件
}

这里的问题并不是 Props 不能工作,而是 Page 明明不负责修改主题,却必须接收并继续转交 themesetTheme

组件层级继续增加后,每一层都要参与搬运数据。这种现象通常被称为 Props Drilling,也就是 Props 逐层传递

useContext 解决的正是这个问题:让深层组件直接读取上层组件提供的数据,不再要求中间组件负责转发。

一、Context 不是全局变量,而是一条组件树中的数据通道

理解 Context,可以先把它想成一条铺在组件树中的数据通道:

ThemeProvider 提供数据
        ↓
      Page
        ↓
      Child 直接读取数据

它包含三个核心角色:

  1. createContext:创建通道。
  2. Provider:向某一棵组件子树提供数据。
  3. useContext:从当前组件上方最近的 Provider 读取数据。

这里有两个容易误解的地方。

第一,Context 并不是自动挂到整个应用上的全局变量。Provider 包住哪棵组件树,数据就在哪个范围内生效。

第二,同一种 Context 可以存在多个 Provider。组件永远读取离自己最近的那个 Provider。

二、第一步:创建 ThemeContext

先创建一个专门描述主题数据通道的文件:

// src/context/theme-context.js
import { createContext } from 'react';

export const ThemeContext = createContext(undefined);

为什么默认值使用 undefined,而不是直接写成 'light'

因为这个 demo 希望所有消费者都必须放在 ThemeProvider 内部。如果忘记添加 Provider,我们希望程序立即给出明确错误,而不是悄悄使用一个默认主题,掩盖组件树配置问题。

Context 本身只负责描述“共享什么数据”,不负责管理业务状态。主题状态仍然应该放在 React 组件中。

三、第二步:用 ThemeProvider 管理并提供状态

接下来封装一个 ThemeProvider

// src/ThemeContext.jsx
import { useCallback, useMemo, useState } from 'react';
import { ThemeContext } from './context/theme-context.js';

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggleTheme = useCallback(() => {
    setTheme((currentTheme) =>
      currentTheme === 'light' ? 'dark' : 'light'
    );
  }, []);

  const value = useMemo(
    () => ({ theme, toggleTheme }),
    [theme, toggleTheme]
  );

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

这个组件做了三件事:

  • 使用 useState 管理真正的主题状态。
  • 提供 toggleTheme,让后代组件可以修改状态。
  • 通过 Provider 的 value,把状态和操作方法一起放进 Context。

因此,Context 传递的不一定只是一个字符串。实际项目中更常见的方式,是同时传递状态和操作状态的方法:

{
  theme,
  toggleTheme
}

这样消费者既可以读取数据,也可以发起更新。

为什么这里使用 useMemo?

Provider 使用对象作为 value 时,每次执行下面的代码都会创建一个新对象:

<ThemeContext.Provider value={{ theme, toggleTheme }}>

如果 Provider 因为其他原因重新渲染,即使 theme 没变,这个对象的引用也已经变了,消费者可能因此收到一次不必要的 Context 更新。

useMemo 可以让 value 在依赖没有变化时保持原来的对象引用。

不过不要把它理解成必须机械添加的“性能口诀”。在简单 demo 中直接传对象也能正常工作;只有当 Provider 频繁渲染、消费者较多时,这种引用稳定才更有意义。

四、第三步:用自定义 Hook 统一读取方式

消费者当然可以直接调用:

const context = useContext(ThemeContext);

但如果很多组件都需要读取主题,每个地方都要导入 Context、判断 Provider 是否存在,代码会逐渐重复。

可以再封装一层 useTheme

// src/hooks/useTheme.js
import { useContext } from 'react';
import { ThemeContext } from '../context/theme-context.js';

export function useTheme() {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error('useTheme 必须在 ThemeProvider 内部使用');
  }

  return context;
}

自定义 Hook 在这里不负责创建新的主题状态,它只是统一 Context 的读取入口,并在使用方式错误时给出清晰提示。

之后业务组件不需要知道 Context 文件放在哪里,只需要调用:

const { theme, toggleTheme } = useTheme();

五、Page 不再负责搬运数据

中间层的 Page 可以读取主题,但不需要继续把主题作为 Props 传给 Child

// src/components/Page.jsx
import Child from './Child.jsx';
import { useTheme } from '../hooks/useTheme.js';

function Page() {
  const { theme } = useTheme();

  return (
    <section>
      <h2>Page</h2>
      <p>当前主题:{theme}</p>

      <Child />
    </section>
  );
}

export default Page;

注意这里的关键变化:

<Child />

我们没有再写:

<Child theme={theme} toggleTheme={toggleTheme} />

Page 不再承担与自身职责无关的数据搬运工作。

六、Child 直接消费并更新 Context

真正使用主题的 Child 可以直接调用 useTheme

// src/components/Child.jsx
import { useTheme } from '../hooks/useTheme.js';

function Child() {
  const { theme, toggleTheme } = useTheme();

  return (
    <div>
      <h3>Child</h3>
      <p>当前主题:{theme}</p>

      <button type="button" onClick={toggleTheme}>
        切换为 {theme === 'light' ? 'dark' : 'light'}
      </button>
    </div>
  );
}

export default Child;

点击按钮后,一次完整的数据更新链路是这样的:

点击 Child 按钮
      ↓
执行 toggleTheme
      ↓
ThemeProvider 中的 theme 状态更新
      ↓
Provider 的 value 发生变化
      ↓
读取 ThemeContext 的 Page 和 Child 重新渲染

真正的状态仍然位于 ThemeProvider 中。Child 只是通过 Context 获得了调用更新函数的能力,并没有绕开 React 的单向数据流。

七、在 App 中确定 Context 的作用范围

最后,用 ThemeProvider 包住需要访问主题的组件树:

// src/App.jsx
import Page from './components/Page.jsx';
import { ThemeProvider } from './ThemeContext.jsx';

function App() {
  return (
    <ThemeProvider>
      <main>
        <h1>跨层级共享主题</h1>
        <Page />
      </main>
    </ThemeProvider>
  );
}

export default App;

如果某个组件位于 ThemeProvider 外部,它就无法获得这里提供的 value

这也是为什么 Context 更准确的描述是“组件树范围内的数据共享”,而不是“全局状态”。

八、Context 并不是 Props 的替代品

看到这里,很容易产生另一个误区:既然 Context 能跨层级传递数据,是不是以后都不用 Props 了?

答案是否定的。

Props 仍然是 React 组件最基本的输入方式,它具有几个明显优势:

  • 数据来源写在 JSX 上,关系清晰。
  • 组件更容易独立复用和测试。
  • 不需要依赖组件树中隐藏的 Provider。

如果数据只需要传一两层,或者只有一个明确的子组件使用,Props 往往比 Context 更简单。

Context 更适合以下场景:

  • 主题、语言、登录用户等多处都需要的数据。
  • 数据需要穿过较深的组件树。
  • 中间组件并不关心数据,却不得不反复转发。

它不适合被当成所有状态的默认存放位置。特别是高频变化、结构复杂的业务状态,如果全部放进一个 Context,可能导致大量消费者跟着更新,也会让数据依赖变得难以追踪。

九、总结

useContext 解决的核心问题,不是“组件之间不能传数据”,而是“深层组件通信时,中间组件不应该被迫搬运与自己无关的数据”。

完整流程可以归纳为四步:

  1. 使用 createContext 创建数据通道。
  2. 使用 Provider 管理并提供数据。
  3. 使用 useContext 或自定义 Hook 消费数据。
  4. 把 Provider 放在所有消费者共同的上层。

但 Context 并没有改变 React 的状态模型:状态仍由上层组件管理,更新仍然沿着 React 的渲染流程传播。

一句话总结:Props 适合显式传递组件输入,Context 适合在一棵组件树中跨层级共享数据。层级不深时继续用 Props;中间组件开始反复搬运数据时,再考虑 Context。