React Context 与自定义 Hook 从底层到实践:「跨层级通信 + 副作用封装」全解析

React Context 与自定义 Hook 从底层到实践:「跨层级通信 + 副作用封装」全解析

前言

在 React 开发中,有两个痛点几乎所有开发者都会遇到:

  1. 组件层级太深,props 一层层传递太痛苦
  2. 多个组件需要复用同一段逻辑,复制粘贴太 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>
  );
}

看清楚:整个组件里没有任何 useStateuseEffectaddEventListener 所有的复杂逻辑都被锁在 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 清理机制的底层原理

核心要点

  1. useContext 的本质:在 Fiber 树上跨层级查找 Provider,打破 Props Drilling
  2. 自定义 Hook 的本质:普通函数的复用能力 + React 响应式系统的接入权
  3. useEffect 清理函数:浏览器 API 不在 React 范围内,创建了就必须手动销毁
  4. 工程化思维hooks/ 放逻辑,components/ 放视图,各司其职

如果你觉得这篇文章对你有帮助,欢迎点赞收藏~有问题欢迎在评论区交流 🚀

相关推荐
滴滴答答哒1 小时前
VUE3+element-plus MultiSelect 多选下拉组件
前端·javascript·vue.js
其美杰布-富贵-李1 小时前
04 watch 与 Vue 响应式数据流
前端·javascript·vue.js
赵大仁3 小时前
生成式 UI 实战:用 JSON Schema + React 动态渲染 AI 界面
前端·ai·react·next.js·前端架构·生成式ui
kyriewen4 小时前
我排查了一个React内存泄漏——罪魁祸首是这3个被忽略的清理函数
前端·javascript·面试
IT_陈寒4 小时前
我又被JavaScript的隐式类型转换坑了
前端·人工智能·后端
其美杰布-富贵-李5 小时前
03 ref、reactive 与 computed 响应式数据
前端·javascript·vue.js
OpenTiny社区5 小时前
GenUI SDK v1.3.0 发布|多框架兼容,一键换物料,渲染器 & 演练场全面增强!
前端·ai编程
用户938515635075 小时前
useRef + Web Worker 实战:React 如何优雅地拥抱多线程
前端·javascript·react.js