在 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 明明不负责修改主题,却必须接收并继续转交 theme 和 setTheme。
组件层级继续增加后,每一层都要参与搬运数据。这种现象通常被称为 Props Drilling,也就是 Props 逐层传递。
useContext 解决的正是这个问题:让深层组件直接读取上层组件提供的数据,不再要求中间组件负责转发。
一、Context 不是全局变量,而是一条组件树中的数据通道
理解 Context,可以先把它想成一条铺在组件树中的数据通道:
ThemeProvider 提供数据
↓
Page
↓
Child 直接读取数据
它包含三个核心角色:
createContext:创建通道。- Provider:向某一棵组件子树提供数据。
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 解决的核心问题,不是“组件之间不能传数据”,而是“深层组件通信时,中间组件不应该被迫搬运与自己无关的数据”。
完整流程可以归纳为四步:
- 使用
createContext创建数据通道。 - 使用 Provider 管理并提供数据。
- 使用
useContext或自定义 Hook 消费数据。 - 把 Provider 放在所有消费者共同的上层。
但 Context 并没有改变 React 的状态模型:状态仍由上层组件管理,更新仍然沿着 React 的渲染流程传播。
一句话总结:Props 适合显式传递组件输入,Context 适合在一棵组件树中跨层级共享数据。层级不深时继续用 Props;中间组件开始反复搬运数据时,再考虑 Context。