本文系统讲解 React 中组件通信的痛点、
useContext上下文机制的由来与用法、以及自定义 Hooks 的封装思想。文章遵循"提出问题 → 引出机制 → 三步上手 → 实战封装"的脉络,帮助读者建立起对 React 跨层级数据共享的完整认知。
目录
- 背景:组件通信的困境
- useContext:跨越层级的解决方案
- [三步上手:createContext → Provider → useContext](#三步上手:createContext → Provider → useContext "#%E4%B8%89%E4%B8%89%E6%AD%A5%E4%B8%8A%E6%89%8Bcreatecontext--provider--usecontext")
- [自定义 Hooks:逻辑封装与复用](#自定义 Hooks:逻辑封装与复用 "#%E5%9B%9B%E8%87%AA%E5%AE%9A%E4%B9%89-hooks%E9%80%BB%E8%BE%91%E5%B0%81%E8%A3%85%E4%B8%8E%E5%A4%8D%E7%94%A8")
- 实战:监听鼠标移动的响应式坐标
- 总结
一、背景:组件通信的困境
1.1 组件间的几种关系
在 React 应用中,组件并不是孤立存在的,它们之间需要共享数据、协同工作。按组件在组件树中的位置关系,大致可以划分为以下几类:
| 关系类型 | 说明 | 典型通信方式 |
|---|---|---|
| 父子组件 | 直接嵌套的上下级 | props 向下传数据 + 自定义事件向上通知 |
| 兄弟组件 | 拥有同一个父级 | 通过父组件"中转",即状态提升 |
| 爷孙组件 | 隔代嵌套 | props 层层传递 |
| 陌生人组件 | 无直接亲缘关系 | 全局状态管理(Redux、Zustand 等) |
1.2 父子通信:单向数据流
React 最核心的数据流动原则是单向数据流 :数据从父组件流向子组件,通过 props 完成传递;子组件若需要向父组件反馈,则通过父组件传入的**自定义事件(回调函数)**来实现。
jsx
// 父组件
<Child name="小明" onUpdate={handleUpdate} />
// 子组件通过 props 接收数据,通过调用回调通知父组件
当需要多个组件共享同一份数据时,最朴素的做法就是统一数据状态管理 ------把状态提升到它们共同的祖先组件,再通过 props 分发下去。
1.3 层层传递的"搬运工"问题
然而,当组件层次比较深 时,props 的逐层传递就会暴露出明显的弊端:
arduino
App
└─ Page // 需要 theme 吗?不需要,但必须帮忙往下传
└─ Child // 需要 theme 吗?也不需要,还得继续传
└─ GrandChild // 真正需要 theme 的地方
如上图所示,如果只有最深层的 GrandChild 需要用到某个数据(比如主题、当前用户、语言等),那么中间的 Page、Child 即便完全用不到这个数据,也必须一层一层地把它作为 props 传递下去,充当纯粹的"搬运工"。
这就是著名的 Prop Drilling(道具钻探 / 属性逐层穿透) 问题------层次太深,搬运工作就太"那啥"了,代码既啰嗦又难维护。更糟的是,一旦中间某个组件漏传,或某个层级改了名字,整条链路都会断裂。
二、useContext:跨越层级的解决方案
2.1 上下文(Context)的思想
React 官方给出的答案是 Context(上下文)。它的核心思想非常直观:
- 在组件树的最外层提供一个"数据上下文"------把共享数据注入到环境中
- 通过
Context.Provider组件包裹整棵(或某一段)组件树 - 任何层级的后代组件都可以**直接消费(读取)**这个上下文,而不必关心它处于树的哪一层
xml
<Provider value={theme}> ← 最外层提供数据上下文
└─ Page ← 中间层不再需要搬运 props
└─ Child
└─ GrandChild ← 任意层级直接消费数据
</Provider>
一句话概括:跨越层级,不考虑层级;连接上下文,消费数据。
2.2 useContext 的作用
useContext 是 React 提供的 Hook,用于在函数组件中读取某个 Context 的值。它的能力可以概括为:
useContext可以跨层级直接读取组件上下文数据,省去逐层向下传递 props。
三、三步上手:createContext → Provider → useContext
使用 Context 的标准流程固定为三个步骤,非常清晰:
① createContext 创建上下文
② Provider 组件包裹组件树
③ useContext 消费上下文
下面结合项目中的主题切换示例,逐一拆解。
3.1 第一步:createContext 创建上下文
jsx
// 将创建一个 Theme 上下文,为深层次组件树提供了主题共享数据。
import { createContext } from 'react';
export const ThemeContext = createContext('light');
createContext(defaultValue)创建一个上下文对象,'light'是默认值。- 当某个消费组件在组件树中找不到对应的
Provider时,就会退而使用这个默认值。 - 这里把
ThemeContext单独导出,方便其他文件导入使用。
3.2 第二步:Provider 组件包裹组件树
jsx
import { useState } from 'react';
import { ThemeContext } from './ThemeContext';
import Page from './components/Page';
function App() {
const [theme, setTheme] = useState('light');
return (
// 上下文提供者(容器)
// 并不是需要全局,任何地方都可以作为容器使用
// 默认值 light,可以通过 value 来改变
<ThemeContext.Provider value={theme}>
<Page />
<button onClick={() => setTheme("dark")}>切换主题</button>
</ThemeContext.Provider>
);
}
export default App;
关键点解读:
Provider是容器,而非必须全局 :Provider包裹的范围可以灵活控制,既可以是整棵应用树,也可以是任意一个局部子树。被包裹范围内的组件才能消费到该上下文。value是动态的 :虽然createContext('light')定义了默认值,但真正提供给下游组件的是Provider上的value属性。这里通过useState管理theme,点击按钮调用setTheme("dark")即可动态改变主题,所有消费方会随之自动更新。
3.3 第三步:useContext 消费上下文
消费方组件通过 useContext 读取数据。示例中提供了两个不同层级的消费组件:
中间层组件 Page:
jsx
import Chlid from './Chlid';
import { useTheme } from '../hooks/useTheme';
const Page = () => {
const theme = useTheme();
console.log(theme);
return (
<>
Page {theme}
<br />
<Chlid />
</>
);
}
export default Page;
深层组件 Chlid:
jsx
import { useTheme } from '../hooks/useTheme';
function Chlid() {
const theme = useTheme();
console.log(theme);
return (
<>
Child
<button className={theme}>按钮{theme}</button>
</>
);
}
export default Chlid;
可以清楚地看到:Page 和 Chlid 之间没有通过 props 传递任何主题数据 ,它们各自独立地调用 useTheme() 直接读取上下文。无论组件嵌套多深,都能"隔空取物"。
四、自定义 Hooks:逻辑封装与复用
4.1 为什么需要自定义 Hooks
在上面的示例中,Page 和 Chlid 都执行了几乎相同的逻辑------调用 useTheme() 获取主题。与其在每个组件里重复写 useContext(ThemeContext),不如把这段逻辑封装成一个可复用的自定义 Hook。
4.2 自定义 Hooks 的约定与价值
React 对自定义 Hook 有几条明确的约定:
- 必须以
use开头:这是 React 识别 Hook 的硬性约定,编译器与 linter 都依赖这个命名规则。 - 通常放在
hooks目录下:属于架构层面的基础设施,独立于具体业务组件。
那么,自定义 Hook 相比普通函数封装,究竟"多"了什么?关键在于它能够把 React 的响应式能力、副作用逻辑一并封装进去,而这些是普通函数做不到的:
React 全面进入 Hooks 编程时代,既可以使用 React、react-router-dom 等库提供的 Hooks,也可以自定义 Hook------以
use开头的函数,自己封装,简单好用。相比普通函数的封装,自定义 Hook 多的地方在于:可以将 React 的响应式、副作用等业务逻辑封装进去。
4.3 封装 useTheme
jsx
// 在 Provider 里,任何层级的组件都可以多个地方消费数据,模块化抽离放到 hooks
import { ThemeContext } from '../ThemeContext';
import { useContext } from 'react'; // 消费数据 context
// 约定以 use 开头
export function useTheme() {
return useContext(ThemeContext);
}
这个 useTheme 极其简洁,但它体现了封装的核心价值:
- 隐藏实现细节:组件只需关心"拿到主题",不必关心数据来自哪个 Context。
- 统一消费入口 :如果将来主题数据来源改变(比如加入了多主题、主题对象化),只需改
useTheme一处,所有组件无感知。 - 语义化 :
useTheme()读起来比useContext(ThemeContext)更贴近业务语义。
五、实战:监听鼠标移动的响应式坐标
readme.md 的最后提出了一个更完整的实战练习:
监听鼠标移动事件,将鼠标坐标显示在页面上。
这个需求拆解为两个动作:抽象 (提炼出"响应式的鼠标坐标")与封装(放进 hooks 目录)。下面看完整实现。
5.1 useMouse:响应式坐标的封装
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 () => {
// 函数组件卸载后,不会主动回收
// 定时器、worker、事件 需要手动回收
document.removeEventListener('mousemove', handleMouseMove);
};
function handleMouseMove(e) {
setX(e.clientX);
setY(e.clientY);
}
}, []);
return { x, y };
};
这段代码包含多个关键知识点:
① 响应式状态
useState(null) 分别维护 x、y 两个坐标状态,初始值为 null。当鼠标移动时,通过 setX / setY 更新,触发组件重新渲染------这就是"响应式"的本质。
② 副作用与事件监听
useEffect 负责注册全局的 mousemove 事件监听。依赖数组 [] 表示该副作用只在组件挂载时执行一次,避免了每次渲染重复注册监听器。
③ 清理函数(cleanup)
这是最容易忽略却至关重要的一环:
jsx
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
React 的函数组件在卸载后不会主动回收 诸如定时器、Worker、事件监听器之类的资源。如果不手动清理,每次组件卸载都会残留一个"僵尸监听器",持续占用内存、更新已卸载组件的状态,进而引发内存泄漏。因此必须在 useEffect 返回的清理函数中显式 removeEventListener。
④ 返回值
return { x, y } 将坐标以对象形式暴露给使用方,便于解构。
5.2 在组件中使用 useMouse
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;
要点:
- 通过解构
const { x, y } = useMouse()拿到响应式坐标。 - 页面采用 Flex 布局,让坐标文字居中显示。
- 三元表达式
x && y ? ... : '鼠标移动'处理初始状态:当x、y尚为null时(鼠标还未移动),显示提示文字"鼠标移动";一旦坐标产生,就实时渲染x:xxx, y:xxx。
至此,一个"响应式鼠标坐标"的能力就被完整地抽象、封装并消费了。任何需要鼠标坐标的组件,都可以通过一行 useMouse() 轻松获得,而无需关心事件注册、状态管理、资源回收等底层细节。
六、总结
本文围绕 useContext 与自定义 Hooks 两条主线展开,可以提炼为如下要点:
6.1 useContext 解决的核心问题
- 背景 :组件层次较深时,
props逐层传递会产生繁琐的"搬运"代码(Prop Drilling)。 - 方案:通过 Context 在最外层提供数据,后代组件跨越层级直接消费。
- 三步走 :
createContext创建 →Provider包裹 →useContext消费。
6.2 自定义 Hooks 的价值
- 约定 :以
use开头的函数,通常放在 hooks 目录。 - 能力:不仅封装普通逻辑,更能封装响应式状态与副作用。
- 示例 :
useTheme封装上下文消费;useMouse封装事件监听与坐标状态,并正确处理资源清理。
6.3 一句话回顾
当数据需要在深层组件间共享时,用
useContext跨越层级、免去层层props传递;当逻辑需要在多处复用、尤其是包含响应式与副作用时,用自定义 Hooks 把它抽象封装。二者结合,正是现代 React 应用中保持组件简洁、逻辑复用的关键手段。