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

一位初学者的 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

    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

    xml 复制代码
    // 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

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


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

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

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

  • 在代码中如何体现

    javascript

    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

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

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

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

流程 1:ThemeContext 的数据流转

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

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

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

为什么要有这个设计?

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

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

如果不用 Provider 会怎样?

javascript

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

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

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

代码逐行解剖

javascript

javascript 复制代码
// ThemeContext.js
import { createContext } from 'react';

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

javascript

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

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

scss 复制代码
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

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

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

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

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

javascript

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

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

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

❌ 错误示范

javascript

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

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

ini 复制代码
// 一个 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

ini 复制代码
// 按照功能拆分 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

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

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 中观察优化前后的渲染性能
相关推荐
AlienZHOU6 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc9 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师10 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen11 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒11 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow11 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81811 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007811 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明12 小时前
第 6 章 组件进阶
前端·vue.js
江华森12 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端