今日重点
- 组件通信的四种关系与各自适用边界
- useContext 三步模板:createContext → Provider → useContext
- 封装自定义 Hook 的意义:复用、校验、隔离
- Context 与单向数据流的关系
- 谁触发组件渲染,memo 能拦住谁拦不住谁
- useMouse 的抽象过程与 mousemove 高频渲染的选择
- useEffect 借还模式与 Hooks 调用规则
知识关系
组件通信是背景问题 → useContext 是跨层级通信方案 → Provider 的 value 变化驱动数据订阅者渲染 → 封装自定义 Hook 把 Context 消费逻辑模块化 → useMouse 展示如何把事件监听 + 状态管理抽象为可复用 Hook → 高频事件引出 useState 与 useRef 的渲染差异 → useEffect 清理函数保证不泄漏。
组件通信的四种关系
知识点
React 页面由组件构成,组件之间需要传递数据,这就是组件通信。根据组件在树中的位置关系,有四种通信方式。
关键代码
父子:props(父→子) + 回调函数(子→父)
兄弟:状态提升到公共父组件
爷孙:useContext 隧道直达
陌生:状态管理库(Redux、Zustand)
运行过程
父子是最基础的模型:父把数据写在子组件的标签属性上,子通过函数参数接收。子不能直接改父的数据,只能通过父传下来的回调函数"通知"父。
兄弟没有直接通道,必须把共享数据提升到最近的公共父组件,由父统一管理、分发给两个子。本质是两个父子通信拼在一起。
爷孙就是 prop drilling 的痛点:爷爷有数据,孙子需要用,中间的父亲被迫接收并转发自己不关心的 props。层级越深越难维护。
陌生是两个组件在树中完全没有共同祖先,通常出现在大型应用中,需要专门的状态管理库。
为什么这样设计
React 坚持单向数据流:数据从父流向子,不能反向。好处是数据变化可追踪------只有持有 state 的组件才能改它,其他组件只能通过回调"申请"。如果数据可以双向随意修改,bug 排查时会找不到元凶。
使用边界
| 关系 | 方案 | 适用场景 |
|---|---|---|
| 父子 | props | 层级浅,一传一 |
| 兄弟 | 状态提升 | 兄弟少,公共父亲近 |
| 爷孙 | useContext | 中间层不需要感知数据 |
| 陌生 | 状态库 | 全局共享,关系复杂 |
自测
- 兄弟组件通信为什么必须经过父组件?
- 状态提升的本质是什么?
参考答案
- React 数据只能从上往下流,两个兄弟之间没有横向通道,只能把数据提到公共父组件再分发。
- 本质是两个父子通信拼在一起:兄弟 A 通过回调通知父,父通过 props 传给兄弟 B。
useContext 三步模板
知识点
useContext 是 React 提供的跨层级数据传递方案。它在组件树中开一条"数据隧道",后代组件直接从祖先拿数据,不用一层层传 props。
真实问题
下面这种情况,Father 不需要 theme,但必须接收并转发:
javascript
function App() {
const theme = 'dark'
return <Father theme={theme} />
}
function Father({ theme }) { // 不用但必须写
return <Child theme={theme} /> // 必须往下传
}
function Child({ theme }) {
return <div>{theme}</div> // 真正使用
}
层级一深,中间组件全被污染。useContext 解决的就是这个问题。
关键代码
第一步:创建 Context
javascript
import { createContext } from 'react'
export const ThemeContext = createContext('light')
createContext 的参数是默认值,不是共享数据。默认值只在组件没有被 Provider 包裹时兜底使用。真正的共享数据由 Provider 的 value 决定。
第二步:Provider 广播
javascript
function App() {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={theme}>
<Page />
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
切换主题
</button>
</ThemeContext.Provider>
)
}
Provider 放在组件树的任意位置,它的作用域就是它包裹的所有后代组件。Provider 不是全局的------放在哪,范围就到哪。
第三步:useContext 消费
javascript
import { useContext } from 'react'
import { ThemeContext } from '../ThemeContext'
function Child() {
const theme = useContext(ThemeContext)
return <div>{theme}</div>
}
运行过程
arduino
点击"切换主题"按钮
→ setTheme('dark') // App 的 state 变了
→ App 重新执行,theme 变量变成 'dark'
→ <Provider value="dark"> // 广播新值
→ 所有调了 useContext(ThemeContext) 的组件标记为脏
→ Page 重新渲染,拿到 'dark'
→ Child 重新渲染,拿到 'dark'
为什么叫"消费"
React 用"生产者-消费者"模式命名:Provider 是生产者(提供数据),useContext 是消费者(订阅数据)。"消费"不是"获取一次就完了",而是持续订阅------Provider 的 value 一变,消费者自动拿到新值并重新渲染。
类比:自来水厂是 Provider,水管是 Context,你家水龙头是 Consumer。水厂压力一变,你家的水立刻跟着变。你在"消费"自来水,不是去水厂"获取"水。
容易混淆
createContext('默认值') 里的参数不是 共享数据。共享数据是 Provider 的 value:
javascript
const ThemeContext = createContext('light') // 默认值
<ThemeContext.Provider value="dark"> // 这才是共享数据
<Child /> // useContext → "dark"
</ThemeContext.Provider>
<Child /> // 没 Provider → "light"(默认值兜底)
使用边界
| 适合放 Context | 不适合放 Context |
|---|---|
| 全局主题 | 频繁变化的值(输入框) |
| 当前用户信息 | 只在一两个组件用的数据 |
| 语言设置 | 能用 props 简单解决的场景 |
滥用 Context 会让组件数据来源变隐晦,且 value 变了所有消费者都重渲染,不适合高频更新的数据。
自测
- createContext 的参数是什么?和 Provider 的 value 有什么关系?
- Provider 是全局的吗?为什么?
参考答案
- createContext 的参数是默认值,只在后代组件找不到 Provider 时兜底。Provider 的 value 才是真正的共享数据,会覆盖默认值。
- 不是全局的。Provider 放在哪个位置,它的作用域就是它包裹的那部分组件树。外面的组件拿不到数据。
封装 useTheme 的意义
知识点
不封装直接用 useContext(ThemeContext) 也能跑。封装成 useTheme() 有三个好处。
关键代码
javascript
import { ThemeContext } from '../ThemeContext'
import { useContext } from 'react'
export function useTheme() {
return useContext(ThemeContext)
}
为什么这样设计
一、少写 import。 每个消费组件不用同时引 useContext 和 ThemeContext,只用引 useTheme 一个。
二、可以加错误校验。 如果有人忘了写 Provider,没封装时静默返回默认值,bug 很难排查。封装后:
javascript
export function useTheme() {
const context = useContext(ThemeContext)
if (context === null) {
throw new Error('useTheme 必须在 Provider 内部使用!')
}
return context
}
忘了包 Provider → 立刻报错,问题一目了然。
三、改实现不惊动使用者。 假设后面不依赖 Context 了,换其他方案,只要改 useTheme 一个函数,所有消费组件不需要动一行代码。
使用边界
小项目可能感受不到差别,但组件多了、跨团队了,封装的价值就体现出来了。hooks 目录属于项目架构的一部分。
自测
- 封装 useTheme 的最大价值是什么?
- 不封装直接用 useContext(ThemeContext) 有什么隐患?
参考答案
- 能在取值前做校验,忘了写 Provider 时立刻报错而不是静默失败。
- 没有校验,如果组件没有被 Provider 包裹,会静默拿到默认值,bug 很难发现。
Context 与单向数据流
知识点
Context 没有改变 React 单向数据流的规则。数据还是从上往下流,只是换了一条路。
运行过程
props 方式: App → Page → Child 走楼梯,层层经过
Context 方式: App ═══════ Child 坐电梯,直达
方向永远是祖先到后代,单向往下。子组件依然不能反过来改 Provider 的数据,除非 Provider 同时把 setState 通过 value 传下来。
容易混淆
| props | Context | |
|---|---|---|
| 数据流向 | 上→下 | 上→下 |
| 中间组件 | 必须接收并转发 | 完全不知情 |
| 改变数据 | 父的 setState | 父的 setState(通过 value 传) |
单向数据流是 React 的铁律,Context 没打破它,只是跳过了中间层的代码传递。
自测
- Context 改变了 React 的单向数据流吗?
- 子组件能通过 Context 反过来改父组件的数据吗?
参考答案
- 没有。数据永远是祖先到后代,流向方向没变。
- 不能。除非父组件把 setState 也放进 value 传下去------即便如此,"改"的动作发生在子组件,但状态所有权仍然在父组件。
谁触发谁渲染
知识点
Provider 的 value 变了,用 useContext 订阅了该 Context 的组件会重新渲染。但即使没订阅,子组件也会因为父组件渲染而跟着渲染。
关键代码
javascript
// App.jsx
function App() {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={theme}>
<Page />
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切换</button>
</ThemeContext.Provider>
)
}
// Page.jsx --- 用了 useTheme
const Page = () => {
const theme = useTheme()
return <><Child /></>
}
// Child.jsx --- 也用了 useTheme
function Child() {
const theme = useTheme()
return <button>{theme}</button>
}
运行过程
markdown
点击切换按钮
→ setTheme 改了 App 的 state
→ App 重新渲染
→ Page 重新渲染(两个原因:父渲染 + useContext 订阅)
→ Child 重新渲染(两个原因:父 Page 渲染 + useContext 订阅)
Page 和 Child 都有两个渲染原因:父组件带着跑 + useContext 订阅。用 React.memo 可以拦住父渲染链:
javascript
const Page = React.memo(() => {
const theme = useTheme()
return <><Child /></>
})
memo 检查 Page 的 props 没变 → 拦住父渲染链。但 useTheme() 订阅了 Context → Provider value 变了 → memo 拦不住,Page 照样渲染。
为什么这样设计
Context 的订阅机制独立于组件树的父子渲染链。memo 只能拦截 props 变化驱动的渲染,不能拦截 Context 订阅驱动的渲染。这是设计上的保证:一个组件声明了依赖某个 Context,不管中间被什么包裹,数据变了它一定能更新。
易错点
子组件渲染的原因不能只看一点。React.memo 能拦住父渲染链,但拦不住 Context 订阅。反过来,如果组件没订阅 Context 但加了 memo,Provider value 变了也不会渲染。
自测
- Page 用了 useTheme,App 重渲染时 Page 一定重渲染吗?React.memo 能拦住吗?
- 如果 Page 没用 useTheme 但加了 React.memo,Provider value 变了,Page 会渲染吗?
参考答案
- 是的。memo 拦不住 Context 订阅触发的渲染,Page 照样渲染。
- 不会。Page 没订阅 Context,memo 发现 props 没变就不会渲染。
自定义 Hook:useMouse
知识点
自定义 Hook 是把组件里的响应式逻辑(useState、useEffect 等)抽出来,变成一个以 use 开头的可复用函数。
真实问题
在 App 里写了鼠标跟踪功能后,如果别的页面也要用,不能复制粘贴。把它抽象为自定义 Hook。
关键代码
javascript
import { useState, useEffect } from 'react'
const useMouse = () => {
const [x, setX] = useState(null)
const [y, setY] = useState(null)
useEffect(() => {
const handleMouseMove = (e) => {
setX(e.clientX)
setY(e.clientY)
}
document.addEventListener('mousemove', handleMouseMove)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
}
}, [])
return { x, y }
}
export default useMouse
使用方只需一行:
bash
function App() {
const { x, y } = useMouse()
return <div>{x && y ? `坐标:${x}, ${y}` : '请移动鼠标'}</div>
}
为什么以 use 开头
React 靠函数名识别"这是 Hook"。不以 use 开头,React 认为它是普通函数,里面的 useState 直接报错。这是约定,也是运行时规则。
容易混淆
Hooks 不能嵌套在普通函数里,必须直接写在 Hook 函数体内。
错误写法:
scss
const useMouse = () => {
function setup() { // 普通函数
const [x, setX] = useState() // 报错!
}
setup()
}
原因:React 靠 Hooks 的调用顺序来匹配每个 state。第一次渲染时 useState、useState、useEffect 按顺序执行,第二次渲染必须完全一致。如果把 Hook 放在条件或嵌套函数里,顺序可能变化,React 分不清谁是谁。
使用边界
自定义 Hook 和普通函数的区别:
| 普通函数 | 自定义 Hook |
|---|---|
| 纯计算,输入 → 输出 | 里面可以用 useState、useEffect |
| 没有响应式 | 数据变了消费组件跟着渲染 |
| 命名无限制 | 必须以 use 开头 |
自测
- 为什么自定义 Hook 必须以 use 开头?
- 为什么 Hooks 不能放在条件语句或嵌套函数里?
参考答案
- React 靠函数名识别 Hook,只有
use开头才会被检查 hooks 调用规则。 - React 靠 Hooks 的调用顺序匹配 state。条件或嵌套函数可能改变顺序,React 就无法建立正确的对应关系。
mousemove 高频渲染
知识点
mousemove 事件一秒触发几十上百次,每次 setX / setY 都触发渲染。如果数据需要显示在 UI 上,渲染是必须的;如果只需要暂存数据,用 useRef 避免渲染。
关键代码
React 在同一个事件回调里的多个 setState 会合并成一次渲染:
scss
const handleMouseMove = (e) => {
setX(e.clientX) // ─── 合并为一次渲染
setY(e.clientY) // ───
}
如果不需要显示坐标,用 useRef:
javascript
const useMouse = () => {
const pos = useRef({ x: 0, y: 0 })
useEffect(() => {
const handleMouseMove = (e) => {
pos.current = { x: e.clientX, y: e.clientY }
// 不调 setState,零渲染
}
document.addEventListener('mousemove', handleMouseMove)
return () => document.removeEventListener('mousemove', handleMouseMove)
}, [])
return pos
}
为什么这样设计
| useState | useRef | |
|---|---|---|
| 改了会渲染吗 | 会 | 不会 |
| 适合什么 | 数据要显示在 UI 上 | 数据只需暂存、计算 |
| 例子 | 列表、表单、开关 | DOM 引用、计时器 ID、坐标 |
选择标准:需要显示就用 useState,只需背后计算就用 useRef。
使用边界
高频事件(mousemove、scroll、resize)处理时要特别注意性能。一秒钟 60 次渲染对简单组件不会造成可见卡顿,但如果组件树复杂且没有优化,可能成为瓶颈。
自测
- 同一事件回调里两次 setState 触发几次渲染?
- 什么情况下用 useRef 代替 useState 存鼠标坐标?
参考答案
- 一次。React 自动合并同一个事件回调里的多个 setState。
- 当坐标不需要显示在 UI 上,只需暂存用于计算时(如传给 WebSocket)。
useEffect 清理函数
知识点
useEffect 的回调函数可以返回一个清理函数,在组件卸载前执行。这是 React 对原生 API(事件监听、定时器、Worker 线程)的"借还"机制。
关键代码
javascript
useEffect(() => {
document.addEventListener('mousemove', handleMouseMove) // 借
return () => {
document.removeEventListener('mousemove', handleMouseMove) // 还
}
}, [])
运行过程
组件挂载 → 执行 effect 回调 → addEventListener 绑定
组件运行中 → 正常工作
组件卸载 → 执行清理函数 → removeEventListener 解绑
为什么这样设计
组件卸载后,它绑定的事件监听器还在内存里。React 管理自己的虚拟 DOM,但不管 DOM 的 addEventListener、setInterval、new Worker() 等原生 API。这些资源必须手动回收,否则每个组件挂载/卸载一次就多一份泄漏,页面越来越慢。
需要清理的资源:
csharp
addEventListener → removeEventListener
setInterval → clearInterval
new Worker() → worker.terminate()
易错点
const 定义的函数放在 useEffect 后面,虽然这里能运行(effect 回调在渲染后执行,那时函数已初始化),但这是坏习惯。建议把函数定义放在 useEffect 前面。
自测
- useEffect 的清理函数在什么时候执行?
- 不清理事件监听会有什么后果?
参考答案
- 组件卸载前执行,用来清理 effect 中创建的资源。
- 监听器一直占着内存,组件反复挂载卸载 → 内存泄漏 → 性能下降。
代码串起来
以 App2.jsx 为例,完整链路:
scss
用户点击按钮
→ onClick 触发 setTheme('dark')
→ App 重新执行,theme 变为 'dark'
→ <Provider value="dark"> 广播新值
→ Page 里的 useTheme() 感知到变化,Page 重渲染
→ Child 里的 useTheme() 感知到变化,Child 重渲染
→ 页面显示新主题
useMouse 的链路:
markdown
用户移动鼠标
→ 浏览器触发 mousemove 事件
→ handleMouseMove 执行 → setX + setY
→ React 合并为一次渲染
→ App 重渲染 → 页面显示新坐标
最后回顾
Context 解决跨层级组件通信,Provider 是局部容器,useContext 是订阅消费。封装自定义 Hook 让逻辑可复用、可校验。高频事件要区分 useState 和 useRef:要显示就渲染,不显示就 ref。useEffect 清理函数是"借了要还"。Hooks 必须直接写在 Hook 函数顶层,不能嵌套。
自查清单
- 我能说出组件通信的四种关系吗?
- 我能写出 useContext 的三步模板吗?
- 我知道 createContext 的默认值和 Provider value 的区别吗?
- 我知道 Context 没有打破单向数据流吗?
- 我能解释 React.memo 对 Context 订阅组件是否有效吗?
- 我能写出一个自定义 Hook 吗?
- 我知道什么时候用 useState 什么时候用 useRef 吗?
- 我知道 useEffect 清理函数什么时候执行、清理什么吗?
- 我知道 Hooks 为什么不能嵌套在普通函数里吗?
- 我能回答每个章节的自测题吗