第 5 章

useState:状态更新的批处理与异步机制

第5章:useState:状态更新的批处理与异步机制

useState 看似简单,但其背后的设计决策——状态快照、批处理、函数式更新——直接影响你在复杂场景下能否写出正确的代码。

本章核心问题:为什么 setState 不是立即执行的?批处理机制如何影响你的代码? 读完本章你将理解


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 是你的工具。

本章评分
4.9  / 5  (64 评分)

💬 留言讨论