阅读视图

发现新文章,点击刷新页面。

Hooks 使用注意事项及来龙去脉

背景

前几篇文章已经聊了Hooks 的意义以及如何实现的,那么现在我们来看下 我们平时 使用 hook的一些注意事项,也就是我们说的 “心智负担”,但是当我们理解它背后的实现原理后,也许 这种负担会大大减轻吧

为什么Hooks必须在函数组件顶层调用,不能写在条件语句中?

前置知识

我们知道 hook链表绑在了fiber上了,那么下次 更新时,首先要找到对应的旧fiber对吗?那怎么找呢??

正确的「新旧 Fiber 树构建 + alternate 绑定」流程

以「首次渲染后,点击 setCount 触发更新」为例,完整步骤:

步骤 1:首次渲染后,先建立“旧树(Current)”
  • 首次渲染时,React 从根节点开始,为每个组件(根→App→Button 等)创建全新的 fiber 节点,构建出「Current Fiber 树」(旧树);
  • 此时每个 fiber 节点的 alternate 为 null(无新树);
  • 根 Fiber 的 current 指向这棵旧树。
步骤 2:触发更新后,创建新树(WorkInProgress)的“骨架”

React 不会复制旧树,而是:

  1. 从根 Fiber 开始,遍历旧树(Current)的节点(按 child/sibling 层级);
  2. 对每个旧 fiber 节点,执行「调和(Reconciliation)」:
    • 如果组件类型未变(且key也没变)(比如还是 <App />):确认结构没变化,创建新的 workInProgress fiber 节点,(只复用旧节点的“结构信息”,比如层级、类型,不复制状态);
    • 如果组件类型变了(比如 <App /> 变成 <Other />):丢弃旧节点,创建全新的新节点;
    • 如果组件被卸载:不创建新节点;
  3. 关键:为新创建的 workInProgress fiber 绑定 alternate 指针 → workInProgress.alternate = 旧 fiber(同时旧 fiber.alternate = workInProgress,双向绑定)。

举例说明:

假设旧 fiber(Current)是这样的:

// 旧 fiber(Current)- App 组件
const oldFiber = {
  // 结构信息(静态)
  type: App, // 组件类型是 App 函数
  key: null,
  return: rootFiber, // 父节点是根 fiber
  child: buttonFiber, // 子节点是 Button 组件的 fiber
  sibling: null, // 无兄弟节点
  
  // 状态信息(动态)
  memoizedState: { memoizedState: 0, queue: null }, // Hook 链表(count=0)
  updateQueue: null, // 副作用队列
  props: { name: "旧名称" } // 父组件传的旧 props
};

当创建新 fiber(workInProgress)时,React 做的是:

// 新 fiber(workInProgress)- App 组件
const newFiber = {
  // 1. 复用旧 fiber 的「结构信息」(直接抄,不用重新判断)
  type: oldFiber.type, // 复用 type: App
  key: oldFiber.key, // 复用 key: null
  return: oldFiber.return, // 复用父节点:rootFiber
  child: null, // 先留空,后续调和子节点时再复用旧 child 的结构
  sibling: oldFiber.sibling, // 复用兄弟节点:null
  
  // 2. 「状态信息」完全清空/重新计算(不复用旧值)
  memoizedState: null, // 清空 Hook 链表(后续通过 renderWithHooks 重新构建)
  updateQueue: null, // 清空副作用队列(后续重新收集)
  props: { name: "新名称" } // 用本次父组件传的新 props(不复用旧 props)
};

// 3. 绑定 alternate 指针(新→旧)
newFiber.alternate = oldFiber;
oldFiber.alternate = newFiber;

👉 核心看差异:

  • 结构信息(type/return/key):直接从旧 fiber 拿,不用重新判断“这个组件是不是 App?它的父节点是谁?”——省掉了重复的层级/类型判断成本;
  • 状态信息(memoizedState/updateQueue):完全清空,后续执行 renderWithHooks 时,再从旧 fiber 的 memoizedState 读取 Hook 状态、计算新状态,最后填充到新 fiber 中。
步骤 3:遍历新树骨架,执行渲染函数填充内容

React 遍历刚创建的「WorkInProgress 新树骨架」,对每个函数组件节点:

  1. 调用 updateFunctionComponent → 进而调用 renderWithHooks
  2. 执行组件的渲染函数(比如 App()),触发 Hook 调用;
  3. 通过 workInProgress.alternate 找到旧 fiber,读取旧 Hook 状态,计算新状态;
  4. 把新状态填充到 workInProgress fiber 的 memoizedState(Hook 链表)中,完成新树的“内容填充”。
步骤 4:提交新树,替换旧树

渲染完成后,React 把「WorkInProgress 新树」提交到 DOM,然后:

  • 根 Fiber 的 current 指向这棵新树(它变成了下一次更新的“旧树”);
  • 原来的旧树被标记为可回收,等待 GC。

总结:

  • React 找旧 fiber 的最终“快捷方式”是 workInProgress.alternate
  • 双树的核心价值是“增量更新”——不用每次重新创建完整 fiber 树,只需基于旧树更新变化的部分;
  • alternate 指针是双树关联的“物理纽带”,保证了旧状态能被精准找到。

旧fiber找到了,那么hook呢?新hook和旧hook的关系是??

首先旧hook链表从哪里来?其实我们可以理解为旧fiber的hook

第一步:先看错误代码(Hook 放条件里)

function Form() {
  const [name, setName] = useState(""); // Hook1
  
  if (name) { // 条件:name有值时才执行Hook2
    const [phone, setPhone] = useState(""); // Hook2
  }
  
  const [submit, setSubmit] = useState(false); // Hook3
  return <div>{/* 渲染 */}</div>;
}

第二步:首次渲染(name 为空,条件不满足)

  • 执行顺序:Hook1(name)→ Hook3(submit);
  • 旧 Hook 链表(current fiber):Hook1(name)→ Hook3(submit)(共2个节点);
  • 指针记录:遍历到第2个节点(Hook3)结束。

第三步:更新渲染(输入name,条件满足)

此时用户输入了 name,if (name) 为 true,Hook 执行顺序变了:

  • 执行顺序:Hook1(name)→ Hook2(phone)→ Hook3(submit);
  • React 开始遍历旧链表(只有2个节点):
    1. 本次第1个 Hook(name)→ 匹配旧链表第1个节点(name)→ 正常;
    2. 本次第2个 Hook(phone)→ 匹配旧链表第2个节点(submit)→ 错位! phone 会读取到 submit 的旧状态(false);
    3. 本次第3个 Hook(submit)→ 旧链表已无节点(只有2个)→ React 直接报错(Hook 数量不匹配)。

第四步:关键补充(不只是“遍历拿”,还有“指针移动”

你说的“依次遍历之前的旧hook链表拿到一个,返回一个”,可以再补全一点: React 不是“一次性遍历旧链表”,而是 每调用一个新 Hook,就把旧链表的指针往后移一位 -- 重点,好好理解!!!!

  • 调用第1个新 Hook → 取旧链表第1个节点,指针移到第2个;
  • 调用第2个新 Hook → 取旧链表第2个节点,指针移到第3个;
  • ……

条件语句会让“新 Hook 的数量/顺序”和“旧链表的节点数量/顺序”对不上,指针一错位,状态就张冠李戴;数量对不上的话,React 直接抛出警告(Hook 调用数量不一致)。

大致步骤如下:

整个过程和 next 复制无关,完全由「本次 Hook 调用次数」驱动,步骤如下(结合源码简化版):

步骤 1:初始化 currentHook 指向旧链表头

更新渲染时,先把 currentHook 指向旧 fiber 的 Hook 链表头(这是唯一和旧链表 next 相关的一步,但只是“读取头节点”,不是复制):

// 初始化:currentHook 指向旧链表头(靠旧链表的 next,但只取头节点)
let currentHook = current !== null ? current.memoizedState : null;
// 新 Hook 链表头初始为空(不复制任何 next)
let workInProgressHook = null;
步骤 2:每调用一个 Hook,手动移动 currentHook 找下一个旧 Hook

每次执行 updateState(比如 useState),React 会:

  1. 当前的 currentHook 创建新 Hook(复制 memoizedState/queue);
  2. 手动执行 currentHook = currentHook.next——这就是“找到下一个旧 Hook”的核心操作;
  3. 新 Hook 的 next 设为 null,后续按本次顺序手动拼接(而非复制旧 next)。因为旧next指向的是就hook,我们说过每次都会重新创建新hook,所以应该新 Hook 的next永远指向下一个新创建的 Hook
完整代码演示(顺序一致的场景):
// 旧链表:Hook1(name)→ Hook2(phone)→ Hook3(submit)(旧 next 正常)
let currentHook = oldHook1; // 初始化指向旧链表头

// 第1次调用 useState(Hook1)
function updateState() {
  // 用当前 currentHook(oldHook1)创建新 Hook1(复制 memoizedState/queue)
  const newHook1 = createWorkInProgressHook(currentHook);
  newHook1.next = null; // 不复制 oldHook1.next(oldHook1.next 是 oldHook2)
  
  // 手动移动 currentHook → 找到下一个旧 Hook(oldHook2)
  currentHook = currentHook.next; 
  
  workInProgressHook = newHook1; // 新链表头指向 newHook1
}

// 第2次调用 useState(Hook2)
function updateState() {
  // 用当前 currentHook(oldHook2)创建新 Hook2
  const newHook2 = createWorkInProgressHook(currentHook);
  newHook2.next = null; // 不复制 oldHook2.next(oldHook2.next 是 oldHook3)
  
  // 手动移动 currentHook → 找到下一个旧 Hook(oldHook3)
  currentHook = currentHook.next; 
  
  // 手动拼接新链表的 next(本次顺序)
  workInProgressHook.next = newHook2;
  workInProgressHook = newHook2;
}

// 第3次调用 useState(Hook3)
function updateState() {
  // 用当前 currentHook(oldHook3)创建新 Hook3
  const newHook3 = createWorkInProgressHook(currentHook);
  newHook3.next = null;
  
  // 手动移动 currentHook → 旧链表结束(null)
  currentHook = currentHook.next; 
  
  // 手动拼接新链表的 next
  workInProgressHook.next = newHook3;
  workInProgressHook = newHook3;
}
关键:找下一个旧 Hook 靠的是 currentHook = currentHook.next,而非新 Hook 的 next
  • 旧链表的 next 只被 currentHook 用来“移动指针”,但新 Hook 完全不复制这个 next
  • 新 Hook 的 next按本次调用顺序手动拼接的,和旧 Hook 的 next 无关;
  • 哪怕旧链表的 next 异常(比如指向 null/僵尸节点),currentHook 移动时能立刻发现,及时报错,而复制旧 next 会把异常带入新链表。

通俗比喻:像“按顺序领快递”

把旧 Hook 链表比作“快递柜的格子”(按顺序编号1、2、3…),每次渲染领快递:

  • 正常情况:每次都按1→2→3的顺序领,格子里的快递(状态)永远对应你的 Hook;
  • 条件语句:第一次领1→3(跳过2),第二次领1→2→3——此时领2号格子时,里面是原本3号的快递(submit的状态),领3号时格子空了,直接报错。

总结

Hook 不能放条件语句里的核心原因:

  1. React 更新时,会按本次 Hook 调用顺序,逐个匹配旧 Hook 链表的节点(指针逐次后移);
  2. 条件语句会导致本次 Hook 的调用顺序/数量,和旧链表的节点顺序/数量不一致;
  3. 最终要么状态错位(张冠李戴),要么直接报错(数量不匹配),完全破坏 Hook 状态和组件的绑定关系。

关于useEffect

useEffect副作用函数啥时候执行?

一、先搞懂核心概念:React 工作的三大步骤

文章里提到的 React 工作流程是理解所有问题的基础,先把这个核心框架记住:

  1. 调度器:决定「什么时候更新」(比如用户点击、数据请求完成触发更新)

    调度器就是 React 的「时间管家」,它判断 “什么时候该干活”。比如你点击按钮想改页面内容,调度器会说 “现在可以开始更新了”;再比如请求接口拿到数据后,调度器会说 “数据到了,该更新页面了”。

  2. 协调器:决定「更新什么内容」(生成 Fiber 树,给需要操作的 Fiber 打标记,构建 effectList)

    协调器是 React 的「规划师」,它不管 “什么时候干”,只管 “干什么”。

  3. 渲染器:执行「实际的更新操作」(遍历 effectList,执行 DOM 插入/更新、useEffect 回调等)

    渲染器是 React 的「打工人」,它照着协调器列的 effectList 清单干活 —— 遍历链表,看到「Placement」标记就插 DOM,看到「Passive」标记就执行 useEffect 回调。

其中,Fiber 可以简单理解为「带标记的虚拟 DOM 节点」,

可以理解为「带备注的虚拟DOM树」,每个组件对应一个 Fiber 节点;
打标记:比如某个组件需要新增 DOM,就给它的 Fiber 打「Placement(插入)」标记;某个组件有 useEffect 要执行,就打「Passive」标记;

effectList 是「需要执行副作用(比如 DOM 操作、useEffect)的 Fiber 链表」。

 effectList:把所有“有标记要干活”的 Fiber 节点串成一个链表
(比如“Child要执行useEffect → Parent要执行useEffect → App要执行useEffect”),方便后续挨个处理。

二、第一个问题:useEffect 的执行顺序(child -> parent -> app)

核心逻辑:effectList 的构建在「归阶段」

React 协调器处理组件树时,分「递」和「归」两个阶段:

  • 递阶段:从根组件(App)向下遍历到叶子组件(Child),相当于「从上到下找要更新的组件」;
  • 归阶段:从叶子组件(Child)向上回到根组件(App),相当于「从下到上给找到的组件打标记、构建 effectList」。

而 useEffect 对应的 Fiber 会被打上 Passive 标记,这个标记的添加、effectList 的构建都发生在「归阶段」。

所以 effectList 的顺序是:Child → Parent → App,渲染器遍历 effectList 执行 useEffect 回调时,自然也是这个顺序,控制台就会先打印 child,再 parent,最后 app

举个通俗例子

你可以把组件树想象成「爷爷(App)→ 爸爸(Parent)→ 儿子(Child)」:

  • 递阶段:爷爷先喊爸爸,爸爸再喊儿子,确认大家是否需要更新;
  • 归阶段:儿子先报告「我要执行 useEffect」,然后爸爸报告,最后爷爷报告;
  • 执行时:按「儿子→爸爸→爷爷」的顺序执行 useEffect 回调。

三、第二个问题:useEffect([]) 和 componentDidMount 调用时机不同

表面相似,底层逻辑不同
  • 相似点:两者都只在「组件挂载(mount)」时执行一次(useEffect 因为 deps 为空,不会触发更新);
  • 核心不同:执行时机的「同步/异步」差异:
    1. componentDidMount:属于类组件的生命周期钩子,在「DOM 渲染完成后同步执行」(渲染完立刻执行,阻塞后续代码);
    2. useEffect([]):属于 Passive Effect,React 会在「DOM 渲染完成后,把所有 Passive Effect 的回调放到异步队列里执行」(不阻塞主线程,浏览器先绘制页面,再执行回调)。
更贴近 componentDidMount 的是 useLayoutEffect

useLayoutEffect 的回调会在「DOM 渲染完成后同步执行」,和 componentDidMount 时机几乎一致;而 useEffect 是异步执行,这也是为什么有时用 useEffect 操作 DOM 会看到「一闪而过」的视觉问题(页面先渲染,再执行回调修改 DOM),而 useLayoutEffect 不会。

四、useEffect 完整执行流程(结合文章)

  1. 组件触发更新 → 协调器执行 FunctionComponent,遇到 useEffect 检查 deps 是否变化;
  2. 如果 deps 变化 → 给当前组件的 Fiber 打上 Passive 标记;
  3. 协调器在「归阶段」构建 effectList(顺序:叶子→根);
  4. 渲染器遍历 effectList:
    • 先执行所有 useEffect 的「销毁函数」(即回调的返回值);
    • 再执行所有 useEffect 的「回调函数」(create);
  5. 整个过程在页面渲染后异步完成。

总结

  1. useEffect 执行顺序:由 effectList 决定,而 effectList 构建于「归阶段」,所以是「子组件 → 父组件 → 根组件」;
  2. useEffect 执行时机:DOM 渲染后异步执行,和类组件的 componentDidMount(同步执行)时机不同,useLayoutEffect 才是同步执行,更贴近 componentDidMount;
  3. 核心思路:理解 useEffect 不要依赖「生命周期类比」,要从 React「调度→协调→渲染」的底层流程出发,核心是 effectList 的构建和遍历逻辑。

依赖数组传空数组 [] 和完全不写依赖 的区别

写法 deps 变化判断逻辑 Passive 标记时机 执行次数 底层本质
useEffect(() => {}, []) 仅首次挂载时,因“无旧 deps”视为 deps 变化;后续更新时,新旧 deps 都是空数组,判定为“无变化” 首次挂载的调和阶段,给 Fiber 打 Passive 标记 只执行 1 次(组件挂载时) 对应“仅 mount 执行”,和 componentDidMount 逻辑对齐
useEffect(() => {}) 完全不判断 deps 变化,React 视为“每次更新都有变化” 每次组件更新的调和阶段,都给 Fiber 打 Passive 标记 组件每次渲染/更新都执行(包括首次挂载) 对应“mount + 每次 update 执行”,和 componentDidUpdate 无依赖版对齐

结合底层流程,拆解差异根源

我们还是以 App → Parent → Child 组件为例,结合调和阶段的递/归流程,看两种写法的执行逻辑:

1. 写法1:useEffect(() => {}, [])(空数组依赖)
  • 递阶段
    • 首次挂载:执行组件时,检查 deps(无旧 deps)→ 判定“变化”,记录 needPassiveEffect = true
    • 后续更新(比如 Parent 组件 setState):执行组件时,对比新旧 deps(都是 [])→ 判定“无变化”,记录 needPassiveEffect = false
  • 归阶段
    • 首次挂载:因 needPassiveEffect = true → 打 Passive 标记,加入 effectList → commit 阶段执行回调;
    • 后续更新:因 needPassiveEffect = false → 不打 Passive 标记,不会加入 effectList → 回调不执行;
  • 最终表现:仅组件首次挂载时执行 1 次,后续无论父/子组件怎么更新,都不再执行。
2. 写法2:useEffect(() => {})(无依赖数组)
  • 递阶段: React 对“不写 deps”的处理逻辑是——跳过 deps 对比,直接判定为“变化”,无论首次挂载还是后续更新,都记录 needPassiveEffect = true
  • 归阶段: 每次组件更新的调和阶段,都会给 Fiber 打 Passive 标记,加入 effectList;
  • 最终表现
    • 首次挂载执行 1 次;
    • 只要组件自身/父组件触发更新(比如 Parent 改 state 导致 Child 重渲染),该 useEffect 就会重新执行;
    • 极端情况:如果父组件频繁 setState,这个 useEffect 会被频繁触发,甚至导致性能问题。

实战例子:直观看到差异

function Child() {
  // 写法1:空数组依赖
  useEffect(() => {
    console.log('空数组依赖:Child 执行');
  }, []);

  // 写法2:无依赖数组
  useEffect(() => {
    console.log('无依赖:Child 执行');
  });

  return <p>Child</p>;
}

function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>点我更新</button>
      <Child />
    </div>
  );
}
执行结果:
  • 首次挂载:
    空数组依赖:Child 执行
    无依赖:Child 执行
    
  • 点击按钮(Parent 触发更新,Child 重渲染):
    无依赖:Child 执行 // 空数组依赖的回调不再执行,无依赖的每次都执行
    

补充:容易踩的坑

  1. 误以为“不写依赖”=“依赖所有变量”: 很多新手觉得“不写 deps”是“监听所有变量变化”,但底层逻辑是——React 不做任何 deps 对比,直接判定“每次都变化”,哪怕组件内没有任何变量变化,只要组件重渲染,就会执行;
  2. 空数组依赖≠“完全不依赖”: 空数组只是“不依赖组件内的任何 state/props”,但如果依赖的是“外部全局变量”(比如 window 的属性),全局变量变化时,回调也不会重新执行(因为 deps 还是空数组);
  3. 性能风险: 不写依赖的 useEffect 若包含耗时操作(比如接口请求、DOM 频繁修改),会导致组件每次更新都触发耗时操作,严重时卡顿,实战中绝对要避免不写依赖的写法

总结

  1. 空数组 []:仅首次挂载打 Passive 标记,回调只执行 1 次,对应“仅 mount 执行”;
  2. 不写依赖:每次更新都打 Passive 标记,回调每次渲染都执行,对应“mount + 每次 update 执行”;
  3. 底层核心差异:React 对“空数组”做严格的 deps 对比,对“不写依赖”直接跳过对比、判定为每次都变化。

实战建议

  • 除非明确需要“每次更新都执行”(几乎没有这种场景),否则绝对不要省略依赖数组
  • 若想“仅挂载执行”,用 []
  • 若想“依赖某个变量变化才执行”,把变量放进依赖数组(比如 [count]),React 会精准对比该变量,仅变化时打 Passive 标记。

useState 和 useRef

一、完整闭包陷阱题目

1. 题目代码(可直接运行)

import { useState } from 'react';

// 闭包陷阱示例组件
function CountDemo() {
  // 初始count为0
  const [count, setCount] = useState(0);

  // 点击按钮触发:延迟1秒更新count
  const handleClick = () => {
    setTimeout(() => {
      // 传值更新:基于当前闭包的count计算
      setCount(count + 1);
    }, 1000);
  };

  return (
    <div style={{ padding: '20px' }}>
      <p>当前count:{count}</p>
      <button onClick={handleClick}>点击更新count(延迟1秒)</button>
    </div>
  );
}

export default CountDemo;

2. 操作与问题

操作:快速点击按钮 3 次(1秒内完成)。 问题:1秒后页面上的 count 最终显示多少? 答案1(而非预期的 3)。

二、现象原因分析(闭包陷阱核心)

结合你之前理解的「闭包快照」逻辑,拆解核心原因:

  1. 每次点击触发的是「同一版本的 handleClick」: 快速点击3次时,所有点击都发生在「1秒延迟期内」,组件还没重新渲染,<button>onClick 始终绑定「初始渲染的 handleClick(版本1)」。
  2. 版本1 handleClick 的闭包捕获的是初始 count=0: 3次点击都会预约「1秒后执行 setCount(0 + 1)」,最终 React 处理这3个更新时,都是将 count 设为 1,因此最终只显示 1
  3. 核心本质:传值更新(setCount(count + 1))依赖「闭包内的旧快照值」,而非 React 内部的最新状态。

三、改造方案:延迟1秒拿到最新count(两种常用方案)

方案1:函数式更新(React 官方推荐,最优解)

改造思路

setCount 的「传值更新」改为「函数式更新」,让 React 自动传入「最新的 prevCount」,脱离闭包对旧值的依赖。

改造后代码
import { useState } from 'react';

function CountDemo() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setTimeout(() => {
      // 函数式更新:参数prevCount是React维护的最新状态
      setCount(prevCount => prevCount + 1);
    }, 1000);
  };

  return (
    <div style={{ padding: '20px' }}>
      <p>当前count:{count}</p>
      <button onClick={handleClick}>点击更新count(延迟1秒)</button>
    </div>
  );
}

export default CountDemo;
原理与效果
  • 原理:函数式更新的参数 prevCount 是 React 在「处理更新时」传入的「最新状态值」,而非闭包内的旧快照;
  • 效果:快速点击3次后,1秒内 React 会依次执行: 第1次:prevCount=0 → 0+1=1; 第2次:prevCount=1 → 1+1=2; 第3次:prevCount=2 → 2+1=3; 最终 count 显示 3,符合预期。

方案2:useRef 保存最新值(适合需主动获取最新状态的场景)

改造思路

利用 useRefcurrent 属性是「引用类型」的特性(闭包中能拿到最新值,不会被快照锁定),同步 countref,延迟操作时从 ref 取最新值。

改造后代码
import { useState, useRef } from 'react';

function CountDemo() {
  const [count, setCount] = useState(0);
  // 用ref保存最新count(ref.current是引用类型,闭包能拿到最新值)
  const countRef = useRef(count);

  // 关键:每次渲染同步ref和最新count
  countRef.current = count;

  const handleClick = () => {
    setTimeout(() => {
      // 从ref中获取最新count值
      setCount(countRef.current + 1);
    }, 1000);
  };

  return (
    <div style={{ padding: '20px' }}>
      <p>当前count:{count}</p>
      <button onClick={handleClick}>点击更新count(延迟1秒)</button>
    </div>
  );
}

export default CountDemo;
原理与效果
  • 原理useRefcurrent 属性存储在堆内存中,引用地址不变,每次组件渲染都会同步最新的 countcountRef.current;延迟回调执行时,能直接读取到「最新的 count 值」;
  • 效果:同样实现点击3次后 count 显示 3,适合需要「在闭包中主动获取最新状态」的场景(比如除了更新,还需打印/使用最新count)。

四、方案对比与适用场景

方案 核心逻辑 适用场景 优点
函数式更新 依赖React传入的最新prevState 仅需「基于最新状态更新」(绝大多数场景) 简洁、React官方推荐
useRef 手动同步最新状态到ref 需要「主动读取最新状态」(如打印、传参等) 灵活,可主动控制状态

总结

  1. 闭包陷阱本质:快速点击时,延迟更新依赖「闭包内的旧count快照」,多次更新都是基于同一个旧值,最终只生效一次;
  2. 核心改造逻辑:脱离闭包对旧值的依赖——要么用函数式更新让React传最新值,要么用useRef保存最新值;
  3. 最佳实践:优先使用「函数式更新」(setXxx(prev => prev + 1)),这是React为解决闭包陷阱设计的最优方案,适配90%以上的延迟更新场景。

useState 和 useRef的区别

一、核心区别(从底层逻辑到使用表现)

为了让你一目了然,先通过对比表总结核心差异,再逐一拆解:

维度 useState useRef
核心定位 管理渲染相关的状态 存储非渲染相关的持久化数据
数据修改影响 调用setter函数会触发组件重新渲染 直接修改.current不触发任何渲染
数据访问方式 直接访问状态变量(如count 必须通过.current属性(如ref.current
数据更新特性 状态更新是“不可变”的(新值替换旧值) 数据修改是“可变”的(直接修改引用值)
依赖监听 状态变化会触发依赖该状态的useEffect/useCallback 修改.current不会触发依赖监听(需手动配合useState/useEffect
初始值特性 初始值仅在组件首次挂载时生效(后续渲染忽略) 初始值仅用于初始化.current,后续可自由修改

二、关键差异拆解(结合代码示例)

1. 数据修改是否触发渲染(最核心区别)
  • useState:状态更新必然触发组件重渲染,这是它的核心设计目的——让页面跟随状态变化更新。

    import { useState } from 'react';
    function StateDemo() {
      const [count, setCount] = useState(0);
      console.log('StateDemo 渲染了'); // 每次点击都会打印(重渲染)
      
      return (
        <button onClick={() => setCount(count + 1)}>
          计数:{count}
        </button>
      );
    }
    
  • useRef:修改.current完全不触发渲染,数据仅在内存中更新,页面无感知。

    import { useRef } from 'react';
    function RefDemo() {
      const countRef = useRef(0);
      console.log('RefDemo 渲染了'); // 仅首次挂载打印一次
      
      return (
        <button onClick={() => {
          countRef.current += 1; // 修改不触发渲染
          console.log('内存中的值:', countRef.current); // 控制台能看到值变化,但页面不变
        }}>
          计数:{countRef.current} {/* 始终显示0,因为没渲染 */}
        </button>
      );
    }
    
2. 数据更新的“可变/不可变”特性
  • useState:状态是“不可变”的,必须通过setter函数更新(不能直接修改原始值),React会用新值替换旧值。

    // 错误用法:直接修改state不会触发渲染
    const [obj, setObj] = useState({ name: '张三' });
    obj.name = '李四'; // 无效,页面不更新
    // 正确用法:通过setter传递新对象
    setObj({ ...obj, name: '李四' }); // 触发渲染
    
  • useRef.current是“可变”的,可以直接修改(类似操作普通JS对象),无需特殊方法。

    const objRef = useRef({ name: '张三' });
    objRef.current.name = '李四'; // 直接修改生效,无需要setter
    
3. 依赖监听的差异

useEffectuseCallback等Hook会监听useState的状态变化,但不会监听useRef.current的变化:

import { useState, useRef, useEffect } from 'react';
function EffectDemo() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);

  // 监听count:count变化时执行
  useEffect(() => {
    console.log('count变了:', count);
  }, [count]);

  // 监听countRef.current:永远不会执行(因为ref不是依赖项)
  useEffect(() => {
    console.log('countRef变了:', countRef.current);
  }, [countRef.current]); // 无效!

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>修改state</button>
      <button onClick={() => countRef.current += 1}>修改ref</button>
    </div>
  );
}

三、适用场景(帮你快速选对Hook)

用useState的场景(渲染相关)
  • 页面需要展示的数据(如按钮文案、列表数据、表单输入值);
  • 需要触发UI更新的状态(如弹窗显隐、加载中状态、切换主题);
  • 依赖状态变化执行副作用的场景(如根据筛选条件请求数据)。
用useRef的场景(非渲染相关)
  • 存储DOM元素引用(如获取输入框焦点、操作DOM尺寸);
  • 存储跨生命周期的临时数据(如定时器ID、请求取消令牌);
  • 保存上一次的状态/属性值(如对比当前值和上一次值);
  • 存储无需页面展示的内存数据(如计算中间结果、缓存数据)。

四、特殊场景:useRef + useState 结合使用

如果需要“数据持久化且修改时触发渲染”,可以结合两者:

import { useRef, useState } from 'react';
function CombineDemo() {
  const countRef = useRef(0); // 持久化存储
  const [, forceRender] = useState({}); // 用于强制渲染

  const increment = () => {
    countRef.current += 1; // 修改ref(不触发渲染)
    forceRender({}); // 手动触发渲染(通过更新空对象)
  };

  return (
    <button onClick={increment}>
      计数:{countRef.current} {/* 能正常更新 */}
    </button>
  );
}

总结

  1. 核心差异useState管理渲染状态(更新触发重渲染),useRef存储持久化数据(更新不触发渲染);
  2. 使用原则:数据需要展示在页面上→用useState;数据仅在内存中使用→用useRef
  3. 更新特性useState是不可变更新(需setter),useRef是可变更新(直接改.current)。

回归初心,React为啥当初要设计Hook?

背景

用了这么久 React,已经默认用函数写组件了,都快忘了怎么写类组件了,经历过写类组件的时代,现在用函数组件,一开始确实有上手时间成本,但是现在用过之后也是觉得真香,到底香在哪?到底React为啥推崇Hook呢?我们一起探究一下吧

对「类组件痛点」的直接回应

让我们回到 类组件开发时代,看看到底类组件的诟病是什么?

类组件痛点1: this 指向混乱

类组件this丢失问题

类组件的方法默认不会绑定 this,导致我们必须手动处理 this 指向,稍不注意就会出错。这是为什么

你想弄明白为什么类组件的方法默认不绑定 this,以及为什么这会导致指向混乱的问题,核心原因要从 JavaScript 中 this 的绑定规则React 类组件的设计方式 两个层面来解释,我会用通俗的语言+实例讲清楚。

一、先搞懂:JavaScript 中 this 指向的核心规则

this 是 JavaScript 中一个「动态绑定」的关键字——它的指向不是在定义函数时确定的,而是在调用函数时确定的。这是理解所有 this 问题的根本。

关键规则:不同调用方式,this 指向不同

我们用一个普通的 JavaScript 类来举例(React 类组件本质就是 JavaScript 类):

class Person {
  constructor(name) {
    this.name = name; // 这里的 this 指向 Person 的实例
  }

  sayHi() {
    console.log(`你好,我是${this.name}`); // this 指向谁?看调用方式
  }
}

const p = new Person('张三');

// 方式1:通过实例调用 → this 指向实例 p
p.sayHi(); // 输出:你好,我是张三

// 方式2:把方法单独抽出来调用 → this 丢失(非严格模式下指向 window,严格模式下是 undefined)
const sayHi = p.sayHi;
sayHi(); // 非严格模式:你好,我是undefined;严格模式:Uncaught TypeError: Cannot read property 'name' of undefined

核心结论:

  • 当你通过 实例.方法() 调用时,this 绑定到这个实例;
  • 当你把方法「单独提取」后调用(比如赋值给变量、作为回调函数),this 就会丢失原本的绑定,指向全局对象(浏览器中是 window)或 undefined(严格模式)。

二、React 类组件中 this 丢失的具体场景

React 类组件的方法丢失 this,本质就是上面的「方式2」——React 在处理事件回调时,会把你的方法「单独提取」后调用,导致 this 不再指向组件实例。

场景还原:React 类组件的点击事件
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  handleClick() {
    // 这里的 this 本该指向 Counter 实例,但实际是 undefined(React 默认开启严格模式)
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    // 问题出在这里:你把 this.handleClick 作为 onClick 的回调 → 相当于把方法抽离了
    return <button onClick={this.handleClick}>+1</button>;
  }
}

为什么会这样?

  1. render 函数执行时,你传给 onClick 的是 this.handleClick——这只是把方法的「引用」传了过去,并没有立即调用
  2. 当用户点击按钮时,React 内部会调用这个方法(类似 const callback = this.handleClick; callback());
  3. 此时调用的是「孤立的方法」,不是通过 实例.handleClick() 调用,所以 this 丢失,指向 undefined(React 严格模式下)。

三、为什么 React 不默认帮我们绑定 this

你可能会问:React 为什么不直接把类组件的方法都自动绑定到实例上?核心原因是:

  1. 遵循 JavaScript 原生规则:React 是基于 JavaScript 构建的库,不会刻意修改 JS 原生的 this 绑定逻辑,否则会增加学习成本和潜在的兼容性问题;
  2. 性能与灵活性:如果默认绑定所有方法,会为每个组件实例创建额外的函数引用,增加内存开销;而让开发者手动处理,能根据实际需求选择绑定方式(比如只绑定需要的方法)。

我用大白话来解释下,其实就是 :放手让开发者去设置this,我不掺合了

React 「不主动替你做绑定,只遵循 JS 原生规则,把控制权完全交给你」。

用更通俗的话讲:

✅ React 的态度:「我不掺合 this 的绑定逻辑,你按 JavaScript 原生的规矩来就行——想让 this 指向实例,你就自己绑;想让 this 指向别的(比如子组件、全局),你也可以自己改。我只负责把你写的方法『原样调用』,不替你做任何额外的绑定操作。」

对比一下如果 React 主动掺合的情况:

❌ 要是 React 自动把所有方法的 this 绑到组件实例,相当于「我替你做了决定」——你想改 this 指向都改不了,还得额外学 React 这套「特殊规则」,反而更麻烦。

最终核心总结
  1. React 对类组件 this 的态度:不干预、不修改、只遵循原生 JS 规则
  2. 把「this 该指向哪」的决定权,完全交给开发者;
  3. 这么做既避免了学习成本翻倍,也兼顾了性能(按需绑定)和灵活性(可自定义 this 指向)。

你这个「放手让开发者去设置,我不掺合」的总结,精准抓住了 React 设计的核心——尊重原生 JS,把控制权还给开发者

四、类组件中解决 this 丢失的 3 种常用方式

知道了原因,再看解决方案就很清晰了,本质都是「强制把 this 绑定到组件实例」:

方式 1:在构造函数中手动 bind(官方早期推荐)
constructor(props) {
  super(props);
  this.state = { count: 0 };
  // 核心:把 handleClick 的 this 强制绑定到当前实例
  this.handleClick = this.handleClick.bind(this);
}
方式 2:使用箭头函数定义方法(最简洁)
// 箭头函数没有自己的 this,会继承外层作用域的 this(即组件实例)
handleClick = () => {
  this.setState({ count: this.state.count + 1 });
};
方式 3:在调用时用箭头函数包裹(不推荐,每次渲染创建新函数)
// 每次 render 都会创建一个新的箭头函数,可能导致子组件不必要的重渲染
<button onClick={() => this.handleClick()}>+1</button>;

总结

  1. 根本原因:JavaScript 中 this 是「调用时绑定」的,React 类组件把方法作为事件回调时,方法会被孤立调用,导致 this 丢失(不再指向组件实例);
  2. 核心矛盾:React 遵循 JS 原生规则,没有默认绑定 this,而开发者容易忽略「调用方式」对 this 的影响;
  3. 解决方案:通过 bind、箭头函数等方式,强制把方法的 this 绑定到组件实例上。

函数组件: 彻底抛弃this,就不会有this指向问题了

核心原因

  1. 核心差异:类组件的状态/方法是「实例的属性」,必须通过 this 这个「中介」访问,中介指错了就出错;函数组件的状态/方法是「函数内的局部变量/函数」,直接访问即可,无需中介;
  2. 为什么无混乱:函数组件的局部变量/函数受「作用域」保护,只属于当前函数执行,不管怎么传递方法(比如作为事件回调),内部访问的变量都是确定的,不会像 this 那样动态变化;
  3. 最终结果:函数组件从根源上抛弃了 this,自然就避开了所有和 this 指向相关的坑,逻辑更简单、更安全。

简单来说,函数组件的写法「更符合普通人写 JS 的直觉」——定义的变量就在当前作用域,直接用就行,不用额外考虑「谁是 this」「this 指向谁」。

实质:用静态绑定 代替 动态绑定

这个总结精准到核心了!只需要再把「静态词法作用域」和「动态 this 绑定」的对应关系说透,就能彻底理解两者的本质区别。

先肯定你的结论:完全正确!

  • 类组件的 this调用时动态绑定 → 指向谁,取决于「怎么调用方法」,而非「方法在哪定义」;
  • 函数组件的变量/方法:定义时静态绑定(词法作用域) → 能访问哪些变量,取决于「代码写在哪」,而非「怎么调用函数」。

用最直白的例子+对比,讲透这个核心区别

1. 类组件:动态 this(调用时确定)
class Counter extends React.Component {
  state = { count: 0 };
  
  handleClick() {
    console.log(this.state.count); // this 指向「调用时的对象」
  }

  render() {
    // 场景1:直接传方法引用 → 调用时 this 是 undefined(丢失)
    return <button onClick={this.handleClick}>按钮1</button>;

    // 场景2:通过实例调用 → 调用时 this 是组件实例(正确)
    // return <button onClick={() => this.handleClick()}>按钮2</button>;
  }
}

关键:handleClick 里的 this不是定义时确定的——写代码时你不知道它指向谁,只有点击按钮、方法被调用的那一刻,才知道 this 是啥。

  • 按钮1:调用方式是「孤立调用」→ this = undefined;
  • 按钮2:调用方式是「实例.方法()」→ this = 组件实例。
2. 函数组件:静态词法作用域(定义时确定)
function Counter() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    console.log(count); // count 指向「定义时所在的作用域」
  };

  return <button onClick={handleClick}>按钮</button>;
}

关键:handleClick 里的 count定义时就确定了——它属于 Counter 函数执行时的局部作用域,不管 handleClick 被传到哪、怎么调用,它内部访问的 count 永远是当前 Counter 作用域里的变量。

  • 哪怕你把 handleClick 传给子组件、甚至全局调用,它依然能拿到 Counter 里的 count(因为词法作用域「锁死」了变量的查找路径);
  • 全程没有「动态绑定」,只有「静态的作用域查找」,所以永远不会找错变量。

再用「找路」的例子通俗解释

类组件(动态 this) 函数组件(词法作用域)
你告诉朋友:「到我家后,问主人在哪,然后跟主人走」→ 朋友到了之后,可能遇到假主人(this 指向错)、没主人(this=undefined),走丢; 你告诉朋友:「沿着XX路直走,到3号楼2单元」→ 路线是固定的(定义时就确定),不管朋友什么时候来、怎么来,按路线走都能到,不会错;

核心总结

  1. 类组件的坑:this动态绑定,指向由「调用方式」决定,写代码时无法确定,容易出错;
  2. 函数组件的优势:利用 JS 的静态词法作用域,变量的查找路径在「定义时就固定」,不管怎么调用函数,都能精准找到对应的变量;
  3. 最终结果:函数组件从根源上避开了「动态绑定」的不确定性,不用再纠结「this 指向谁」,逻辑更稳定。

精准抓到「动态调用绑定 vs 静态词法作用域」这个核心,说明我们已经完全理解了 Hooks 函数组件避开 this 坑的底层逻辑!

类组件痛点2: 业务相关逻辑碎片化

一个业务逻辑(比如「请求数据并渲染」)往往需要拆分到多个生命周期里,导致相关逻辑被分散在不同函数中,阅读和维护成本极高。

class UserList extends React.Component {
 state = { users: [], loading: true };

  // 1. 组件挂载时请求数据 -->
 componentDidMount() {
   this.fetchUsers();
   this.timer = setInterval(() => console.log('定时器'), 1000);
 }

  // 2. 组件更新时(比如 props 变化)重新请求数据 -->
 componentDidUpdate(prevProps) {
   if (prevProps.id !== this.props.id) {
     this.fetchUsers();
   }
 }

  // 3. 组件卸载时清理定时器 -->
 componentWillUnmount() {
   clearInterval(this.timer);
 }

 // 业务逻辑:请求用户数据
 fetchUsers() {
   fetch('/api/users')
     .then(res => res.json())
     .then(users => this.setState({ users, loading: false }));
 }

 render() { /* 渲染逻辑 */ }
}

你看:「请求数据 + 清理定时器」 这两个相关的逻辑,被拆到了 componentDidMount/componentDidUpdate/componentWillUnmount 三个生命周期里,代码跳来跳去,很难一眼看懂。

也就是说 业务逻辑强制和 react生命周期耦合到一起去了

类组件痛点3: 状态逻辑复用难、陷入嵌套地狱

先明确核心概念

  • 状态逻辑复用:比如「跟踪鼠标位置」「表单校验」「登录状态管理」这些逻辑,多个组件都需要用,想抽出来复用;
  • 嵌套地狱:为了复用逻辑,类组件只能用「高阶组件(HOC)」或「Render Props」,导致组件代码一层套一层,像剥洋葱一样难读。

第一步:看一个真实场景——复用「鼠标位置跟踪」逻辑

假设你有两个组件:MouseShow(显示鼠标位置)、MouseFollowBtn(按钮跟着鼠标动),都需要「跟踪鼠标位置」这个逻辑。

先写类组件的复用方案:Render Props(最典型的嵌套来源)

首先,把「鼠标跟踪」逻辑封装成一个通用组件(Render Props 模式):

// 通用的鼠标跟踪组件(Render Props 核心:把状态传给 children 函数)
class MouseTracker extends React.Component {
  state = { x: 0, y: 0 };

  // 监听鼠标移动,更新状态
  componentDidMount() {
    window.addEventListener('mousemove', this.handleMouseMove);
  }
  componentWillUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove);
  }
  handleMouseMove = (e) => {
    this.setState({ x: e.clientX, y: e.clientY });
  };

  // 核心:把状态传给子组件(通过 children 函数)
  render() {
    return this.props.children(this.state);
  }
}

然后,用这个组件实现「显示鼠标位置」:

// 第一个组件:显示鼠标位置
function MouseShow() {
  return (
    <div>
      <h2>鼠标位置:</h2>
      {/* 第一层嵌套:MouseTracker */}
      <MouseTracker>
        {/* children 是函数,接收鼠标状态 */}
        {({ x, y }) => (
          <p>X: {x}, Y: {y}</p>
        )}
      </MouseTracker>
    </div>
  );
}

再实现「按钮跟着鼠标动」:

// 第二个组件:按钮跟着鼠标动
function MouseFollowBtn() {
  return (
    <div>
      {/* 第一层嵌套:MouseTracker */}
      <MouseTracker>
        {({ x, y }) => (
          {/* 按钮样式绑定鼠标位置 */}
          <button style={{ position: 'absolute', left: x, top: y }}>
            跟着鼠标跑
          </button>
        )}
      </MouseTracker>
    </div>
  );
}
问题来了:如果要复用多个逻辑,嵌套直接「地狱化」

现在需求升级:这两个组件不仅要「跟踪鼠标」,还要「复用主题样式」(比如深色/浅色模式)。

先封装「主题复用」的 Render Props 组件:

// 通用的主题组件
class ThemeProvider extends React.Component {
  state = { theme: 'dark', color: 'white', bg: 'black' };

  render() {
    return this.props.children(this.state);
  }
}

现在,MouseShow 要同时复用「鼠标+主题」逻辑,代码变成这样:

function MouseShow() {
  return (
    <div>
      <h2>鼠标位置:</h2>
      {/* 第一层嵌套:ThemeProvider */}
      <ThemeProvider>
        {/* 接收主题状态 */}
        {({ theme, color, bg }) => (
          {/* 第二层嵌套:MouseTracker */}
          <MouseTracker>
            {/* 接收鼠标状态 */}
            {({ x, y }) => (
              <p style={{ color, backgroundColor: bg }}>
                【{theme}主题】X: {x}, Y: {y}
              </p>
            )}
          </MouseTracker>
        )}
      </ThemeProvider>
    </div>
  );
}

如果再要加一个「用户登录状态」的复用逻辑,就会出现第三层嵌套

<UserProvider>
  {({ user }) => (
    <ThemeProvider>
      {({ theme, color, bg }) => (
        <MouseTracker>
          {({ x, y }) => (
            <p>【{user.name}】【{theme}】X: {x}, Y: {y}</p>
          )}
        </MouseTracker>
      )}
    </ThemeProvider>
  )}
</UserProvider>

这就是嵌套地狱

  • 代码层层缩进,一眼看不到头;
  • 逻辑越复用,嵌套越深;
  • 想改某个逻辑(比如换主题),要在嵌套里找半天,维护成本极高。
补充:高阶组件(HOC)的复用方式,同样逃不开嵌套

如果用 HOC 实现复用(比如 withMousewithTheme),代码是这样的:

// 用 HOC 包装组件:一层套一层
const MouseShowWithTheme = withTheme(withMouse(MouseShow));

// 渲染时看似没有嵌套,但 HOC 本质是「组件套组件」,调试时 DevTools 里全是 HOC 包装层
// 比如 DevTools 里会显示:WithTheme(WithMouse(MouseShow))

调试时要一层层点开包装组件,才能找到真正的业务组件,同样痛苦。

函数组件说:没有hook,就别指望我了

千万别以为 函数组件是救星,React 早就有函数组件了,但是只是个纯展示的配角

先明确:Hooks 出现前,函数组件的「纯展示」本质

在 React 16.8(Hooks 诞生)之前,函数组件的官方定位就是 「无状态组件(Stateless Functional Component,SFC)」,核心特点:

  1. 没有自己的状态(this.state);
  2. 没有生命周期(componentDidMount/render 等);
  3. 本质就是「输入 props → 输出 JSX」的纯函数——输入不变,输出就不变,没有任何副作用。

举个 Hooks 前的函数组件例子:

// 典型的「纯展示组件」:只接收 props,渲染 UI,无任何状态/副作用
function UserCard(props) {
  const { name, avatar, age } = props;
  return (
    <div className="card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
      <p>年龄:{age}</p>
    </div>
  );
}

// 使用时:状态全靠父组件传递
class UserList extends React.Component {
  state = {
    users: [{ name: '张三', avatar: 'xxx.png', age: 20 }]
  };

  render() {
    return (
      <div>
        {this.state.users.map(user => (
          <UserCard key={user.name} {...user} />
        ))}
      </div>
    );
  }
}

这个例子里:

  • UserCard 是函数组件,只负责「展示」,没有任何自己的逻辑;
  • 所有状态(users)、数据请求、生命周期逻辑,都必须写在父类组件 UserList 里;
  • 如果 UserCard 想加个「点击头像放大」的交互(需要状态 isZoom),对不起——函数组件做不到,必须把它改成类组件,或者把 isZoom 状态提到父组件里(增加父组件复杂度)。

为什么当时函数组件只能是「纯展示」?

核心原因是 React 的设计规则:

  • 状态、生命周期、副作用这些「动态能力」,当时只开放给类组件;
  • 函数组件被设计成「轻量、高效、无副作用」的最小渲染单元,目的是简化「纯展示场景」的代码(不用写 class/constructor 等冗余代码)。

Hooks说:函数组件你别灰心,我让你从配角变主角

先拆清楚:函数组件 vs Hooks 的分工

1. 光有「函数组件」,解决不了任何问题

在 Hooks 出现之前,React 早就有函数组件了,但那时的函数组件是「纯展示组件」—— 没有状态、没有生命周期,只能接收 props 渲染 UI。

如果想在旧版函数组件中复用状态逻辑,依然只能用「Render Props/HOC」的嵌套方式:

// Hooks 出现前的函数组件:想复用逻辑,还是得嵌套
function MouseShow() {
  return (
    <MouseTracker>
      {({x,y}) => <p>X:{x}, Y:{y}</p>}
    </MouseTracker>
  );
}

你看,哪怕是函数组件,没有 Hooks,依然逃不开嵌套 —— 因为没有「抽离状态逻辑」的工具。

2. Hooks 出现后:函数组件从「纯展示」→「全能选手」-- 痛点1迎刃而解

还是上面的 UserCard,Hooks 后可以直接加状态/副作用,不用依赖父组件:

import { useState } from 'react';

// 函数组件拥有了自己的状态,不再是「纯展示」
function UserCard(props) {
  const { name, avatar, age } = props;
  // 自己的状态:控制头像是否放大
  const [isZoom, setIsZoom] = useState(false);

  return (
    <div className="card">
      <img 
        src={avatar} 
        alt={name}
        style={{ width: isZoom ? '200px' : '100px' }}
        onClick={() => setIsZoom(!isZoom)} // 自己的交互逻辑
      />
      <h3>{name}</h3>
      <p>年龄:{age}</p>
    </div>
  );
}

此时函数组件的定位完全变了:

  • 既能做「纯展示」(简单场景),也能做「有状态、有副作用、有复杂逻辑」的完整组件;
  • 彻底替代了类组件的大部分场景,成为 React 官方推荐的写法。
  • 是不是 彻底解决了 痛点1

3. 相关逻辑「聚在一起」,告别碎片化 -- 痛点2 再见

useEffect 一个 Hook 就能覆盖挂载、更新、卸载三个阶段的逻辑,让「请求数据 + 清理定时器」这样的相关逻辑写在同一个地方。

import { useState, useEffect } from 'react';

function UserList({ id }) {
 const [users, setUsers] = useState([]);
 const [loading, setLoading] = useState(true);

 // 核心:请求数据 + 清理定时器 写在同一个 useEffect 里
 useEffect(() => {
   // 1. 挂载/更新时请求数据
   const fetchUsers = async () => {
     const res = await fetch(`/api/users?id=${id}`);
     const data = await res.json();
     setUsers(data);
     setLoading(false);
   };
   fetchUsers();

   // 2. 挂载时启动定时器
   const timer = setInterval(() => console.log('定时器'), 1000);

   // 3. 卸载/更新时清理副作用
   return () => clearInterval(timer);
 }, [id]); // 只有 id 变化时,才重新执行

 return <div>{loading ? '加载中' : users.map(u => <div key={u.id}>{u.name}</div>)}</div>;
}

对比类组件的写法:所有相关逻辑都在一个 useEffect,不用在多个生命周期函数之间跳来跳去,可读性直接拉满。

4. Hooks 才是「解决嵌套问题的核心」

Hooks(尤其是自定义 Hooks)的核心价值,是「把状态逻辑从组件渲染流程中抽离出来,变成可调用的纯逻辑函数」。

Hooks 的核心思路是:把「状态逻辑」从「组件嵌套」中抽离出来,变成独立的「函数」,组件直接调用函数即可,没有任何嵌套毕竟状态逻辑本来就跟 UI组件无关,为啥非要掺合在一起呢

还是上面的场景,用自定义 Hook 实现:

1. 抽离「鼠标跟踪」的自定义 Hook
import { useState, useEffect } from 'react';

// 自定义 Hook:抽离鼠标跟踪逻辑,返回鼠标位置
function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return position;
}
2. 抽离「主题」的自定义 Hook
// 自定义 Hook:抽离主题逻辑,返回主题状态
function useTheme() {
  const [theme, setTheme] = useState({ mode: 'dark', color: 'white', bg: 'black' });
  return theme;
}
3. 组件中直接调用 Hook,没有任何嵌套
function MouseShow() {
  // 调用自定义 Hook:平铺写法,没有嵌套
  const { x, y } = useMousePosition(); // 复用鼠标逻辑
  const { mode, color, bg } = useTheme(); // 复用主题逻辑
  const { user } = useUser(); // 再复用用户逻辑,也只是多一行代码

  return (
    <p style={{ color, backgroundColor: bg }}>
      【{user.name}】【{mode}主题】X: {x}, Y: {y}
    </p>
  );
}
  • useState/useEffect 等内置 Hooks:让函数组件拥有了「状态」和「副作用处理能力」(这是抽离逻辑的基础);
  • 自定义 Hooks:把复用逻辑(比如鼠标跟踪、主题管理)封装成独立函数,让函数组件能「平铺调用」,而非「嵌套组件」。

对比类组件的嵌套地狱:

  • Hooks 是**平铺式复用**:不管复用多少逻辑,都是「调用函数 → 用状态」,代码没有任何缩进嵌套;
  • 逻辑和组件解耦:useMousePosition 可以在任何组件里调用,不用套任何包装组件;
  • 调试简单:DevTools 里直接看到 MouseShow 组件,没有层层包装的 HOC/Render Props 组件。

核心总结(痛点+解决方案)

类组件复用逻辑(HOC/Render Props) Hooks 复用逻辑(自定义 Hook)
必须通过「组件嵌套」实现 直接调用「函数」,无嵌套
逻辑越多,嵌套越深(地狱化) 逻辑越多,只是多几行函数调用
调试时要拆包装组件,成本高 调试直接看业务组件,逻辑清晰

简单来说,类组件的复用是「用组件套组件」,自然会嵌套;而 Hooks 的复用是「用函数抽逻辑」,组件只需要调用函数,从根源上消灭了嵌套地狱。 这也是 Hooks 最核心的价值之一——让状态逻辑复用变得简单、平铺、可维护

最后总结

  1. 函数组件是「容器」:提供了「平铺写代码」的基础形式,但本身没有复用状态逻辑的能力;
  2. Hooks 是「核心能力」
    • 内置 Hooks(useState/useEffect)让函数组件能「持有状态、处理副作用」给函数组件「加手脚」;
    • 自定义 Hooks 让状态逻辑能「脱离组件嵌套,以函数形式被平铺调用」「状态与 UI 解耦」
  3. 最终结论:不是函数的功劳,也不是单纯 Hook 的功劳 —— 是「函数组件作为载体」+「Hooks 作为逻辑复用工具」的组合,才解决了一系列问题

Hooks 是「矛」,函数组件是「握矛的手」—— 少了任何一个,都刺不穿嵌套地狱的盾。

❌