前言
如果你刚接触 React,大概率第一个遇到的 Hook 就是 useState。本文会用一个 Vite + React 项目中的两个小示例,一步步拆解它的工作机制。我们不求面面俱到,只求把最核心的几个概念讲清楚。
本文涉及的代码文件:
- src/App.jsx --- 用户列表 + 筛选,演示惰性初始化 和派生状态
- src/App2.jsx --- 计数器,演示异步更新 和函数式更新
一、useState 能做什么
简单说,useState 让你在函数组件里拥有"会变的数据"。一个计数器可能是这样:
jsx
const [count, setCount] = useState(0)
count 是当前值,setCount 是用来改值的函数。改值之后,React 会重新跑一次组件函数,页面就会跟着更新。
传统的变量是做不到这一点的------你改了值,页面不会自动刷新。而 useState 搭起了"数据变化 → 界面重渲染"这座桥。
二、状态的变更其实是"排队的"
来看 App2.jsx 里的这段代码:
jsx
const [count, setCount] = useState(0)
const addCount = () => {
setCount(count + 1)
console.log(count) // 这里打印的是旧值还是新值?
}
很多初学者直觉上会觉得 console.log(count) 应该打印 1。但实际上,它打印的是 0。
原因在于,setCount 不会立刻修改 count。 它做的事情更像是:"嘿 React,我这儿有个更新,你找个合适的时间处理一下。" 然后当前这段代码继续往下跑,等整段跑完之后,React 才会统一处理排队的更新,并触发一次重新渲染。
可以这样理解执行顺序:
scss
用户点击按钮
↓
setCount(count + 1) → 把更新加入队列,count 暂时不变
↓
console.log(count) → 同步代码,拿到的是尚未更新之前的 0
↓
当前事件处理函数执行完毕
↓
React 处理队列中的更新,组件函数重新执行
↓
count 变成 1,页面更新
这个机制通常被称为批量更新(batching)------React 会把短时间内多次 setState 合并处理,免得每改一个值就渲染一次,浪费性能。
三、连续多次 setState 的小陷阱
假设你想点一次按钮就让计数器加 3:
jsx
setCount(count + 1) // count 当前是 0
setCount(count + 1) // count 当前是 0(还没更新)
setCount(count + 1) // count 当前是 0(还没更新)
三次调用拿到的 count 都是 0,React 批量处理后,最终 count 变成了 1 而不是 3。
这不是 bug,而是 React 有意为之的优化策略。同一个事件处理函数中的多次 setState,React 会尽量合并。
那如果想实现 +3 的效果呢?这时候就该用函数式更新了:
jsx
setCount(prev => prev + 1) // prev = 0 → 返回 1
setCount(prev => prev + 1) // prev = 1 → 返回 2
setCount(prev => prev + 1) // prev = 2 → 返回 3
当你传给 setCount 的不是一个值,而是一个函数 时,React 会把这个函数放进一个队列里,然后按顺序执行。每一步的 prev 都是上一步的返回值,所以最终能拿到正确累积的结果。
什么时候用函数式更新? 一个简单的判断标准:如果你的新状态依赖旧状态,用函数式更新通常更安全。它不依赖闭包里的旧快照,而是总是拿到最新的前一次结果。
四、初始值很"贵"的时候怎么办
App.jsx 里有一个模拟"昂贵计算"的函数:
jsx
function heavyComputation() {
console.log('开始执行 heavyComputation....')
const startTime = performance.now()
const result = []
for (let i = 0; i < 10000; i++) {
result.push({ id: i, name: `用户:${i}` })
}
console.log(`heavyComputation 执行耗时: ${performance.now() - startTime}ms`)
return result
}
假设初始状态需要跑这样一段比较耗时的逻辑,比如生成大量用户数据。两种写法看起来很像,但效果截然不同:
jsx
// 写法 A
const [users] = useState(heavyComputation())
// 写法 B
const [users] = useState(() => heavyComputation())
写法 A :heavyComputation() 会在每次渲染时都执行。虽然 React 后续渲染时会忽略返回值(只取第一次的),但函数本身跑了,时间就花了。
写法 B :传入的是函数引用 () => heavyComputation(),React 只在组件首次挂载时调用它。后续重新渲染时,React 看到传进来的是函数,直接跳过。
这就是 lazy initialization(惰性初始化)。当初始值需要经过复杂计算才能得到时,传入一个函数而不是计算结果,可以避免不必要的开销。
当然,如果初始值只是一个简单的 0 或 [],就没必要纠结了------直接传值完全没问题。
五、不是所有的数据都需要变成 state
App.jsx 里还有一个值得注意的细节:
jsx
const [users] = useState(() => heavyComputation())
const [filterText, setFilterText] = useState('')
// 注意:filteredUsers 不是 state
const filteredUsers = users.filter(user =>
user.name.includes(filterText)
)
users 和 filterText 是 state,而 filteredUsers 只是一个普通变量------它由 users 和 filterText 计算得来。
这是一个很有用的思维方式:能推导出来的值,不需要单独存一份 state。 每次组件重新渲染时,filteredUsers 会自然地根据最新的 users 和 filterText 重新计算。
反过来,如果把它也设成 state:
jsx
const [filteredUsers, setFilteredUsers] = useState(users)
那就需要手动在 users 或 filterText 变化时同步更新它,凭空多了一份维护成本和出错的可能性。
这类值在 React 社区里常被称为 computed / derived state(派生状态)。它不是一个正式 API,而是一种设计思路:state 应该是"最少必要的那几个",剩下的交给计算。
六、换一个角度理解 state
抛开 API 细节,可以这样理解 React 组件:
ini
组件 = 一个函数
渲染 = 运行这个函数
state = 函数里会变、但变化时能让函数重新运行的变量
每一次渲染都是一次独立的函数调用,函数内部的变量形成一次"快照"。setState 做的事情本质上是告诉 React:"这轮跑完之后,用新值再跑一次。"
带着这个视角去看:
- 为什么
console.log(count)拿到的是旧值? 因为当前这次函数调用还没结束,快照还是旧的。 - 为什么函数式更新能突破限制? 因为
prev => prev + 1不依赖闭包里的快照,它读的是 React 内部管理的最新值。 - 为什么惰性初始化有用? 因为函数每次都会重新执行,但我们希望某些昂贵的逻辑只在"第一次"跑。
七、小结
| 要点 | 一句话 |
|---|---|
| useState 的作用 | 让函数组件拥有会触发重渲染的数据 |
| setState 的异步性 | 更新是排队的,当前代码块内读不到新值 |
| 批量合并 | 同一事件中多次 setState 会合并处理 |
| 函数式更新 | prev => newValue 用来安全地依赖旧值 |
| 惰性初始化 | 初始计算很贵时,传函数比传值更节省 |
| 派生状态 | 能推导的值不要设为 state,减少冗余 |
这篇文章聚焦在 useState 最核心的几个行为上。如果你还想了解它在真实项目里怎么和其他组件配合使用(状态提升、父子通信等),可以参考同目录下 todolist 项目的代码和笔记。