一个 useState,三个核心问题:
- 初始值可以传什么?惰性初始化又是什么?
setState为什么是"异步"的?React 为什么要这样做?- 为什么三次
setState(count + 1)结果是 +1,而三次setState(prev => prev + 1)结果却是 +3?
这篇文章逐一拆解。
一、useState 的基本形式
const [state, setState] = useState(初始值);
// ↑ ↑ ↑
// 状态变量 修改状态的方法 初始值
useState 返回一个数组:第一个元素是当前状态值,第二个是修改这个状态的方法。这两个东西是绑定在一起的——调用 setCount,React 就知道你要改 count;调用 setName,就知道要改 name。
二、初始值可以是什么
2.1 任何 JS 类型都可以
useState(0); // number
useState(''); // string
useState(null); // null — 常用于"暂无数据"
useState([]); // 空数组
useState({}); // 空对象
useState(true); // boolean
基础类型、对象、数组、null、undefined——没有任何限制。
2.2 惰性初始化:传函数
有时初始值不是随手能写出来的——比如从 localStorage 读数据、计算一个 1000 条的用户列表、做一次复杂的数学运算。
function heavyComputation() {
const result = [];
for (let i = 0; i < 1000; i++) {
result.push({ id: i, name: `用户-${i}` });
}
return result;
}
// ❌ 错误方式 — 每次渲染都执行 heavyComputation
const [users] = useState(heavyComputation());
// 组件每重新渲染一次,heavyComputation() 就跑一次,结果只在第一次被 React 使用,
// 后续渲染中计算结果直接丢弃。100 次渲染 = 100 次无用计算。
// ✅ 惰性初始化 — 只在挂载时执行一次
const [users] = useState(() => heavyComputation());
// React 在首次挂载时执行这个箭头函数,拿到返回值,存入 state。
// 后续重新渲染时,React 已经有初始值了,箭头函数不会被执行。
React 内部的简化逻辑:
function useState(initialValue) {
if (!hasMounted) {
// 如果是函数就执行拿结果,不是函数就直接用
state = typeof initialValue === 'function'
? initialValue()
: initialValue;
hasMounted = true;
}
// 后续渲染:initialValue 完全被忽略
return [state, setState];
}
惰性初始化 = "这个初始值算起来很贵,你只算一次就行。"
三、setState 的"异步"是什么意思
3.1 它不返回 Promise
const result = setCount(count + 1);
console.log(result); // undefined
setState 不是 JavaScript 的 async 函数。说它"异步"是指:调用后状态值不会立即改变。
const handleClick = () => {
console.log(count); // 0
setCount(count + 1);
console.log(count); // 还是 0,没变
};
3.2 React 为什么要这样做:批处理(Batching)
React 的目的是减少真实 DOM 操作的次数。如果每次 setState 都立即更新,就会:
setState → 更新 DOM → Layout → Paint → Composite
setState → 更新 DOM → Layout → Paint → Composite
setState → 更新 DOM → Layout → Paint → Composite
每调用一次,浏览器就要走一遍完整的渲染管线。
React 的做法是:把同一事件处理函数中的多次 setState 攒起来,等函数执行完毕,一次性计算新状态、一次性更新 DOM。
setState(1) ──┐
setState(2) ──┼── React:"不急,先攒着"
setState(3) ──┘
│
函数执行完毕 ↓
│
React:一次性计算 → 一次性更新 DOM → 浏览器只重排一次
这和我们用原生 document.createDocumentFragment() 批量插入 DOM 节点的思路完全一致——攒着,一起做,减少 Layout 和 Paint 的次数。
四、为什么三次 setState(count + 1) 结果是 +1
4.1 传值:值来自渲染快照
React 每次渲染都是一个独立的快照。在同一个快照中,state 是常量——它不会中途变化。
当前渲染快照中:count = 0(这个值在快照中是不会变的)
setState(count + 1); // setState(0 + 1) = setState(1)
setState(count + 1); // setState(0 + 1) = setState(1)
setState(count + 1); // setState(0 + 1) = setState(1)
↑ 三次的 count 都是 0
每次 count + 1 的结果都是 0 + 1 = 1。React 批处理合并这三次,最终 count 变成 1。
这不是 bug,是 React 有意为之——同一次渲染中,state 是常量。这保证了代码的可预测性:同一个快照里所有地方看到的 count 都一样。
4.2 这跟闭包有什么关系
先看一个最基础的闭包例子:
function createCounter() {
let count = 0; // 外部函数的变量
return function() { // 内部函数
count++;
console.log(count);
};
}
const counter = createCounter(); // createCounter 执行完了
counter(); // 1 // 内部函数还是能访问 count
counter(); // 2 // 它"记住"了 count
counter(); // 3
闭包 = 内部函数"抓"住了外部函数的变量。即使外部函数已经执行完毕,内部函数仍然可以访问这些变量。 这是 JavaScript 最基础也最优雅的特性之一。
现在看 React 组件:
function Counter() { // 外部函数 — 组件本身
const [count, setCount] = useState(0); // count 是 Counter 里的变量
const handleClick = () => { // 内部函数 — 闭包
setCount(count + 1); // ← 这个 count 是从外面抓进来的
};
return <button onClick={handleClick}>+</button>;
}
handleClick 是一个闭包,它抓取了它出生那次渲染中的 count 值。
关键来了:每次重新渲染,Counter() 都会完整地重新执行一次,产生一个全新的闭包:
第 1 次渲染: Counter() → count = 0, handleClick 闭包记住 count = 0
第 2 次渲染: Counter() → count = 1, handleClick 闭包记住 count = 1
第 3 次渲染: Counter() → count = 2, handleClick 闭包记住 count = 2
现在回头看三次 setCount(count + 1):
const handleClick = () => {
setCount(count + 1); // count 来自闭包,在第 1 次渲染中是 0
setCount(count + 1); // 同一个闭包,count 还是 0
setCount(count + 1); // 还是 0
};
这三个 count 都是同一个闭包里的同一个变量,它在这个闭包生命周期里永远不会变。 这就是常说的"闭包陷阱"——但严格来说这不是陷阱,闭包本来就应该这样工作。闭包忠实地记住了它出生时看到的值,这正是闭包的设计意图。
所以问题不在于闭包,而在于 React 的渲染模型:每次渲染是一个独立快照,闭包抓住的是那个快照中的值。 你需要的不是"修复闭包",你需要的是"从闭包外面拿最新值"——这就是传函数的作用。
4.3 传函数:绕过闭包,让 React 给值
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
prev 不是从闭包抓来的,而是 React 在执行时通过函数参数实时传进来的。你传了一个函数(prev => prev + 1)给 React,React 在合适的时机调用它,并把最新状态值作为 prev 传进去。
// React 内部简化逻辑
function dispatch(action) {
if (typeof action === 'function') {
newState = action(currentState); // 把最新 state 作为参数传给你的函数
} else {
newState = action; // 直接用值
}
}
React 维护一个更新队列,依次执行这些函数:
let state = 0; // 当前状态
const updaters = [
prev => prev + 1, // 第一个更新函数
prev => prev + 1, // 第二个
prev => prev + 1, // 第三个
];
state = updaters[0](state); // prev = 0 → 返回 1
state = updaters[1](state); // prev = 1 → 返回 2
state = updaters[2](state); // prev = 2 → 返回 3
// 最终 state = 3
传值 = 你从闭包快照里拿 count,算好结果告诉 React。传函数 = 你告诉 React"怎么算",React 把最新值递到你手上。 后者主动放弃了闭包里的旧值,把控制权交还给了 React。
4.4 直观对比
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
这里 prev 不是从渲染快照中抓取的,而是 React 在执行时通过参数传进来的——每次传的都是最新值。
React 内部维护一个更新队列,依次执行这些函数:
let state = 0; // 初始值
const updaters = [
prev => prev + 1, // 你的第一个更新函数
prev => prev + 1, // 第二个
prev => prev + 1, // 第三个
];
state = updaters[0](state); // prev = 0 → 返回 1
state = updaters[1](state); // prev = 1 → 返回 2
state = updaters[2](state); // prev = 2 → 返回 3
// 最终 state = 3
4.4 直观对比
传值 setState(count + 1):
你从快照里拿 count(旧值),算好了结果告诉 React
→ 三次算出来都是同一个结果 → 被批处理合并
传函数 setState(prev => prev + 1):
你告诉 React"怎么算",由 React 把最新值传给你
→ 三次执行,每次拿到上一步的结果 → 链式累加
| 传值 | 传函数 | |
|---|---|---|
| 值的来源 | 当前渲染快照中的 state(旧值) | React 在执行时传入的最新值 |
| 谁负责计算 | 调用之前就计算好了 | React 调用时实时计算 |
| 多次调用的结果 | 都拿到同一个值 | 每次拿到上一步的结果 |
五、总结
| 知识点 | 一句话 |
|---|---|
| 初始化 | useState(初始值),初始值可以是任何 JS 类型 |
| 惰性初始化 | useState(() => 昂贵计算) — 函数只在挂载时执行一次 |
| setState 的"异步" | 调用后状态不立即改变,React 批处理合并更新 |
| 批处理的目的 | 减少真实 DOM 更新次数,进而减少浏览器 Layout/Paint |
| 传值 vs 传函数 | 传值用的是快照中的旧值,传函数拿到的是 React 给的最新值 |
| 什么时候必须传函数 | 当新状态依赖旧状态时(prev => prev + 1),避免批处理覆盖 |