useState:状态更新的批处理与异步机制
第5章:useState:状态更新的批处理与异步机制
useState 看似简单,但其背后的设计决策——状态快照、批处理、函数式更新——直接影响你在复杂场景下能否写出正确的代码。
本章核心问题:为什么 setState 不是立即执行的?批处理机制如何影响你的代码? 读完本章你将理解:
- State 是每次渲染的不可变快照,而非可变变量
- React 18 自动批处理的行为变化及 flushSync 的使用场景
- 函数式更新如何打破闭包陷阱,以及懒初始化的正确用法
Level 1 · 你需要知道的(1-3年经验)
状态是快照,不是可变变量
React 初学者最常见的误解是把 state 当成普通的 JavaScript 变量。
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// 你以为 count 变成了 3,实际上只变成了 1
}
return <button onClick={handleClick}>{count}</button>;
}
在整个事件处理函数执行过程中,count 的值始终是当前渲染快照中的值。这个设计保证了渲染一致性——组件在一次渲染中读到的值不会中途改变。
setState 是调度,不是立即执行
调用 setState 并不会立即修改状态值,它只是告诉 React:"我希望在下一次渲染中,这个状态变成新值。"
function handleClick() {
console.log(count); // 0
setCount(count + 1);
console.log(count); // 仍然是 0,不是 1
}
函数式更新:处理异步和批处理
function handleClick() {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// count 正确地增加了 3
}
规则:当新状态依赖旧状态时,必须用函数式更新。
懒初始化:避免每次渲染都计算初始值
// 错误:每次渲染都执行 expensiveCompute()
const [data, setData] = useState(expensiveCompute());
// 正确:传入函数,React 只在初始化时调用一次
const [data, setData] = useState(() => expensiveCompute());
Level 2 · 它是怎么运行的(3-5年经验)
自动批处理:React 18 的重大改变
React 17 及之前:只有事件处理器内部才批处理。setTimeout、Promise 回调中的多次 setState 各自触发独立渲染。
React 18:所有场景都自动批处理。这背后依赖于 createRoot API。
// React 18:以下代码只触发 1 次渲染
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 1000);
flushSync:退出批处理
有时你确实需要在 setState 之后立即同步读取 DOM:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1);
});
// 这行代码执行时,DOM 已经更新
listRef.current.scrollToItem(count + 1);
}
注意:flushSync 性能开销较大,只在真正需要同步 DOM 操作时使用。
闭包陷阱的深度分析
function SearchBox() {
const [query, setQuery] = useState('');
function handleSearch() {
fetchResults(query).then(data => {
// 如果用户在请求期间修改了 query,
// 这里的 query 仍然是旧值(闭包捕获)
setResults(data);
});
}
}
竞态条件的正确解法是使用 cleanup 或 AbortController,而不是试图在 .then 里读取最新的 query。
Level 3 · 规范怎么定义的(资深)
状态结构设计原则
useState 的使用方式直接影响组件的可维护性:
// 过度拆分:相关状态应该放在一起
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// 合理合并:表单字段天然是一个整体
const [form, setForm] = useState({ firstName: '', lastName: '' });
function handleChange(field, value) {
setForm(prev => ({ ...prev, [field]: value }));
}
何时合并,何时拆分? 如果多个状态总是同时更新,合并它们。如果它们完全独立,分开更容易推理。如果状态逻辑复杂,考虑 useReducer。
Level 4 · 边界与陷阱(所有人)
陷阱一:在 useEffect 依赖为空时使用 state 值
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count 永远是 0(闭包陷阱)
}, 1000);
return () => clearInterval(id);
}, []);
解法:使用函数式更新 setCount(prev => prev + 1)。
陷阱二:Object.is 比较的引用陷阱
const [user, setUser] = useState({ name: 'Alice' });
setUser({ name: 'Alice' }); // 触发重渲染!引用不同
setUser(prev => prev); // 不触发重渲染,引用相同
陷阱三:升级 React 18 时的行为变化
React 18 的自动批处理是一个重要的行为变化,升级时需要测试原有逻辑是否依赖了"每次 setState 立即触发渲染"的行为。绝大多数情况下,自动批处理让代码更快;少数需要同步 DOM 的场景,flushSync 是你的工具。