我被 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 的时候,还踩过啥有意思的坑?评论区唠唠,我也避避雷。

相关推荐
鱼樱前端1 小时前
我用 Claude Code 后,编码效率翻 3 倍。但更值钱的是别的。
前端·后端·ai编程
_lucas2 小时前
给知识库网站接入AI问答
前端·ai编程·全栈
浮生望2 小时前
React useState 深度解析:异步更新、闭包陷阱与惰性初始化的底层逻辑
react.js
前端糕手2 小时前
前端面试题大全:JavaScript + Vue3 + React + TypeScript + 工程化 + 性能优化
前端
我不叫武3 小时前
一个用 Rust 写的离线编码查询桌面工具
前端
Web4Browser4 小时前
指纹浏览器 API 自动化怎么接:启动 Profile、获取 CDP 端点并连接自动化框架
前端·网络·typescript·自动化
Patrick_Wilson5 小时前
从 React 到 Flutter:写给前端的一张跨端知识地图
前端·flutter·react.js
颜酱5 小时前
06 | 把 meta_config 同步进 MySQL(生成阶段)
前端·人工智能·后端
OpenTiny社区5 小时前
Loop Engineering:让 AI Agent 自己跑起来的工程方法
前端·github