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 不会复制旧树,而是:
- 从根 Fiber 开始,
遍历旧树(Current)的节点(按child/sibling层级); - 对每个旧 fiber 节点,执行「调和(Reconciliation)」:
- 如果组件类型未变(且key也没变)(比如还是
<App />):确认结构没变化,创建新的 workInProgress fiber 节点,(只复用旧节点的“结构信息”,比如层级、类型,不复制状态); - 如果组件类型变了(比如
<App />变成<Other />):丢弃旧节点,创建全新的新节点; - 如果组件被卸载:不创建新节点;
- 如果组件类型未变(且key也没变)(比如还是
-
关键:为新创建的 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 新树骨架」,对每个函数组件节点:
- 调用
updateFunctionComponent→ 进而调用renderWithHooks; - 执行组件的渲染函数(比如
App()),触发 Hook 调用; 通过 workInProgress.alternate 找到旧 fiber,读取旧 Hook 状态,计算新状态;- 把新状态填充到 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个 Hook(name)→ 匹配旧链表第1个节点(name)→ 正常;
- 本次第2个 Hook(phone)→ 匹配旧链表第2个节点(submit)→ 错位! phone 会读取到 submit 的旧状态(false);
- 本次第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 会:
- 用当前的
currentHook创建新 Hook(复制 memoizedState/queue); - 手动执行
currentHook = currentHook.next——这就是“找到下一个旧 Hook”的核心操作; 新 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 不能放条件语句里的核心原因:
- React 更新时,会按本次 Hook 调用顺序,逐个匹配旧 Hook 链表的节点(指针逐次后移);
- 条件语句会导致本次 Hook 的调用顺序/数量,和旧链表的节点顺序/数量不一致;
- 最终要么状态错位(张冠李戴),要么直接报错(数量不匹配),完全破坏 Hook 状态和组件的绑定关系。
关于useEffect
useEffect副作用函数啥时候执行?
一、先搞懂核心概念:React 工作的三大步骤
文章里提到的 React 工作流程是理解所有问题的基础,先把这个核心框架记住:
-
调度器:决定「什么时候更新」(比如用户点击、数据请求完成触发更新)
调度器就是 React 的「时间管家」,它判断 “什么时候该干活”。比如你点击按钮想改页面内容,调度器会说 “现在可以开始更新了”;再比如请求接口拿到数据后,调度器会说 “数据到了,该更新页面了”。
-
协调器:决定「更新什么内容」(生成 Fiber 树,给需要操作的 Fiber 打标记,构建 effectList)
协调器是 React 的「规划师」,它不管 “什么时候干”,只管 “干什么”。
-
渲染器:执行「实际的更新操作」(遍历 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 为空,不会触发更新);
-
核心不同:执行时机的「同步/异步」差异:
-
componentDidMount:属于类组件的生命周期钩子,在「DOM 渲染完成后同步执行」(渲染完立刻执行,阻塞后续代码); -
useEffect([]):属于 Passive Effect,React 会在「DOM 渲染完成后,把所有 Passive Effect 的回调放到异步队列里执行」(不阻塞主线程,浏览器先绘制页面,再执行回调)。
-
更贴近 componentDidMount 的是 useLayoutEffect
useLayoutEffect 的回调会在「DOM 渲染完成后同步执行」,和 componentDidMount 时机几乎一致;而 useEffect 是异步执行,这也是为什么有时用 useEffect 操作 DOM 会看到「一闪而过」的视觉问题(页面先渲染,再执行回调修改 DOM),而 useLayoutEffect 不会。
四、useEffect 完整执行流程(结合文章)
- 组件触发更新 → 协调器执行 FunctionComponent,遇到 useEffect 检查 deps 是否变化;
- 如果 deps 变化 → 给当前组件的 Fiber 打上
Passive标记; - 协调器在「归阶段」构建 effectList(顺序:叶子→根);
- 渲染器遍历 effectList:
- 先执行所有 useEffect 的「销毁函数」(即回调的返回值);
- 再执行所有 useEffect 的「回调函数」(create);
- 整个过程在页面渲染后异步完成。
总结
- useEffect 执行顺序:由 effectList 决定,而 effectList 构建于「归阶段」,所以是「子组件 → 父组件 → 根组件」;
- useEffect 执行时机:DOM 渲染后异步执行,和类组件的 componentDidMount(同步执行)时机不同,useLayoutEffect 才是同步执行,更贴近 componentDidMount;
- 核心思路:理解 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;
- 首次挂载:执行组件时,检查 deps(无旧 deps)→ 判定“变化”,记录
-
归阶段:
- 首次挂载:因
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 执行 // 空数组依赖的回调不再执行,无依赖的每次都执行
补充:容易踩的坑
- 误以为“不写依赖”=“依赖所有变量”: 很多新手觉得“不写 deps”是“监听所有变量变化”,但底层逻辑是——React 不做任何 deps 对比,直接判定“每次都变化”,哪怕组件内没有任何变量变化,只要组件重渲染,就会执行;
- 空数组依赖≠“完全不依赖”: 空数组只是“不依赖组件内的任何 state/props”,但如果依赖的是“外部全局变量”(比如 window 的属性),全局变量变化时,回调也不会重新执行(因为 deps 还是空数组);
- 性能风险: 不写依赖的 useEffect 若包含耗时操作(比如接口请求、DOM 频繁修改),会导致组件每次更新都触发耗时操作,严重时卡顿,实战中绝对要避免不写依赖的写法。
总结
-
空数组
[]:仅首次挂载打 Passive 标记,回调只执行 1 次,对应“仅 mount 执行”; - 不写依赖:每次更新都打 Passive 标记,回调每次渲染都执行,对应“mount + 每次 update 执行”;
- 底层核心差异: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)。
二、现象原因分析(闭包陷阱核心)
结合你之前理解的「闭包快照」逻辑,拆解核心原因:
-
每次点击触发的是「同一版本的 handleClick」:
快速点击3次时,所有点击都发生在「1秒延迟期内」,组件还没重新渲染,
<button>的onClick始终绑定「初始渲染的 handleClick(版本1)」。 -
版本1 handleClick 的闭包捕获的是初始 count=0:
3次点击都会预约「1秒后执行
setCount(0 + 1)」,最终 React 处理这3个更新时,都是将 count 设为1,因此最终只显示1。 -
核心本质:传值更新(
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 保存最新值(适合需主动获取最新状态的场景)
改造思路
利用 useRef 的 current 属性是「引用类型」的特性(闭包中能拿到最新值,不会被快照锁定),同步 count 到 ref,延迟操作时从 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;
原理与效果
-
原理:
useRef的current属性存储在堆内存中,引用地址不变,每次组件渲染都会同步最新的count到countRef.current;延迟回调执行时,能直接读取到「最新的 count 值」; -
效果:同样实现点击3次后 count 显示
3,适合需要「在闭包中主动获取最新状态」的场景(比如除了更新,还需打印/使用最新count)。
四、方案对比与适用场景
| 方案 | 核心逻辑 | 适用场景 | 优点 |
|---|---|---|---|
| 函数式更新 | 依赖React传入的最新prevState | 仅需「基于最新状态更新」(绝大多数场景) | 简洁、React官方推荐 |
| useRef | 手动同步最新状态到ref | 需要「主动读取最新状态」(如打印、传参等) | 灵活,可主动控制状态 |
总结
- 闭包陷阱本质:快速点击时,延迟更新依赖「闭包内的旧count快照」,多次更新都是基于同一个旧值,最终只生效一次;
- 核心改造逻辑:脱离闭包对旧值的依赖——要么用函数式更新让React传最新值,要么用useRef保存最新值;
-
最佳实践:优先使用「函数式更新」(
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. 依赖监听的差异
useEffect、useCallback等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>
);
}
总结
-
核心差异:
useState管理渲染状态(更新触发重渲染),useRef存储持久化数据(更新不触发渲染); -
使用原则:数据需要展示在页面上→用
useState;数据仅在内存中使用→用useRef; -
更新特性:
useState是不可变更新(需setter),useRef是可变更新(直接改.current)。