别再逐层传 Props 了!useContext + 自定义 Hook 彻底搞定 React 跨层通信
当组件嵌套到第四层、第五层时,你还在用 props 逐层搬运数据吗?
useContext是 React 提供的跨层级通信方案 ,配合自定义 Hook,不仅能解决 "Props Drilling" 地狱,还能将响应式状态和副作用逻辑封装成可复用的架构单元。本文从组件通信的四种关系出发,带你完整掌握 Context API 三步法、自定义 Hook 封装策略,以及hooks目录的架构意义。全文代码可直接运行,建议收藏后动手实践。
一、React 组件通信的四种关系
在真实项目中,组件之间的关系远不止"父子"那么简单。理解这四种关系,是选择正确通信方案的前提。
css
┌──────────────────────────────────────────────────────────┐
│ 组件通信关系图 │
│ │
│ ① 父子关系 ② 兄弟关系 ③ 爷孙关系 │
│ │
│ App App App │
│ ├── Parent ├── A ├── Parent │
│ │ └── Child ├── B │ └── Child │
│ │ ↑ │ └── GrandChild│
│ ↑ props向下 需要共同父组件 ↑ 层层传递太深 │
│ ↓ 事件向上 来中转数据 ↓ │
│ │
│ ④ 陌生人关系 │
│ │
│ App │
│ ├── Header(需要主题数据) │
│ ├── Content │
│ │ ├── Sidebar(也需要主题数据) │
│ │ └── Main │
│ │ └── Card(还需要主题数据) │
│ └── Footer │
│ │
│ 毫无直接父子关系的组件,需要共享同一份数据 │
└──────────────────────────────────────────────────────────┘
1.1 父子通信:props + 自定义事件
jsx
// 父 → 子:props 向下传递数据
<Child theme="dark" />
// 子 → 父:自定义事件向上传递
<Child onThemeChange={(newTheme) => setTheme(newTheme)} />
这是 React 最基础的通信方式。单向数据流:数据通过 props 向下流动,事件通过回调向上传递。
1.2 兄弟通信:状态提升
jsx
// 兄弟组件不能直接通信,需要将共享状态提升到共同的父组件
function App() {
const [theme, setTheme] = useState('light');
return (
<>
<Header theme={theme} />
<Content theme={theme} />
</>
);
}
1.3 爷孙通信:Props Drilling 的开始
jsx
// 爷爷组件有数据,但要传给孙子
function App() {
const [theme, setTheme] = useState('light');
return (
<Parent theme={theme} /> {/* Parent 只是搬运工,自己不用 theme */}
);
}
function Parent({ theme }) {
return (
<Child theme={theme} /> {/* Child 也只是搬运工 */}
);
}
function Child({ theme }) {
return <div className={theme}>终于用到了!</div>;
}
问题来了 :Parent 组件根本不需要 theme,却被迫接收并转发。如果嵌套到第五层,中间有四个组件都在做无意义的搬运。
1.4 陌生人通信:Props Drilling 的地狱
当多个毫无直接关系的组件需要共享同一份数据时,props 逐层传递的成本已经不可接受------这就是 Context 诞生的原因。
二、Context API:跨层通信的三步法
xml
┌──────────────────────────────────────────────────────────┐
│ Context 通信三步法 │
│ │
│ 步骤一:createContext │
│ ┌──────────────┐ │
│ │ createContext │ 创建一个上下文对象 │
│ │ ("light") │ 参数为默认值 │
│ └──────┬───────┘ │
│ │ │
│ 步骤二:Provider 包裹 │
│ ┌──────────────┐ │
│ │ <Context │ 用 Provider 包裹组件树 │
│ │ .Provider │ 通过 value 属性提供数据 │
│ │ value={...}>│ │
│ │ <Page /> │ │
│ │ </Context │ │
│ │ .Provider> │ │
│ └──────┬───────┘ │
│ │ │
│ 步骤三:useContext 消费 │
│ ┌──────────────┐ │
│ │ useContext │ 在任意层级组件中直接读取数据 │
│ │ (Context) │ 无需逐层 props 传递 │
│ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
2.1 第一步:createContext 创建上下文
jsx
// ThemeContext.jsx
import { createContext } from 'react';
// 创建一个 Theme 上下文,为深层次的组件树提供主题共享数据
// 参数 "light" 是默认值:当组件不在任何 Provider 内时使用
export const ThemeContext = createContext('light');
默认值的意义 :如果消费上下文的组件没有被任何 Provider 包裹,就会使用默认值。这保证了组件在脱离 Provider 时依然能正常工作。
2.2 第二步:Provider 包裹组件树
jsx
// App.jsx
import { useState } from 'react';
import { ThemeContext } from './ThemeContext';
import Page from './components/Page';
function App() {
const [theme, setTheme] = useState('light');
return (
// 上下文的提供者容器
// 并不必须是全局的,任何地方都可以作为容器使用
// value 属性决定了消费方拿到的数据
<ThemeContext.Provider value={theme}>
<Page />
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
切换主题
</button>
</ThemeContext.Provider>
);
}
export default App;
关键点:
Provider的value属性发生变化时,所有消费该 Context 的组件都会重新渲染Provider不一定要放在最外层,可以放在任何需要共享数据的子树根部value可以是任意类型:字符串、对象、数组、函数等
2.3 第三步:useContext 消费上下文
jsx
// components/Page.jsx ------ 中间层,不需要 theme,但也不需要转发
import Child from './Child';
import { useTheme } from '../hooks/useTheme';
const Page = () => {
const theme = useTheme();
return (
<>
Page {theme}
<br />
<Child />
</>
);
};
export default Page;
jsx
// components/Child.jsx ------ 深层组件,直接消费上下文
import { useTheme } from '../hooks/useTheme';
function Child() {
const theme = useTheme();
return (
<>
Child
<button className={theme}>按钮{theme}</button>
</>
);
}
export default Child;
注意 :Page 和 Child 都能直接获取到 theme,中间不需要任何 props 传递。这就是 Context 的核心价值------跨越层级,直达消费方。
2.4 完整数据流图
ini
App(Provider,持有 theme 状态)
│
│ ThemeContext.Provider value={theme}
│
├── Page(useContext 消费 theme)
│ │
│ └── Child(useContext 消费 theme)
│
└── button(切换 theme,触发 Provider value 变化)
│
▼
Provider value 变化 → 所有消费方重新渲染
Page 重渲染 ✅ Child 重渲染 ✅
三、自定义 Hook:Context 消费的终极封装
3.1 为什么要封装成自定义 Hook?
直接在每个组件中写 useContext(ThemeContext) 有几个问题:
jsx
// ❌ 每个组件都要重复写两行导入
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';
function Child() {
const theme = useContext(ThemeContext);
// ...
}
问题:
- 每个消费组件都要导入
useContext和ThemeContext - 如果 Context 名称变了,所有文件都要改
- 如果消费逻辑需要增加(比如同时返回
theme和toggleTheme),每个组件都要改 - 语义不清晰------
useContext(ThemeContext)不如useTheme()直观
3.2 useTheme:封装 Context 消费
jsx
// hooks/useTheme.js
import { ThemeContext } from '../ThemeContext';
import { useContext } from 'react';
// 约定以 use 开头的函数就是自定义 Hook
// 封装了 useContext 消费逻辑,可在任意层级复用
export function useTheme() {
return useContext(ThemeContext);
}
消费方只需要:
jsx
// ✅ 简洁、语义化
import { useTheme } from '../hooks/useTheme';
function Child() {
const theme = useTheme();
// ...
}
封装后的优势:
| 对比项 | 直接 useContext | 自定义 Hook useTheme |
|---|---|---|
| 导入行数 | 2 行 | 1 行 |
| 语义化 | useContext(ThemeContext) |
useTheme() |
| 维护成本 | Context 改名 → 全部改 | 只改 hook 文件 |
| 扩展性 | 无法统一增加逻辑 | 可统一增加逻辑 |
| 复用性 | 每个组件重复写 | 一处定义,处处复用 |
3.3 自定义 Hook 的本质
javascript
普通函数封装:
┌──────────────┐
│ function fn() │ 封装纯逻辑
│ return value │ 不能使用 React Hooks
└──────────────┘
自定义 Hook:
┌──────────────────────┐
│ function useXxx() │ 封装逻辑 + 响应式 + 副作用
│ useState / useEffect │ 可以使用所有 React Hooks
│ useContext / useRef │ 返回值驱动组件渲染
│ return { data } │
└──────────────────────┘
自定义 Hook 是以
use开头的函数,它封装了响应式状态、副作用、上下文消费等 React 能力,让逻辑在多个组件之间复用。与普通函数的区别在于:自定义 Hook 内部可以使用 React Hooks。
四、自定义 Hook 进阶:useMouse 鼠标追踪
4.1 需求分析
监听鼠标移动事件,将鼠标坐标实时显示在页面上。
抽象思考:
- 鼠标坐标是响应式数据 (变化时 UI 要更新)→
useState - 监听事件是副作用 (组件挂载时添加,卸载时移除)→
useEffect - 这两个能力需要封装复用 → 自定义 Hook
4.2 实现 useMouse
jsx
// hooks/useMouse.js
import { useState, useEffect } from 'react';
export const useMouse = () => {
const [x, setX] = useState(null);
const [y, setY] = useState(null);
useEffect(() => {
// 在 useEffect 内部定义事件处理函数
function handleMouseMove(e) {
setX(e.clientX);
setY(e.clientY);
}
// 挂载时添加事件监听
document.addEventListener('mousemove', handleMouseMove);
// 清理函数:组件卸载时移除事件监听
// 函数组件卸载后,不会自动回收事件监听、定时器等资源
// 必须手动清理,否则会导致内存泄漏
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
return { x, y };
};
4.3 使用 useMouse
jsx
// App.jsx
import { useMouse } from './hooks/useMouse';
function App() {
const { x, y } = useMouse();
return (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{x !== null && y !== null
? `x: ${x}, y: ${y}`
: '鼠标未移动'}
</div>
);
}
export default App;
4.4 useMouse 的架构解析
scss
useMouse Hook 内部结构:
┌─────────────────────────────────────────┐
│ useMouse() │
│ │
│ ┌─────────────┐ 响应式状态 │
│ │ useState │ x, y 坐标 │
│ │ (null) │ 变化时触发渲染 │
│ └──────┬──────┘ │
│ │ │
│ ┌──────┴──────┐ 副作用管理 │
│ │ useEffect │ 挂载:addEventListener │
│ │ ([]) │ 卸载:removeEventListener│
│ └──────┬──────┘ │
│ │ │
│ ┌──────┴──────┐ 返回值 │
│ │ return │ { x, y } │
│ │ { x, y } │ 消费方解构使用 │
│ └─────────────┘ │
└─────────────────────────────────────────┘
使用方:
const { x, y } = useMouse();
// x, y 是响应式的,变化时组件自动重渲染
4.5 为什么用 x !== null 而不是 x && y?
jsx
// ❌ 有 bug:当鼠标在 (0, 0) 位置时,0 是 falsy 值
{x && y ? `x: ${x}, y: ${y}` : '鼠标未移动'}
// 鼠标移到左上角 (0, 0) 时,会显示 "鼠标未移动"
// ✅ 正确:显式判断 null
{x !== null && y !== null ? `x: ${x}, y: ${y}` : '鼠标未移动'}
// 鼠标在 (0, 0) 时,正确显示 "x: 0, y: 0"
这是一个常见的 JavaScript 隐式类型转换陷阱:0 是 falsy 值,0 && 0 结果为 0,会被判定为 false。
五、架构视角:hooks 目录的意义
5.1 项目结构
bash
src/
├── App.jsx # 根组件
├── ThemeContext.jsx # 上下文定义
├── components/ # UI 组件目录
│ ├── Page.jsx # 页面组件
│ └── Child.jsx # 子组件
└── hooks/ # 自定义 Hook 目录
├── useMouse.js # 鼠标追踪 Hook
└── useTheme.js # 主题消费 Hook
5.2 为什么要有 hooks 目录?
组件的关注点分离:
┌──────────────────────────────────────────────────┐
│ components/ 目录 │
│ 关注:UI 渲染、组件结构、样式 │
│ 职责:展示数据、响应用户交互 │
│ │
│ hooks/ 目录 │
│ 关注:业务逻辑、状态管理、副作用 │
│ 职责:封装响应式状态、事件监听、上下文消费 │
│ │
│ ThemeContext.jsx │
│ 关注:上下文定义 │
│ 职责:创建 Context 对象,定义共享数据契约 │
└──────────────────────────────────────────────────┘
hooks 目录的架构价值:
- 复用性 :
useMouse可以在任意组件中使用,无需重复实现 - 可测试性:Hook 可以独立于组件进行单元测试
- 可维护性:逻辑集中在一处,修改时只需改一个文件
- 语义化 :
useTheme()、useMouse()一看就知道做什么 - 分层清晰:UI 组件专注于渲染,Hook 专注于逻辑
5.3 自定义 Hook 的设计原则
jsx
// ✅ 单一职责:一个 Hook 只做一件事
useMouse() // 只管鼠标位置
useTheme() // 只管主题上下文
useWindowSize() // 只管窗口尺寸
useLocalStorage() // 只管本地存储
// ✅ 返回值设计:对象比数组更灵活
// 对象返回:消费方可以按需解构,顺序无关
const { x, y } = useMouse();
// 数组返回:顺序敏感,但适合固定结构的返回值
const [count, setCount] = useState(0);
// ✅ 清理副作用:所有副作用都要有清理逻辑
useEffect(() => {
const handler = () => { ... };
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
六、Context + 自定义 Hook 的完整通信链路
6.1 主题切换完整流程
bash
用户点击"切换主题"按钮
│
▼
App 组件:setTheme('dark')
│
▼
ThemeContext.Provider value 变化:'light' → 'dark'
│
▼
React 通知所有消费该 Context 的组件重新渲染
│
├── Page 组件:useTheme() 返回 'dark' → 重渲染
│ │
│ └── Child 组件:useTheme() 返回 'dark' → 重渲染
│ │
│ └── <button className="dark">按钮dark</button>
│
└── button 自身重渲染(使用本地 theme 状态)
6.2 完整代码链路
scss
ThemeContext.jsx 创建上下文
│
▼
App.jsx Provider 提供数据
└── ThemeContext.Provider value={theme}
├── Page.jsx useTheme() 消费
│ └── Child.jsx useTheme() 消费
└── button 切换数据
hooks/useTheme.js 封装消费逻辑
└── useContext(ThemeContext)
6.3 Context 与 Props 的对比
| 维度 | Props 逐层传递 | Context 上下文 |
|---|---|---|
| 跨层能力 | 需要每层手动转发 | 直接跨越任意层级 |
| 中间组件 | 被迫接收不需要的数据 | 完全无感知 |
| 维护成本 | 数据路径变更 → 多处修改 | 只改 Provider 和 Hook |
| 性能 | 只有相关组件渲染 | 所有消费方重新渲染 |
| 适用场景 | 父子直接通信 | 跨层共享、全局状态 |
| 数据流 | 清晰可追踪 | 隐式注入,需借助 DevTools |
七、useContext 的注意事项
7.1 Context 的性能问题
jsx
// ⚠️ 每次 Provider 的 value 变化,所有消费方都会重渲染
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{/* 如果 value 每次都创建新对象,所有消费方都会重渲染 */}
<App />
</ThemeContext.Provider>
// ✅ 使用 useMemo 优化
const contextValue = useMemo(
() => ({ theme, toggleTheme }),
[theme]
);
<ThemeContext.Provider value={contextValue}>
<App />
</ThemeContext.Provider>
7.2 Context 嵌套地狱
jsx
// ⚠️ 多个 Context 嵌套,可读性差
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<LanguageContext.Provider value={lang}>
<AuthContext.Provider value={auth}>
<App />
</AuthContext.Provider>
</LanguageContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
// ✅ 封装组合 Provider
function AppProviders({ children }) {
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<LanguageContext.Provider value={lang}>
<AuthContext.Provider value={auth}>
{children}
</AuthContext.Provider>
</LanguageContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
);
}
// 使用
<AppProviders>
<App />
</AppProviders>
7.3 什么时候该用 Context?
适合用 Context 的场景:
✅ 全局主题(theme、暗黑模式)
✅ 用户登录信息(currentUser、token)
✅ 语言/国际化(locale)
✅ 路由状态
✅ 全局配置(API 地址、特性开关)
不适合用 Context 的场景:
❌ 高频变化的数据(如实时鼠标位置 → 用自定义 Hook + 局部状态)
❌ 短暂的、只在父子间传递的数据 → 用 props
❌ 复杂的状态管理(多组件共享 + 频繁更新)→ 考虑 Zustand / Redux
八、React Hooks 的完整分工
scss
React Hooks 体系:
│
├── useState → 响应式业务数据(触发渲染)
├── useEffect → 副作用管理(请求、订阅、DOM 操作)
├── useRef → 非响应式持久引用(DOM、Worker、可变值)
├── useContext → 跨层级通信(消费上下文)
├── useMemo → 计算结果缓存(性能优化)
├── useCallback → 函数引用缓存(性能优化)
├── useReducer → 复杂状态管理(替代 useState)
│
└── 自定义 Hook → 逻辑封装与复用(架构层)
├── useTheme() → 封装 Context 消费
├── useMouse() → 封装状态 + 副作用
└── useXxx() → 封装任意可复用逻辑
每个 Hook 各司其职,自定义 Hook 则是将它们组合起来,形成可复用的逻辑单元。
九、总结
9.1 知识体系图
scss
useContext + 自定义 Hook
│
├── 组件通信四种关系
│ ├── 父子:props + 自定义事件
│ ├── 兄弟:状态提升
│ ├── 爷孙:Props Drilling(痛点)
│ └── 陌生人:Context(解决方案)
│
├── Context API 三步法
│ ├── createContext() 创建上下文,设置默认值
│ ├── Provider 包裹组件树,提供 value
│ └── useContext() 任意层级消费数据
│
├── 自定义 Hook
│ ├── use 开头的函数
│ ├── 内部可使用所有 React Hooks
│ ├── 封装响应式状态 + 副作用 + 上下文
│ └── hooks/ 目录统一管理(架构层)
│
├── 实战案例
│ ├── useTheme → 封装 Context 消费,语义化复用
│ └── useMouse → 封装状态 + 事件监听 + 清理
│
└── 架构最佳实践
├── 组件关注 UI 渲染,Hook 关注业务逻辑
├── 单一职责,一个 Hook 只做一件事
├── 所有副作用必须有清理逻辑
└── Context 适合低频变化的共享数据
9.2 核心概念速查
| 概念 | 要点 |
|---|---|
| Props Drilling | 逐层传递 props,中间组件被迫搬运数据 |
| createContext | 创建上下文对象,参数为默认值 |
| Provider | 包裹组件树,通过 value 提供共享数据 |
| useContext | 在任意层级消费上下文,无需逐层传递 |
| 自定义 Hook | use 开头,封装 React Hooks 逻辑,可复用 |
| hooks 目录 | 架构分层,逻辑与 UI 分离,统一管理 |
| 副作用清理 | 事件监听、定时器等必须手动清理 |
| 性能注意 | Provider value 变化时所有消费方重渲染 |
9.3 一句话总结
useContext解决了跨层通信问题,自定义 Hook 解决了逻辑复用问题,hooks/目录解决了架构分层问题。三者结合,就是 React 函数式编程的最佳实践。
如果这篇文章对你有帮助,欢迎点赞 和收藏!