学了 useContext 还是不理解?从 prop drilling 到 useMouse 的一次完整复盘

今日重点

  • 组件通信的四种关系与各自适用边界
  • 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 中间层不需要感知数据
陌生 状态库 全局共享,关系复杂

自测

  1. 兄弟组件通信为什么必须经过父组件?
  2. 状态提升的本质是什么?

参考答案

  1. React 数据只能从上往下流,两个兄弟之间没有横向通道,只能把数据提到公共父组件再分发。
  2. 本质是两个父子通信拼在一起:兄弟 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 变了所有消费者都重渲染,不适合高频更新的数据。

自测

  1. createContext 的参数是什么?和 Provider 的 value 有什么关系?
  2. Provider 是全局的吗?为什么?

参考答案

  1. createContext 的参数是默认值,只在后代组件找不到 Provider 时兜底。Provider 的 value 才是真正的共享数据,会覆盖默认值。
  2. 不是全局的。Provider 放在哪个位置,它的作用域就是它包裹的那部分组件树。外面的组件拿不到数据。

封装 useTheme 的意义

知识点

不封装直接用 useContext(ThemeContext) 也能跑。封装成 useTheme() 有三个好处。

关键代码

javascript 复制代码
import { ThemeContext } from '../ThemeContext'
import { useContext } from 'react'

export function useTheme() {
  return useContext(ThemeContext)
}

为什么这样设计

一、少写 import。 每个消费组件不用同时引 useContextThemeContext,只用引 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 目录属于项目架构的一部分。

自测

  1. 封装 useTheme 的最大价值是什么?
  2. 不封装直接用 useContext(ThemeContext) 有什么隐患?

参考答案

  1. 能在取值前做校验,忘了写 Provider 时立刻报错而不是静默失败。
  2. 没有校验,如果组件没有被 Provider 包裹,会静默拿到默认值,bug 很难发现。

Context 与单向数据流

知识点

Context 没有改变 React 单向数据流的规则。数据还是从上往下流,只是换了一条路。

运行过程

复制代码
props 方式:  App → Page → Child    走楼梯,层层经过
Context 方式: App ═══════ Child    坐电梯,直达

方向永远是祖先到后代,单向往下。子组件依然不能反过来改 Provider 的数据,除非 Provider 同时把 setState 通过 value 传下来。

容易混淆

props Context
数据流向 上→下 上→下
中间组件 必须接收并转发 完全不知情
改变数据 父的 setState 父的 setState(通过 value 传)

单向数据流是 React 的铁律,Context 没打破它,只是跳过了中间层的代码传递。

自测

  1. Context 改变了 React 的单向数据流吗?
  2. 子组件能通过 Context 反过来改父组件的数据吗?

参考答案

  1. 没有。数据永远是祖先到后代,流向方向没变。
  2. 不能。除非父组件把 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 变了也不会渲染。

自测

  1. Page 用了 useTheme,App 重渲染时 Page 一定重渲染吗?React.memo 能拦住吗?
  2. 如果 Page 没用 useTheme 但加了 React.memo,Provider value 变了,Page 会渲染吗?

参考答案

  1. 是的。memo 拦不住 Context 订阅触发的渲染,Page 照样渲染。
  2. 不会。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 开头

自测

  1. 为什么自定义 Hook 必须以 use 开头?
  2. 为什么 Hooks 不能放在条件语句或嵌套函数里?

参考答案

  1. React 靠函数名识别 Hook,只有 use 开头才会被检查 hooks 调用规则。
  2. 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 次渲染对简单组件不会造成可见卡顿,但如果组件树复杂且没有优化,可能成为瓶颈。

自测

  1. 同一事件回调里两次 setState 触发几次渲染?
  2. 什么情况下用 useRef 代替 useState 存鼠标坐标?

参考答案

  1. 一次。React 自动合并同一个事件回调里的多个 setState。
  2. 当坐标不需要显示在 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 前面。

自测

  1. useEffect 的清理函数在什么时候执行?
  2. 不清理事件监听会有什么后果?

参考答案

  1. 组件卸载前执行,用来清理 effect 中创建的资源。
  2. 监听器一直占着内存,组件反复挂载卸载 → 内存泄漏 → 性能下降。

代码串起来

以 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 为什么不能嵌套在普通函数里吗?
  • 我能回答每个章节的自测题吗
相关推荐
玉宇夕落1 小时前
React useContext 与自定义 Hook —— 从零到一理解跨组件通信与状态共享
前端
涛涛ing1 小时前
一行命令让AI帮你修性能问题:perfpatch正在改变前端优化的游戏规则
前端
huabuyu1 小时前
模型吐到一半的 JSON 为什么不崩、不卡、不抖?流式 Function Call 的三层修复
前端·javascript
触底反弹1 小时前
JS 运行原理与 Event Loop:从 Web Worker 实战说起
前端·javascript·react.js
0__O1 小时前
从一段 JS 回调变为异步的代码来了解 JavaScript 异步编程
javascript
无糖可可果1 小时前
React Context 与自定义 Hooks 学习分享
前端
胡萝卜术1 小时前
声明式与命令式的边界:从鉴权路由守卫到 useRef 的引用哲学
前端·javascript·面试
Python私教1 小时前
如意 Django CRM 容器化实战:后端、前端、数据库、Redis 的协同启动逻辑
前端·数据库·django
默_笙1 小时前
🍉 我把《天龙八部》喂给了 AI,问它"段誉会什么武功?"答案让我惊呆了
前端·javascript