🎯 从零彻底搞懂 React Context API ------ 一篇带你穿越"组件树"的状态共享方案
学习日志 | React 进阶必修课
通过一个完整的
context-demo项目,从"为什么需要 Context"到"如何封装自定义 Hook",从源码逐行解析到架构思维升级,彻底掌握 React 跨层级数据传递。
📋 目录
- 前言:从一个真实痛点说起
- [项目全景:context-demo 做了什么](#项目全景:context-demo 做了什么 "#2-%E9%A1%B9%E7%9B%AE%E5%85%A8%E6%99%AFcontext-demo-%E5%81%9A%E4%BA%86%E4%BB%80%E4%B9%88")
- [第一关:创建上下文 ------
createContext](#第一关:创建上下文 —— createContext "#3-%E7%AC%AC%E4%B8%80%E5%85%B3%E5%88%9B%E5%BB%BA%E4%B8%8A%E4%B8%8B%E6%96%87--createcontext") - [第二关:提供数据 ------
Provider](#第二关:提供数据 —— Provider "#4-%E7%AC%AC%E4%BA%8C%E5%85%B3%E6%8F%90%E4%BE%9B%E6%95%B0%E6%8D%AE--provider") - [第三关:消费数据 ------
useContext](#第三关:消费数据 —— useContext "#5-%E7%AC%AC%E4%B8%89%E5%85%B3%E6%B6%88%E8%B4%B9%E6%95%B0%E6%8D%AE--usecontext") - [第四关:封装自定义 Hook ------ 思维升级](#第四关:封装自定义 Hook —— 思维升级 "#6-%E7%AC%AC%E5%9B%9B%E5%85%B3%E5%B0%81%E8%A3%85%E8%87%AA%E5%AE%9A%E4%B9%89-hook--%E6%80%9D%E7%BB%B4%E5%8D%87%E7%BA%A7")
- [第五关:实战延伸 ------
useMouse自定义 Hook](#第五关:实战延伸 —— useMouse 自定义 Hook "#7-%E7%AC%AC%E4%BA%94%E5%85%B3%E5%AE%9E%E6%88%98%E5%BB%B6%E4%BC%B8--usemouse-%E8%87%AA%E5%AE%9A%E4%B9%89-hook") - 项目组件树全景图
- 常见误区与踩坑指南
- 总结:一张图消化全部知识点
1. 前言:从一个真实痛点说起
想象这样一个场景:
markdown
App
└── Page
└── Child
└── GrandChild(需要用到 theme 状态)
如果没有 Context ,App 中的 theme 要传到 GrandChild,你必须一层层手动传递 props:
jsx
// 😫 可怕的 Props Drilling(属性钻探)
function App() {
const [theme, setTheme] = useState('light');
return <Page theme={theme} setTheme={setTheme} />;
}
function Page({ theme, setTheme }) { // 虽然我不需要 theme,但我必须接收并转发
return <Child theme={theme} setTheme={setTheme} />;
}
function Child({ theme, setTheme }) { // 我也不需要,但还得继续转发 😩
return <GrandChild theme={theme} setTheme={setTheme} />;
}
function GrandChild({ theme, setTheme }) {
return <button className={theme}>当前主题:{theme}</button>;
}
这就像是你在公司里,CEO 想把一份文件交给 4 楼的小张,结果你得先给 2 楼完全不相关的李经理,李经理再转交给 3 楼的王主任,王主任再转交......中间每个人都成了"人肉路由器"。
🎉 React Context 的解决方案
jsx
// ✅ 优雅的 Context 方案
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Page /> {/* Page 不需要接收任何 props! */}
</ThemeContext.Provider>
);
}
function GrandChild() {
const theme = useContext(ThemeContext); // 直接从这里拿!
return <button className={theme}>当前主题:{theme}</button>;
}
Context 就像在 React 组件树中挖了一条"虫洞",数据可以直接跨越中间所有层级,到达目标组件。
2. 项目全景:context-demo 做了什么
我们先来看看这个项目的完整结构:
bash
context-demo/
├── src/
│ ├── main.jsx # 应用入口,挂载到 #root
│ ├── App.jsx # 演示:useMouse 自定义 Hook(鼠标追踪)
│ ├── App2.jsx # 演示:ThemeContext 主题切换(核心!)
│ ├── ThemeContext.jsx # 上下文定义:createContext
│ ├── hooks/
│ │ ├── useTheme.js # 自定义 Hook:封装 useContext
│ │ └── useMouse.js # 自定义 Hook:封装鼠标位置追踪
│ └── components/
│ ├── Page.jsx # 中间组件:直接使用 useContext
│ └── Child.jsx # 深层组件:使用自定义 useTheme Hook
├── index.html
├── package.json # React 19 + Vite 8
└── vite.config.js
技术栈:React 19.2.6 + Vite 8 + ESLint
项目包含了 两条独立的学习路线:
- 🅰️ 路线 A (
App2.jsx→ThemeContext.jsx→Page.jsx→Child.jsx→useTheme.js):完整展示 Context 的创建、提供、消费、封装 - 🅱️ 路线 B (
App.jsx→useMouse.js):演示自定义 Hook 的通用封装模式
小提示 :文章默认走的是路线 A(App2.jsx),因为
main.jsx中虽然写的是import App from './App.jsx',但你可以手动改成import App from './App2.jsx'来体验 Context 的完整功能。下文我们会在两套方案间对照讲解。
3. 第一关:创建上下文 ------ createContext
jsx
// 将创建一个 Theme 上下文,为深层次的组件树提供主题共享数据
import { createContext } from 'react';
export const ThemeContext = createContext({
theme: 'light',
setTheme: () => {},
});
🔍 逐行解析
| 代码 | 含义 |
|---|---|
import { createContext } from 'react' |
从 React 中引入 createContext API |
createContext({...}) |
创建一个上下文对象,括号里的对象是默认值 |
theme: 'light' |
默认主题为亮色 |
setTheme: () => {} |
默认的 setter 是一个空函数(占位符) |
export const ThemeContext |
导出上下文,供其他组件使用 |
💡 核心理解:默认值的作用
createContext(defaultValue) 中的 默认值 是什么时候生效的?
答案 :当组件在 没有找到对应的 Provider 时,
useContext返回的就是这个默认值。
jsx
// 场景:如果组件这样写(没有包裹 Provider)
function App() {
return <Page />; // ❌ 没有 ThemeContext.Provider
}
function Page() {
const ctx = useContext(ThemeContext);
console.log(ctx); // { theme: 'light', setTheme: () => {} } ← 默认值生效!
}
默认值相当于一个 "兜底方案" ,确保即使忘记写 Provider,组件也不会崩溃(当然,setTheme 是个空函数,实际上无法切换主题)。
4. 第二关:提供数据 ------ Provider
jsx
import { ThemeContext } from './ThemeContext';
import Page from './components/Page';
import { useState } from 'react';
function App() {
const [theme, setTheme] = useState('light');
return (
<>
<ThemeContext.Provider value={theme}>
<Page />
<button onClick={() => setTheme('dark')}>切换主题</button>
</ThemeContext.Provider>
</>
);
}
export default App;
🔍 逐行解析
| 代码 | 含义 |
|---|---|
const [theme, setTheme] = useState('light') |
在 App 组件中维护 theme 状态,初始值为 'light' |
<ThemeContext.Provider value={theme}> |
核心! 把 theme 值注入到上下文,所有子组件都能拿到 |
<Page /> |
子组件无需 props,直接在 Context 中读取 |
<button onClick={() => setTheme('dark')}> |
点击后把 theme 改为 'dark',触发所有消费者重渲染 |
💡 核心理解:Provider 的工作原理
ini
┌─────────────────────────────────────────────┐
│ ThemeContext.Provider ← 数据发射塔 │
│ value={theme} → "light" │
│ │ │
│ ├── Page ← 可以接收 │
│ │ └── Child ← 可以接收 │
│ ├── 其他组件 ← 都可以接收 │
│ │ │
│ └── 任何后代组件都能通过 useContext │
│ 拿到 "light" 这个值 │
└─────────────────────────────────────────────┘
🔑 Provider 的三个关键特性
- 可以嵌套:内层 Provider 会覆盖外层 Provider 的 value
- 可以动态改变 value:value 变化时,所有消费者都会自动重渲染
- 不局限于全局 :Provider 可以放在组件树的 任意位置,只对该子树有效
⚠️ 注意源码中的一个小设计
项目中 createContext 的默认值是 一个对象 {theme: 'light', setTheme: () => {}},但 Provider 传递的 value 是 一个字符串 'light':
jsx
// 默认值:对象
createContext({ theme: 'light', setTheme: () => {} });
// Provider 传递:字符串
<ThemeContext.Provider value={theme}> // theme = 'light'(字符串)
这是 刻意的设计 ------Provider 的 value 会 完全覆盖 默认值。在这个 demo 中,作者选择只传递主题字符串而不是对象,从而让代码简洁地演示"值被覆盖"的行为。实际项目中,你通常 保持 value 的类型与默认值一致,避免类型混乱。
5. 第三关:消费数据 ------ useContext
jsx
import Child from './Child';
import { ThemeContext } from '../ThemeContext';
import { useContext } from 'react';
const Page = () => {
const theme = useContext(ThemeContext);
console.log(theme); // 输出当前 theme 值,如 "light" 或 "dark"
return (
<>
Page{theme} {/* 渲染 "Pagelight" 或 "Pagedark" */}
<br />
<Child />
</>
);
};
export default Page;
🔍 逐行解析
| 代码 | 含义 |
|---|---|
import { useContext } from 'react' |
引入 useContext Hook |
const theme = useContext(ThemeContext) |
核心! 从 ThemeContext 中读取当前值 |
console.log(theme) |
在控制台打印,方便调试 |
Page{theme} |
将主题值渲染到页面上 |
深层组件 Child
jsx
import useTheme from '../hooks/useTheme';
function Child() {
const theme = useTheme();
console.log(theme);
return (
<>
<h1>Child</h1>
<button className={theme}>按钮{theme}</button>
</>
);
}
export default Child;
🔍 注意:Child 用了自定义 Hook!
Child 没有直接调用 useContext,而是用了 useTheme()。这是项目设计的一个巧妙之处------同一个 Context,两种消费方式:
- Page :直接
useContext(ThemeContext)------ 原始写法 - Child :通过
useTheme()------ 封装后的优雅写法
这两个方法 完全等价,但后者更好(下一节详解为什么)。
6. 第四关:封装自定义 Hook ------ 思维升级
jsx
// react 全面 hooks 编程,可以使用 react, react-router-dom 等
// 提供的 hooks 还可以自定义 hook use 开头函数,自己封装的,简单好用,封装
// 比普通函数的封装,多的地方是可以将 React 响应式、副作用业务等封装进去
// 在 Provider 里任何层级的组件多个地方消费数据,模块化抽离放到 hooks
import { ThemeContext } from '../ThemeContext';
import { useContext } from 'react';
// 约定以 use 开头
export default function useTheme() {
return useContext(ThemeContext);
}
🔍 这段注释本身就是一份学习笔记!
作者在文件中留下了非常珍贵的思考(我把它翻译整理如下):
| 原始注释 | 理解 |
|---|---|
| "react 全面 hooks 编程" | React 已经从 Class 组件全面转向 Hooks 函数组件 |
| "还可以自定义 hook,use 开头函数" | 自定义 Hook 必须用 use 前缀,这是 React 的约定 |
| "简单好用,封装" | 自定义 Hook 的核心价值就是 封装复用 |
| "比普通函数的封装,多的地方是可以将 React 响应式、副作用业务等封装进去" | 自定义 Hook 可以用 useState、useEffect 等 Hooks,普通函数不行 |
| "模块化抽离放到 hooks" | 把逻辑从 UI 组件中抽离到独立的 hooks 文件中 |
💡 为什么要封装自定义 Hook?
方案对比
jsx
// ❌ 方案一:每个组件都直接写(代码重复、耦合度高)
function Page() {
const theme = useContext(ThemeContext); // 需要同时引入 ThemeContext 和 useContext
// ...
}
function Child() {
const theme = useContext(ThemeContext); // 重复代码
// ...
}
function GrandChild() {
const theme = useContext(ThemeContext); // 又重复了
// ...
}
jsx
// ✅ 方案二:封装成自定义 Hook(一次封装,到处使用)
function useTheme() {
return useContext(ThemeContext);
}
function Page() {
const theme = useTheme(); // 简洁、语义化
}
function Child() {
const theme = useTheme(); // 你甚至不需要知道 Context 的存在
}
function GrandChild() {
const theme = useTheme(); // 统一入口,改一处全改
}
封装带来的好处
| 维度 | 直接 useContext |
自定义 useTheme Hook |
|---|---|---|
| 代码量 | 每个组件都要引入 useContext + ThemeContext |
只需引入 useTheme |
| 语义化 | "从 ThemeContext 中取值" | "获取主题",更符合业务语义 |
| 可维护性 | 如果 Context 结构变了,N 个组件都要改 | 只需改 useTheme 一处 |
| 类型安全 | 无类型提示(JS 环境) | 可以在 Hook 中添加类型断言 |
| 扩展性 | 每个组件各自处理 | 可以在 Hook 中统一加日志、错误处理 |
🔑 自定义 Hook 的命名铁律
必须用
use开头! 这不是建议,是 React 的硬性规则。
jsx
// ✅ 正确
function useTheme() { ... }
function useMouse() { ... }
// ❌ 错误(React 会报 lint 警告,Hooks 规则检查也会失败)
function getTheme() { ... }
function themeHook() { ... }
React 的 ESLint 插件会检测所有 useXxx 函数,确保它们遵循 Hook 规则(不可在条件语句、循环中调用)。
7. 第五关:实战延伸 ------ useMouse 自定义 Hook
虽然 useMouse 跟 Context 无关,但它是自定义 Hook 的 另一个经典范例 ,展示了 Hook 如何封装 副作用逻辑。
jsx
import { useState, useEffect } from 'react';
export const useMouse = () => {
const [x, setX] = useState(null);
const [y, setY] = useState(null);
useEffect(() => {
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
function handleMouseMove(e) {
setX(e.clientX);
setY(e.clientY);
}
}, []); // 空依赖数组 = 只在挂载时注册一次
return { x, y };
};
🔍 逐行解析
| 代码 | 含义 |
|---|---|
const [x, setX] = useState(null) |
鼠标 X 坐标,初始为 null(还没移动过) |
const [y, setY] = useState(null) |
鼠标 Y 坐标 |
useEffect(() => {...}, []) |
副作用核心 ,[] 表示只在组件挂载时执行一次 |
document.addEventListener('mousemove', handleMouseMove) |
注册全局鼠标移动监听 |
return () => { removeEventListener(...) } |
清理函数,组件卸载时移除监听,防止内存泄漏 |
return { x, y } |
把鼠标坐标暴露给调用方 |
📝 使用场景
jsx
import { useMouse } from './hooks/useMouse';
function App() {
const { x, y } = useMouse();
return (
<div style={{ height: '100vh', display: 'flex',
alignItems: 'center', justifyContent: 'center' }}>
{x && y ? `x: ${x}, y: ${y}` : '鼠标未移动'}
</div>
);
}
export default App;
💡 这个 Hook 的巧妙之处
- 状态封装 :
x和y的状态管理完全被封装在 Hook 内部,App 只需解构使用 - 副作用隔离 :
addEventListener/removeEventListener的配对逻辑不污染组件 - 可复用性 :任何一个需要追踪鼠标位置的组件,只需
const {x, y} = useMouse()一行代码 - 清理得当 :
useEffect的return函数保证了组件销毁时自动移除监听器
🔗 与 useTheme 的对比
| 对比维度 | useTheme |
useMouse |
|---|---|---|
| 依赖的 React API | useContext |
useState + useEffect |
| 封装的内容 | 状态读取(从 Context 中取值) | 副作用处理(DOM 事件监听) |
| 复杂度 | 极简(一行代码) | 中等(注册/注销/状态更新) |
| 共同点 | 都是 use 开头,都是自定义 Hook |
8. 项目组件树全景图
将两套方案合并到一张图中,你就看清了整个项目的设计脉络:
less
📦 main.jsx
│
├── 导入 App.jsx(路线 B:useMouse 演示)
│ └── App 组件
│ └── useMouse() Hook
│ ├── useState(x, y)
│ └── useEffect(mousemove 监听)
│
└── 导入 App2.jsx(路线 A:Context 演示)★核心
└── App2 组件
├── useState('light') ← 状态源头
├── ThemeContext.Provider ← 数据发射塔
│ value={theme}
│ │
│ ├── <Page /> ← 消费者(直接 useContext)
│ │ └── <Child /> ← 消费者(通过 useTheme Hook)
│ │
│ └── <button>切换主题</button> ← 状态修改者
│
└── ThemeContext ← 上下文定义(createContext)
└── hooks/useTheme.js ← 自定义 Hook 封装
数据流动轨迹
scss
用户点击 "切换主题"
│
▼
setTheme('dark') ← App2 中的 state 更新
│
▼
Provider value 变化 ← React 检测到 Provider value 改变
│
├────▶ Page 重渲染 ← useContext(ThemeContext) → 拿到 'dark'
│ │
│ └────▶ Child 重渲染 ← useTheme() → 拿到 'dark'
│
└────▶ 所有消费者都自动更新! ← 不需要 props 传递
9. 常见误区与踩坑指南
❌ 误区 1:Context 默认值 = Provider 的 value
jsx
const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
// 有人以为 Provider 会自动用默认值:
<ThemeContext.Provider>
<Page /> {/* ❌ 没有 value prop!Page 拿到的还是默认值 */}
</ThemeContext.Provider>
正解 :
value是 必传 的(不传就还是默认值,等于没提供新值)。你必须显式地写:
jsx
<ThemeContext.Provider value={/* 你真正想传的数据 */}>
❌ 误区 2:把 Provider 放错位置
jsx
function App() {
const theme = useContext(ThemeContext); // ❌ 返回默认值!
return (
<ThemeContext.Provider value="dark">
<Page />
</ThemeContext.Provider>
);
}
正解 :
App是 Provider 的 兄弟或父级 ,不在 Provider 内部,所以拿不到 Provider 的 value。Provider 只对 其 JSX 子树 生效。
jsx
// ✅ 拆成两个组件
function App() {
return (
<ThemeContext.Provider value="dark">
<ThemedPage /> {/* 在这个内部才能拿到 value */}
</ThemeContext.Provider>
);
}
function ThemedPage() {
const theme = useContext(ThemeContext); // ✅ 'dark'
// ...
}
❌ 误区 3:过度使用 Context
| 场景 | 建议 |
|---|---|
| 全局主题、语言、用户信息 | ✅ 适合 Context |
| 组件间的临时共享状态 | ❌ 用 props 或状态提升更合适 |
| 高频变化的状态(如动画帧、输入框值) | ❌ 会导致大量不必要重渲染,考虑状态管理库 |
| 只在父子间传递的数据 | ❌ 直接用 props 即可,不要过度设计 |
❌ 误区 4:Provider value 用对象字面量导致不必要渲染
jsx
// ❌ 每次 App 重渲染,{theme, setTheme} 都是新对象,导致所有消费者重渲染
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Page />
</ThemeContext.Provider>
);
}
// ✅ 用 useMemo 缓存 value 对象
function App() {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>
<Page />
</ThemeContext.Provider>
);
}
不过这个 demo 项目中传的是字符串
'light'/'dark'(原始值),不会有这个性能问题 👍
10. 总结:一张图消化全部知识点
📊 React Context 完整使用流程
ini
┌───────────────────────────────────────────────────────────┐
│ 步骤 1: 创建上下文 │
│ ┌───────────────────────────────────────────────────┐ │
│ │ createContext(defaultValue) │ │
│ │ → 返回一个 Context 对象 │ │
│ │ → 包含 Provider 组件 和 Consumer(历史遗留) │ │
│ └───────────────────────────────────────────────────┘ │
│ ↓ │
│ 步骤 2: 提供数据(在祖先组件中) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ <ThemeContext.Provider value={数据}> │ │
│ │ {children} │ │
│ │ </ThemeContext.Provider> │ │
│ └──────────────────────────────────────────────────┘ │
│ ↓ │
│ 步骤 3: 消费数据(在任意后代组件中) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 方式 A: const value = useContext(ThemeContext) │ │
│ │ 方式 B: 封装自定义 Hook → const value = useTheme()│ │
│ └───────────────────────────────────────────────────┘ │
│ ↓ │
│ 步骤 4: 封装自定义 Hook(进阶) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ function useXxx() { return useContext(XxxCtx); } │ │
│ │ → 语义化、可维护、可扩展 │ │
│ └───────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
🎯 三个核心 API + 一个最佳实践
| API | 作用 | 类比 |
|---|---|---|
createContext(defaultValue) |
创建上下文,定义"通道" | 修建管道 |
<Ctx.Provider value={...}> |
提供数据,注入"水源" | 接通水源 |
useContext(Ctx) |
消费数据,读取"水流" | 打开水龙头 |
| 最佳实践:封装自定义 Hook | 包装 useContext,提供更干净的接口 |
安装智能水表 🚿 |
📝 从这个小项目中你学到了什么
- Context 解决了 Props Drilling:不再需要一层层手动传递数据
- Provider 决定范围:只有被 Provider 包裹的组件才能访问 Context
- useContext 是消费方式:简洁、Hook 风格的数据读取
- 自定义 Hook 是灵魂:将 Context 消费逻辑封装成语义化的自定义 Hook,真正做到"关注点分离"
- 自定义 Hook ≠ 只能用 Context :
useMouse展示了 Hook 如何封装状态+副作用,和 Context 完全解耦
📚 延伸阅读 :如果你想继续深入,建议学习
useReducer+ Context 组合模式(轻量级状态管理方案),以及useMemo/React.memo如何优化 Context 引发的重渲染问题。