我被 useState 坑了两次之后,终于把它的脾气摸透了

前几天写个简单的计数器 demo,想点一下按钮数字加 3,我想这还不简单,连着写三行 setCount(count + 1) 不就完事了。结果一点按钮,数字从 0 变成 1,我人傻了。

我盯着代码看了三分钟,甚至怀疑自己加法算错了。又在后面加了个 console.log(count),控制台稳稳输出 0。合着我写了三行,一行都没生效?不对,也不是没生效,最后是变成 1 了,说明只执行了一次。

javascript 复制代码
const addCount = () => {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1); // 我天真地以为三次加起来就是+3
  console.log(count);  // 坑了我二十分钟,每次打印都是0
}

说实话当时我第一反应是闭包的锅?但想想不对,就算是闭包,三次执行也该有一次生效吧。后来翻了下 React 的文档才反应过来,我是拿同步的思路写 setState 了。

别拿同步的思路写 setState

其实 React 里的 setState,根本不是你调一次就立刻改一次状态。它是异步调度的,说白了就是你调用setCount之后,React 不会马上更新 count 的值,而是先把这个更新记下来,继续跑你当前函数里剩下的代码。等本轮所有同步代码都执行完了,再统一处理所有的状态更新,最后触发组件重渲染。

我当时看到这儿还专门想了个类比,这就跟奶茶店做单一样。你连着点三杯奶茶,店员不会点一杯做一杯,而是把三杯都记在单子上,攒到一批一起做,效率更高。React 也是这个道理,要是你调一次 setState 就重渲染一次组件,那状态一多,性能直接就崩了。

那为什么三次setCount(count + 1)最后只加了 1 呢? 因为这三次调用,拿到的 count 都是当前这轮渲染闭包里的值 ------ 也就是 0。三次传进去的都是0 + 1 = 1,React 合并更新的时候,发现三次都是改成 1,那自然就只执行最后一次,结果就是 1。

说穿了很简单,但刚写的时候真的容易懵,脑子里总觉得调用完 count 就变了,下一行就能拿到新值。

函数式更新:我就要每次拿最新的

那我非要点一下加 3 怎么办?总不能写个循环吧。 也简单,给 setCount 传个函数进去就行。

javascript 复制代码
const addCount = () => {
  setCount(prevCount => prevCount + 1);
  setCount(prevCount => prevCount + 1);
  setCount(prevCount => prevCount + 1); // 这次真的能+3
}

我第一次这么写的时候还挺疑惑,这个prevCount是哪来的?为啥传函数就能拿到最新的? 后来才明白,你传值进去的时候,React 直接拿这个值去覆盖状态;你传函数进去,React 处理更新队列的时候,会把上一次更新完的状态当成参数传给这个函数,执行完的结果再作为新的状态。

就好比你点奶茶的时候不说 "要一杯和现在一样的",而是说 "在当前这杯的基础上再加一份珍珠",店员处理的时候就会顺着上一次的结果往下做,三次累加下来自然就是加了三份珍珠。

所以记住这个规律:如果你的新状态依赖上一个状态的值,优先传函数;如果就是直接改个固定值,传值就行。

本想优化列表,结果越优化越卡

解决了计数器的问题没多久,我写用户列表的时候又踩了另一个坑。 需求很简单:生成一万条用户数据,上面有个输入框可以按名字筛选。我寻思数据量有点大,就写了个函数专门生成初始数据,直接丢给 useState 当初始值。

结果一跑起来,输入框打字卡得不行,每按一个键都要顿一下。打开控制台一看,好家伙,每打一个字,那个生成数据的函数就执行一次。

javascript 复制代码
// 这是我一开始的写法
const [users] = useState(heavyComputation());

我当时第一反应是 filter 写得有问题,还专门优化了半天筛选逻辑,结果一点用都没有。后来断点调试了一下才发现,每次输入框改变触发重渲染,整个组件函数都会重新跑一遍,useState 这行代码也会跟着执行。

虽然 React 只会用第一次的结果当初始值,后面重渲染的时候会忽略这个初始值,但是 ------ 你写的是heavyComputation(),是函数调用啊!JavaScript 才不管你 React 用不用这个结果,只要这行代码执行了,这个函数就会跑一遍。一万条数据,每次打字都生成一遍,不卡才怪。

useState 的初始值,别随便写函数调用

知道原因就好办了,把初始值改成函数形式就行:

javascript 复制代码
// 正确写法:传个函数进去
const [users] = useState(() => heavyComputation());

就加了个() => ,世界瞬间清净了。现在只有组件第一次挂载的时候,React 才会调用这个函数拿到初始值,后面不管怎么重渲染,这个函数都不会再执行了。这就是 React 里说的「懒初始化」。

小提示 别搞混两种写法:

  • useState(fn()):组件每次渲染都会执行 fn,只是 React 只用第一次的结果
  • useState(fn):只有首次渲染会执行 fn,后续渲染直接跳过

如果初始值只是个数字、字符串或者简单数组,直接写值就行,没必要强行套函数。只有当初始值需要复杂循环、大量计算或者读取本地存储这类耗时操作时,才需要用懒初始化优化。

翻了眼源码,大概摸清了逻辑

踩完这俩坑,我好奇心上来了,专门去翻了下 React 源码里 useState 的实现,想看看底层到底是怎么处理的。

其实 useState 本质上就是往一个 hook 链表里加节点。首次渲染的时候,会创建一个 hook 对象,把初始值存到 hook 的memoizedState里,如果初始值是函数,就先执行函数再存。

更新的时候呢,调用 setCount 其实就是往这个 hook 的更新队列里塞一个更新任务。等 React 调度到这个组件重渲染的时候,就会遍历这个更新队列,挨个计算新的状态:

  • 如果更新任务是个值,直接覆盖成新值
  • 如果更新任务是个函数,就拿当前的状态当参数执行,结果作为新状态

所以三次传值的更新,最后都会覆盖成同一个值;三次传函数的更新,就会依次执行,状态一步步累加。

大概捋明白这个逻辑之后,之前踩的坑就全串起来了,不是 React 有 bug,是我没搞懂它的更新机制。

最后说两句实在的

折腾完这俩坑,我对 useState 的理解才算真的落地了,不是停留在 "用来定义响应式状态" 的层面。

其实核心就三个容易踩的点,记牢了能少走很多弯路: 第一,setState 是异步批量更新的,调用完别立刻读状态,读了也是旧的。真需要在更新后做操作,要么用函数式更新,要么等组件重渲染之后在 effect 里处理。 第二,新状态依赖旧状态的时候,一定要传函数,别图省事直接用闭包里的变量,不然批量更新的时候很容易出问题。 第三,复杂的初始计算一定要用懒初始化,包一层箭头函数。别小看这点优化,数据量大的时候,性能差得不是一点半点。

当然 useState 也不是万能的,要是组件里状态特别多,状态之间还有各种联动逻辑,硬堆一堆 useState 反而更乱,那时候不如考虑 useReducer,或者直接上状态库。适合的场景用适合的东西,才是最优解。

你们平时写 useState 的时候,还踩过啥有意思的坑?评论区唠唠,我也避避雷。

相关推荐
卸任3 小时前
Vibe coding工具使用心得
前端·ai编程
brien93 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance4 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M4 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写4 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李4 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易4 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
清晨Coding4 小时前
删了 Cookie 还能自动登录?Google系网站登录态自动恢复的原理分析
javascript
风骏时光牛马4 小时前
前沿视界
前端
kyriewen4 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员