React Context 与自定义 Hook 从底层到实践:「跨层级通信 + 副作用封装」全解析
前言
在 React 开发中,有两个痛点几乎所有开发者都会遇到:
- 组件层级太深,props 一层层传递太痛苦
- 多个组件需要复用同一段逻辑,复制粘贴太 low
React 官方的解决方案是:useContext 解决第一个问题,自定义 Hook 解决第二个。二者结合,就是 React 全面 Hooks 编程的核心武器。
本文将以一个主题切换 Demo 和一个鼠标追踪 Demo 为例,从组件通信的底层困境出发,逐步推导出 Context 的设计动机,再深入到自定义 Hook 的封装思想,最后落到 useEffect 清理机制的底层原理。
读完你会理解的不只是"怎么用",更是"为什么这样设计"。
一、组件通信的困局 ------ 为什么需要 Context?
1.1 React 数据的单向流动
React 的核心设计原则是单向数据流 :数据只能从父组件通过 props 向下传递给子组件。
markdown
App(持有数据)
└─ Parent(转发 props)
└─ Child(转发 props)
└─ GrandChild(终于用上了)
这种模式在层级少的时候没问题,但一旦组件树变深,就会出现 Props Drilling(逐层透传) 问题------中间组件明明不需要这个数据,却被迫充当"搬运工",代码既啰嗦又难以维护。
1.2 关系的分类
组件之间的关系可以分成四种:
| 关系类型 | 通信方式 | 痛点 |
|---|---|---|
| 父子 | props 直接传递 | 天然支持,无痛点 |
| 兄弟 | 状态提升到公共父组件 | 需要额外设计 |
| 爷孙 | props 层层转发 | 层级太深,搬运太痛苦 |
| 陌生人 | 全局状态管理 | 需要 Redux/Zustand 等外援 |
Context 正是为了解决爷孙关系和深层组件树的通信问题而生的。
二、useContext 三步走 ------ 创建、提供、消费
useContext 的使用遵循一个固定三段式,理解了这三步就理解了整个 Context 机制。
2.1 第一步:createContext ------ 创建上下文
javascript
// ThemeContext.jsx
import { createContext } from 'react';
// createContext("light") 中的 "light" 是默认值
// 当组件树中没有 Provider 时,useContext 会返回这个默认值
export const ThemeContext = createContext("light");
底层理解 :createContext 本质上创建了一个"数据管道"。这个管道本身不存数据,它只是一个通道的声明------告诉 React:"我定义了一种可以被跨层级传递的数据类型"。返回的 ThemeContext 对象上挂着两个关键东西:Provider(发送端)和 Consumer(接收端,但 Hooks 时代我们用 useContext 替代了它)。
2.2 第二步:Provider ------ 注入数据
xml
// 在根组件或任一上层组件中
<ThemeContext.Provider value="dark">
<Page />
</ThemeContext.Provider>
底层理解 :Provider 是 React 内部实现的一个特殊组件。当你给它传 value 时,React 会把这个值存入 Fiber 节点上的一个链表中。每个 Provider 对应链表的一个节点,组件树中可以有多个同类型 Provider 嵌套------内层的会覆盖外层的。这正是"最近 Provider 生效"的底层原理。
2.3 第三步:useContext ------ 消费数据
javascript
// Page.jsx
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';
const Page = () => {
const theme = useContext(ThemeContext); // 返回最近的 Provider 的 value
console.log(theme); // "dark"
return (
<>
Page {theme}
<Child />
</>
);
};
底层理解 :useContext 会在组件渲染时,沿着 Fiber 树向上遍历,查找最近的对应类型的 Provider,取出其 value 返回。这个过程和组件层级无关------无论 Page 嵌套多深,它都能跳过中间所有层级,直接读取 Provider 的数据。
关键细节 :一旦 Provider 的 value 发生改变(引用变化),所有使用该 Context 的组件都会重新渲染 。React 通过 Object.is 来比较新旧 value,所以如果你传的是对象字面量 value={{ theme: "dark" }},每次 App 渲染都会创建一个新对象,导致性能问题------这是 Context 最常见的坑之一。
三、自定义 Hook ------ 封装的力量
3.1 为什么需要自定义 Hook?
假设你有 10 个组件都要读主题,每个组件都要写:
javascript
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';
// ...
const theme = useContext(ThemeContext);
两行 import + 一行调用,写了 10 遍。而且如果以后改逻辑(比如加上主题切换函数),每个组件都要改------这就是散弹式修改。
3.2 封装 useTheme
javascript
// hooks/useTheme.js
import { ThemeContext } from '../ThemeContext';
import { useContext } from 'react';
// 自定义 Hook 必须以 use 开头 ------ 这是 React 的约定
// 因为 React 的 ESLint 插件靠这个前缀识别 Hook,做规则校验
export function useTheme() {
return useContext(ThemeContext);
}
现在组件里只需要一行:
javascript
// Child.jsx
import { useTheme } from '../hooks/useTheme';
function Child() {
const theme = useTheme(); // 干净利落
return <button className={theme}>按钮 {theme}</button>;
}
3.3 自定义 Hook 的本质
很多人问:自定义 Hook 和普通函数有什么区别?
答案在于它能"容纳"React 的响应式系统:
| 普通函数 | 自定义 Hook | |
|---|---|---|
| 能封装逻辑 | ✅ | ✅ |
| 能调用 useState | ❌(违反 Hook 规则) | ✅ |
| 能调用 useEffect | ❌ | ✅ |
| 能调用其他 Hook | ❌ | ✅ |
| 能返回响应式数据 | ❌ | ✅ |
自定义 Hook = 普通函数的复用能力 + React 响应式系统的接入权。
它把"响应式状态 + 副作用 + 业务逻辑"打包成一个黑盒,对外只暴露最简单的接口。这就是笔记里说的"比普通函数的封装,多的地方是可以将 React 响应式、副作用业务等封装进去"。
四、实战:useMouse ------ 封装鼠标追踪逻辑
上面 useTheme 只是一个简单的"包装器"------一行 useContext 而已。真正的自定义 Hook 威力体现在封装包含副作用的复杂逻辑上。
4.1 完整的 useMouse Hook
javascript
// hooks/useMouse.js
import { useState, useEffect } from 'react';
export const useMouse = () => {
// 响应式状态:鼠标坐标
const [x, setX] = useState(null);
const [y, setY] = useState(null);
// 鼠标移动回调
const handleMouseMove = (e) => {
setX(e.clientX);
setY(e.clientY);
};
useEffect(() => {
// 组件挂载:绑定事件监听
document.addEventListener('mousemove', handleMouseMove);
// 组件卸载:清理副作用
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
}, []); // 空依赖数组 = 只在挂载/卸载时执行
return { x, y };
};
4.2 在组件中使用
arduino
// 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 && y ? `x: ${x}, y: ${y}` : '鼠标未移动'}
</div>
);
}
看清楚:整个组件里没有任何 useState、useEffect、addEventListener。 所有的复杂逻辑都被锁在 useMouse 里面,组件只管"消费数据"。
这就是自定义 Hook 的最高境界:让组件只关心"是什么",不用关心"怎么实现" 。
五、useEffect 清理机制 ------ 为什么必须手动回收?
这是很多新手容易踩的坑,也是笔记中最重要的知识点之一。
5.1 React 只管 DOM,不管浏览器 API
React 的工作范围是虚拟 DOM → 真实 DOM。当你写:
javascript
document.addEventListener('mousemove', handleMouseMove);
这行代码调用的是浏览器的原生 API ,跳出了 React 的管辖范围。React 在卸载组件时会把对应的 DOM 节点从页面移除,但它不会、也不能自动帮你:
- 清除定时器(
clearInterval) - 终止 Web Worker(
worker.terminate()) - 解绑事件监听(
removeEventListener)
5.2 不清理的后果
| 资源类型 | 不清理会发生什么 |
|---|---|
定时器 setInterval |
组件已卸载,定时器还在后台跑。如果回调里调了 setState,React 会在控制台报内存泄漏警告 |
事件监听 addEventListener |
组件都不在了,但回调还被 document/window 持有引用,整个闭包无法被 GC 回收,造成内存泄漏 |
| Web Worker | 线程持续占用 CPU 资源,直到用户关闭页面 |
| WebSocket/订阅 | 连接保持,收到推送后尝试更新一个不存在的组件 → 直接报错 |
5.3 useEffect 的 return ------ 清理函数
scss
useEffect(() => {
// 副作用:创建资源
const timer = setInterval(() => tick(), 1000);
// return 一个清理函数
return () => {
// 副作用:销毁资源
clearInterval(timer);
};
}, []);
底层原理 :React 在重新执行 effect 之前(依赖变化时)或组件卸载之前,会先调用上一次返回的清理函数。这个机制保证了创建和销毁总是成对出现:
挂载 → 执行 effect 函数(创建)
依赖变 → 先执行上次的清理函数(销毁),再执行新的 effect(创建)
卸载 → 执行清理函数(销毁)
这就是笔记里说的:「函数组件卸载后,不会主动回收的。定时器、Worker、事件------手动回收。 」
六、项目目录结构 ------ 工程化思维
回头看整个 Demo 的目录:
css
src/
├── hooks/ ← 自定义 Hook,属于架构层
│ ├── useTheme.js ← 封装 Context 消费逻辑
│ └── useMouse.js ← 封装鼠标追踪逻辑
├── components/ ← UI 组件,只管渲染
│ ├── Page.jsx
│ └── Child.jsx
├── ThemeContext.jsx ← Context 定义
├── App.jsx ← 根组件
└── main.jsx ← 入口
分层思想:
hooks/是逻辑层------封装所有"怎么实现"components/是视图层------只管"展示什么"ThemeContext.jsx是数据管道------连接 Provider 和 Consumer
这是 React Hooks 时代的核心架构模式:关注点分离,逻辑复用靠 Hook,UI 复用靠组件。
七、总结
本文从组件通信的困境出发,逐层推导了以下知识链路:
markdown
组件通信痛点 → createContext + Provider + useContext(基础三步)
→ 自定义 Hook 封装 Context(useTheme)
→ 自定义 Hook 封装副作用(useMouse)
→ useEffect 清理机制的底层原理
核心要点:
- useContext 的本质:在 Fiber 树上跨层级查找 Provider,打破 Props Drilling
- 自定义 Hook 的本质:普通函数的复用能力 + React 响应式系统的接入权
- useEffect 清理函数:浏览器 API 不在 React 范围内,创建了就必须手动销毁
- 工程化思维 :
hooks/放逻辑,components/放视图,各司其职
如果你觉得这篇文章对你有帮助,欢迎点赞收藏~有问题欢迎在评论区交流 🚀