React useContext 与自定义 Hook —— 从零到一理解跨组件通信与状态共享

0 阅读15分钟

一位初学者的 React Hooks 通关指南:从外卖柜到源码,彻底吃透 useContext 和自定义 Hook


1. 开胃菜:这是啥玩意儿?(用极致白话介绍核心功能)

想象你住在一个超大型小区里(这就是你的 React 应用)。

以前的通信方式(props 逐层传递)

你家住 18 楼,想给 1 楼的物业传达一条信息:"今天晚上我要请客,帮忙留个车位"。按照传统方式,你需要:

  1. 先把消息告诉 17 楼的邻居
  2. 17 楼告诉 16 楼
  3. 16 楼告诉 15 楼
  4. ... 一直传到 1 楼

问题来了:中间那些楼层根本不需要知道这个消息,但她们不得不当"传话筒"。如果小区有 100 层,这种传递简直要命!

这就是 React 中的  "props drilling"(属性穿透)  问题。

useContext 是什么?—— 小区广播系统 🎤

现在物业在小区装了广播系统(这就是 Context):

  • 你在 18 楼对着麦克风说:"我要留车位"
  • 所有楼层都能听到(任何组件都能访问)
  • 但只有 1 楼的物业真正需要这个信息,其他人可以选择"不听"

核心价值跨层级直接通信,不需要中间组件当"传话筒"。

自定义 Hook 是什么?—— 你的"私人工具箱" 🧰

你在小区经常要做三件事:开信箱、拿快递、浇花。每次都要重复"下楼→做事→上楼"这套流程。

你把这三个动作打包成一个"取件浇花套餐"(这就是自定义 Hook):

  • 以后只需要说"执行套餐",自动完成所有步骤
  • 这个套餐还能在小区任何一栋楼复用

核心价值封装可复用的逻辑,把重复代码抽离出来,让组件更干净。


2. 名词解释大全(扫清学习障碍)

🔍 名词 1:Context(上下文)

  • 官方定义:Context 提供了一种在组件树中传递数据的方法,无需在每个层级手动传递 props。

  • 大白话翻译:就是小区里的"广播系统"。任何组件都能"收听"这个频道,获取共享信息。

  • 在代码中如何体现

    javascript

    // 这就是"建立广播频道"
    const ThemeContext = createContext('light');
    
    // 这是"安装广播发射器",所有子组件都能收到信号
    <ThemeContext.Provider value={theme}>
      <Page />
    </ThemeContext.Provider>
    
    // 这是"打开收音机收听"
    const theme = useContext(ThemeContext);
    
  • 解决了什么痛点props drilling(属性穿透) 。如果没有 Context,主题颜色要从 App 传到 GreatGrandChild,需要经过 4 层组件的"手动搬运"。


🔍 名词 2:Provider(提供者)

  • 官方定义:Provider 接收一个 value 属性,传递给所有消费组件。每个 Context 对象都有一个 Provider 组件。

  • 大白话翻译:就是广播系统的发射塔。你往发射塔里放什么数据(value),所有收听者就能收到什么。

  • 在代码中如何体现

    javascript

    // ThemeContext.Provider 是一个 React 组件
    // value 就是"发射的信号",这里传入了 theme 状态
    <ThemeContext.Provider value={theme}>
      <Page />  {/* 所有子孙组件都能收到 theme */}
      <button onClick={...}>切换主题</button>
    </ThemeContext.Provider>
    
  • 重要细节:Provider 必须包裹需要使用数据的组件。如果组件没被 Provider 包裹,useContext 会使用 createContext 时的默认值。

  • 解决了什么痛点:解决了数据从哪里来的问题。明确指定数据的作用范围——只有被 Provider 包裹的组件树才能访问。


🔍 名词 3:useContext(消费上下文)

  • 官方定义:useContext 是一个 Hook,用于在函数组件中读取 Context 的当前值。

  • 大白话翻译:就是打开收音机的按钮。调对频道(传入 Context 对象),就能听到广播内容。

  • 在代码中如何体现

    javascript

    // 在 useTheme 这个自定义 Hook 里
    export function useTheme() {
      return useContext(ThemeContext);  // 调频到 ThemeContext 频道
    }
    
    // 在组件里使用
    const theme = useTheme();  // 拿到当前主题值
    
  • 解决了什么痛点:解决了数据怎么取的问题。不需要关心数据从哪一层传下来的,直接"调频收听"即可。


🔍 名词 4:自定义 Hook(Custom Hook)

  • 官方定义:自定义 Hook 是一个以 use 开头的 JavaScript 函数,可以调用其他 Hook,用于封装可复用的逻辑。

  • 大白话翻译:就是你的私人工具箱。把经常要做的"一组操作"打包成一个函数,需要时直接调用。

  • 在代码中如何体现

    javascript

    // 这是"鼠标追踪器"工具箱
    export const useMouse = () => {
      const [x, setX] = useState(null);
      const [y, setY] = useState(null);
      
      // 封装了"监听鼠标移动并更新坐标"的完整逻辑
      useEffect(() => {
        document.addEventListener("mousemove", handleMouseMove);
        return () => {
          document.removeEventListener("mousemove", handleMouseMove);
        };
      }, []);
      
      return { x, y };
    };
    
    // 在组件中使用:一行代码获得鼠标坐标
    const { x, y } = useMouse();
    
  • 和普通函数的区别

    • 普通函数:只能封装逻辑,不能使用 React 的 Hook(如 useState、useEffect)
    • 自定义 Hook:可以封装带状态的逻辑,内部可以使用所有 React Hook
  • 解决了什么痛点:解决了逻辑复用的问题。如果多个组件都需要"获取鼠标位置"的功能,不用在每个组件里重复写 useEffect 和事件监听。


🔍 名词 5:副作用(Effect)

  • 官方定义:副作用是指在函数执行过程中,对外部环境产生的影响,如 DOM 操作、网络请求、订阅事件等。

  • 大白话翻译:组件除了"渲染 UI"之外,做的其他"额外事情"。比如:发请求拿数据、绑定鼠标事件、设置定时器。

  • 在代码中如何体现

    javascript

    useEffect(() => {
      // 副作用:给 document 绑定了鼠标移动事件
      document.addEventListener("mousemove", handleMouseMove);
      
      return () => {
        // 清理副作用:移除事件监听,防止内存泄漏
        document.removeEventListener("mousemove", handleMouseMove);
      };
    }, []);  // 空依赖数组 = 只在组件挂载时执行一次
    
  • 为什么需要清理:组件卸载后,如果事件监听还在,会导致:

    • 内存泄漏:占用的内存无法被回收
    • 错误调用:组件都销毁了,事件触发时还在尝试更新已经不存在的组件状态

3. 核心流程图解(把代码跑起来给你看)

流程 1:ThemeContext 的数据流转

image.png

流程 2:useMouse 自定义 Hook 的生命周期

image.png

4. 重难点深度剖析(核心中的核心)

🔥 难点 1:Provider 的"包裹"机制 —— 为什么必须包裹?

为什么要有这个设计?

在 React 中,数据流是单向的(从上到下)。Context 机制打破了"必须逐层传递"的限制,但没有打破"单向流动"的基本原则

数据必须从(Provider)流到(Consumer)。这就是为什么 Provider 必须包裹子组件树——只有被包裹的部分才能接收到数据。

如果不用 Provider 会怎样?

javascript

// 场景:组件没有被 Provider 包裹
function App() {
  return (
    // 没有 ThemeContext.Provider 包裹
    <Page />  // Page 内部调用 useTheme()
  );
}

// Page 组件
function Page() {
  const theme = useTheme();  // 拿到的 theme 是 createContext 时的默认值 'light'
  // 但注意!这个默认值永远不会变化,因为没有任何 Provider 在更新它
}

设计哲学:React 希望数据流向是可预测的。通过强制 Provider 包裹,你能清晰地知道"数据从哪里来,影响哪些组件"。

代码逐行解剖

javascript

// ThemeContext.js
import { createContext } from 'react';

// 第 1 行:createContext 创建了一个"容器"
// 参数 'light' 是"默认值"——当没有 Provider 时的后备方案
// 这个容器内部有一个 _currentValue 属性,初始为 'light'
export const ThemeContext = createContext('light');

javascript

// App.jsx
import { ThemeContext } from './ThemeContext';

function App() {
  // 第 2 行:useState 在 React 的 Fiber 节点上创建了一个"记忆单元"
  // 这个记忆单元存储了 theme 的当前值 'light' 和更新函数 setTheme
  const [theme, setTheme] = useState('light');
  
  return (
    // 第 3 行:JSX 语法糖,实际是 React.createElement(ThemeContext.Provider, { value: theme })
    // 在 React 内部,Provider 会做两件事:
    // 1. 将 value 存入 Fiber 节点的 context 属性中
    // 2. 标记这个节点"有新的 context 值需要向下传播"
    <ThemeContext.Provider value={theme}>
      {/* 第 4 行:Page 和 button 成为 Provider 的 children */}
      {/* 在 React 的 commit 阶段,这些 children 会被渲染 */}
      {/* 同时,React 会检测到 context 变化,触发所有订阅者的更新 */}
      <Page />
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        切换主题
      </button>
    </ThemeContext.Provider>
  );
}

javascript

// useTheme.js
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';

export function useTheme() {
  // 第 5 行:useContext 是一个"读取器"
  // 它会沿着组件树向上查找最近的 ThemeContext.Provider
  // 找到后,读取 Provider 的 value 属性
  // React 内部:从当前 Fiber 节点的 _contextDependencies 链中查找
  // 如果找到了,返回 value;否则返回 createContext 时的默认值
  return useContext(ThemeContext);
}

🔥 难点 2:自定义 Hook 的"响应式"封装 —— 它和普通函数到底有什么区别?

为什么要有这个设计?

React 组件是纯函数(理想情况下),但现实世界充满了副作用(事件监听、网络请求、定时器)。自定义 Hook 让我们把这些"不纯"的部分封装起来,保持组件的干净。

如果不使用自定义 Hook,你会在每个需要鼠标坐标的组件里写:

javascript

function ComponentA() {
  const [x, setX] = useState(null);
  const [y, setY] = useState(null);
  
  useEffect(() => {
    document.addEventListener('mousemove', (e) => {
      setX(e.clientX);
      setY(e.clientY);
    });
    return () => { /* 清理逻辑 */ };
  }, []);
  // ... 其他逻辑
}

function ComponentB() {
  // 同样的代码再写一遍!DRY 原则被破坏了
}

自定义 Hook 的价值:把"状态 + 副作用"打包成可复用的单元。

代码逐行解剖

javascript

// useMouse.js
import { useState, useEffect } from "react";

export const useMouse = () => {
  // ===== 第 1 步:创建响应式状态 =====
  // useState 在 Hook 内部创建了两个"记忆单元"
  // 注意:每个调用 useMouse 的组件都有自己独立的 x 和 y 状态
  // 这就是 Hook 的"状态隔离"特性
  const [x, setX] = useState(null);
  const [y, setY] = useState(null);
  
  // ===== 第 2 步:定义事件处理函数 =====
  // 这个函数在每次鼠标移动时被调用
  // 它通过 setX/setY 触发状态更新
  function handleMouseMove(e) {
    // e.clientX:鼠标在视口中的水平位置(像素)
    // e.clientY:鼠标在视口中的垂直位置(像素)
    setX(e.clientX);  // 触发 React 的调度器,标记这个组件需要更新
    setY(e.clientY);  // 注意:两次 setState 会被批量合并(在 React 18 的自动批处理中)
  }
  
  // ===== 第 3 步:注册副作用 =====
  useEffect(() => {
    // 副作用:将 handleMouseMove 注册到 document 的 mousemove 事件
    // 这里用到了"闭包"——handleMouseMove 捕获了 x、y 的 setter 函数
    document.addEventListener("mousemove", handleMouseMove);
    
    // ===== 第 4 步:清理副作用 =====
    // 返回的函数称为"清理函数"(cleanup function)
    // React 在组件卸载时,以及下次 effect 执行前调用它
    return () => {
      // 移除事件监听,防止内存泄漏
      // 为什么需要手动移除?
      // 因为 document 是全局对象,在组件生命周期之外
      // 如果不移除,组件销毁后,事件触发时还会调用 handleMouseMove
      // 而 handleMouseMove 中的 setX/setY 已经无效(组件已销毁),React 会报错
      document.removeEventListener("mousemove", handleMouseMove);
    };
  }, []); // 空依赖数组 = 只在组件"挂载"时执行一次,卸载时执行清理
  
  // ===== 第 5 步:返回响应式数据 =====
  // 返回的对象包含 x 和 y
  // 当 x/y 变化时,使用这个 Hook 的组件会自动重新渲染
  return { x, y };
};

底层原理:为什么自定义 Hook 能"响应式"?

  1. Hook 调用链:每个组件都有一个 Fiber 节点,Fiber 上有一个 memoizedState 链表,存储所有 Hook 的状态。
  2. 状态绑定:当 useState 在自定义 Hook 中被调用时,它的状态被绑定到调用该 Hook 的组件的 Fiber 节点上。
  3. 更新触发:当 setX 被调用时,React 会标记该 Fiber 节点需要更新,然后重新执行组件函数,组件函数中调用的 useMouse 会返回最新的 x。

🔥 难点 3:useEffect 的依赖数组 —— 为什么空数组只执行一次?为什么需要清理?

为什么要有这个设计?

React 组件的生命周期分为:挂载(Mount)、更新(Update)、卸载(Unmount)。useEffect 让我们在不同的生命周期阶段执行不同的逻辑。

依赖数组让开发者精确控制 effect 的执行时机:

  • 不传依赖数组:每次渲染都执行
  • 传空数组 []:只在挂载时执行,卸载时清理
  • 传具体依赖 [count]:只在 count 变化时执行

代码逐行解剖

javascript

useEffect(() => {
  // ===== 第 1 阶段:挂载时执行 =====
  // 当组件首次渲染到 DOM 后,React 执行这个函数
  document.addEventListener("mousemove", handleMouseMove);
  
  // ===== 第 2 阶段:返回清理函数 =====
  // 这个函数会在"组件卸载前"执行
  // 注意:如果依赖数组不为空,清理函数会在"下次 effect 执行前"执行
  return () => {
    // 移除事件监听
    document.removeEventListener("mousemove", handleMouseMove);
  };
}, []); // 空数组:告诉 React "这个 effect 不依赖任何外部变量"
       // React 内部:将 effect 标记为"不需要重新执行"
       // 在 Fiber 的 updateQueue 中,这个 effect 只在 mount 时执行一次

为什么必须清理?

javascript

// ❌ 错误示范:忘记清理
useEffect(() => {
  document.addEventListener("mousemove", handleMouseMove);
  // 没有返回清理函数!
}, []);

// 后果:
// 组件 A 挂载 → 添加监听
// 组件 A 卸载 → 监听还在!
// 用户移动鼠标 → 事件触发 → handleMouseMove 执行
// → handleMouseMove 中调用了 setX/setY
// → 但组件 A 已经销毁,没有 Fiber 节点了
// → React 尝试更新不存在的组件 → 报错!
// → 多次切换组件 → 监听越来越多 → 内存泄漏!

javascript

// ✅ 正确姿势:完整清理
useEffect(() => {
  document.addEventListener("mousemove", handleMouseMove);
  return () => {
    document.removeEventListener("mousemove", handleMouseMove);
  };
}, []);

5. 避坑指南:新手的十面埋伏

🚨 坑点 1:Provider 的 value 使用"对象字面量"导致无限重渲染

❌ 错误示范

javascript

function App() {
  const [theme, setTheme] = useState('light');
  const [user, setUser] = useState({ name: '小明' });
  
  return (
    // 每次 App 渲染,都会创建一个新的对象 { theme, setTheme, user, setUser }
    // 即使 theme 和 user 都没变,这个对象也是新的
    // React 通过 Object.is 比较 value,发现不同 → 所有订阅者强制更新
    <ThemeContext.Provider value={{ theme, setTheme, user, setUser }}>
      <Page />
    </ThemeContext.Provider>
  );
}

💥 后果

  • 每次父组件渲染,所有用了 useContext(ThemeContext) 的组件都会重新渲染
  • 即使它们用到的数据根本没变(比如 Page 只用了 theme,但 user 变了导致它也跟着渲染)
  • 大规模应用会卡顿、掉帧

✅ 正确姿势

javascript

function App() {
  const [theme, setTheme] = useState('light');
  const [user, setUser] = useState({ name: '小明' });
  
  // 使用 useMemo 缓存 value,只在依赖变化时重新创建
  const contextValue = useMemo(() => ({
    theme,
    setTheme,
    user,
    setUser
  }), [theme, user]); // 只有 theme 或 user 变化时才创建新对象
  
  return (
    <ThemeContext.Provider value={contextValue}>
      <Page />
    </ThemeContext.Provider>
  );
}

🚨 坑点 2:把"所有数据"塞进一个 Context

❌ 错误示范

javascript

// 一个 Context 管所有
const AppContext = createContext();

function App() {
  const [theme, setTheme] = useState('light');
  const [user, setUser] = useState(null);
  const [cart, setCart] = useState([]);
  const [notifications, setNotifications] = useState([]);
  
  // 所有东西混在一起
  const value = { theme, setTheme, user, setUser, cart, setCart, notifications, setNotifications };
  
  return (
    <AppContext.Provider value={value}>
      <Page />
    </AppContext.Provider>
  );
}

💥 后果

  • 修改任何一个状态(比如购物车加了件商品),所有用了 AppContext 的组件都会重新渲染
  • 即使这些组件只关心主题,也要被迫更新
  • 性能灾难

✅ 正确姿势

javascript

// 按照功能拆分 Context
const ThemeContext = createContext();
const UserContext = createContext();
const CartContext = createContext();

function App() {
  const [theme, setTheme] = useState('light');
  const [user, setUser] = useState(null);
  const [cart, setCart] = useState([]);
  
  return (
    // 层层嵌套,各管各的
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <UserContext.Provider value={{ user, setUser }}>
        <CartContext.Provider value={{ cart, setCart }}>
          <Page />
        </CartContext.Provider>
      </UserContext.Provider>
    </ThemeContext.Provider>
  );
}

🚨 坑点 3:自定义 Hook 的"条件调用"

❌ 错误示范

javascript

function Component() {
  if (someCondition) {
    // ❌ Hook 不能在条件语句中调用!
    const { x, y } = useMouse();
    return <div>{x}, {y}</div>;
  }
  return <div>No mouse</div>;
}

💥 后果

React 依赖 Hook 的调用顺序来关联状态。如果条件调用,会导致:

  • Hook 调用顺序在不同渲染间不一致
  • 状态错乱:useMouse 的 x 可能被当成其他 Hook 的值
  • 严重的运行时错误

✅ 正确姿势

javascript

function Component() {
  // ✅ Hook 始终在顶层调用
  const { x, y } = useMouse();
  
  if (someCondition) {
    return <div>{x}, {y}</div>;
  }
  return <div>No mouse</div>;
}

黄金法则只在 React 函数组件和自定义 Hook 的顶层调用 Hook


6. 面试官问什么?(备战八股文)

🎯 面试题 1:useContext 和 Redux 有什么区别?什么时候用哪个?

回答大纲

  1. 定位不同

    • useContext:React 内置的状态共享方案,解决 props drilling
    • Redux:独立的状态管理,提供完整的 Flux 架构
  2. 性能差异

    • useContext:只要 Provider 的 value 变化,所有订阅者都会重渲染(需手动优化)
    • Redux:通过 connect/useSelector 实现选择性订阅,只有使用的数据变化才更新
  3. 适用场景

    • 用 useContext:主题切换、用户信息、语言包等低频更新的全局数据
    • 用 Redux:复杂表单、购物车、大量交互的高频更新数据,或需要时间旅行调试的场景
  4. 复杂度

    • useContext:简单,无需额外依赖
    • Redux:复杂,需要学习 action、reducer、middleware 等概念

🎯 面试题 2:自定义 Hook 和普通函数的区别是什么?

回答大纲

  1. 内部可使用 React Hook

    • 自定义 Hook:可以调用 useState、useEffect、useContext 等
    • 普通函数:不能调用 React Hook,否则会报错
  2. 状态隔离

    • 自定义 Hook:每次调用都有独立的状态副本,互不影响
    • 普通函数:没有状态管理能力
  3. 返回值响应式

    • 自定义 Hook:返回的数据变化会触发组件重新渲染
    • 普通函数:只返回计算值,不触发渲染
  4. 命名规范

    • 必须以 use 开头,便于 lint 插件识别和检查

🎯 面试题 3:useEffect 的清理函数会在什么时候执行?

回答大纲

  1. 组件卸载时:必然执行,用于清理副作用(事件监听、定时器等)

  2. 下次 effect 执行前(当依赖数组不为空时):

    • 如果依赖变化,React 会先执行上次 effect 的清理函数,再执行新的 effect

    • 例如:useEffect(() => { ... }, [count]),当 count 从 1 变到 2:

      • 执行上次的清理函数(count=1 时)
      • 执行新的 effect(count=2 时)
  3. React 渲染阶段:清理函数的执行在布局和绘制之后,不会阻塞浏览器渲染

  4. 为什么需要清理

    • 防止内存泄漏(事件监听、定时器未释放)
    • 防止状态更新在已卸载的组件上执行(React 警告)

总结:你现在的知识版图

通过这份源码拆解,你掌握了:

概念理解程度
Context 机制理解数据如何跨层级传递,Provider 为什么必须包裹
useContext知道如何"消费"数据,理解其响应式原理
自定义 Hook理解如何封装"状态+副作用",知道与普通函数的区别
useEffect理解依赖数组的作用,知道为什么需要清理
性能优化理解 value 缓存、Context 拆分的重要性
避坑指南掌握三个常见错误的解法

下一步建议

  1. 打开你的项目,找出所有 props 传递超过 3 层的地方,尝试用 Context 重构
  2. 写一个自定义 Hook,封装 window 的 resize 事件
  3. 在 React DevTools 的 Profiler 中观察优化前后的渲染性能