React 状态更新与批处理机制

第五编 · 第六章:React 状态更新与批处理机制的深入分析


什么是批处理

批处理(batching)是 React 的一种性能优化机制:多个状态更新可以合并成一次重新渲染,而不是每次 setState 都触发一次。

考虑这个场景:

function Counter() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount(c => c + 1);
    setFlag(f => !f);
    setCount(c => c + 1);
  }

  return <button onClick={handleClick}>{count}</button>;
}

点击一次按钮,count 应该增加 2,而不是 1。如果没有批处理,setCount(c => c + 1) 会触发一次渲染,setFlag(!f) 会触发第二次渲染,setCount(c => c + 1) 会触发第三次渲染。

有了批处理,React 会在 handleClick 这个事件处理函数执行完毕后,把所有的状态更新合并成一次渲染。最终 count 增加 2,flag 翻转一次,组件只重新渲染一次。


批处理在 React 18 之前和之后的区别

React 18 之前,批处理只在 React 的事件处理函数内部生效。原生事件绑定(addEventListener)或者异步代码(setTimeoutPromise.then)里的 setState 不会自动批处理。

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.body.addEventListener('click', () => {
      setCount(c => c + 1); // React 18 之前,这会触发单独的一次渲染
    });
  }, []);

  return <div>{count}</div>;
}

在 React 18 之前,addEventListener 回调里的 setState 每次都会立即触发一次重新渲染。在 React 18 里,自动批处理扩展到了所有场景,包括 setTimeoutPromise.then、原生事件处理。

这就是为什么很多 React 18 的升级指南会说"某些依赖独立 setState 触发多次渲染的代码,在 React 18 里行为会变"——不是变 bug 了,而是批处理把多次渲染合并成了一次。


函数式更新和批处理的关系

回到之前提到的例子:

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

这个写法在批处理下有个问题:三次 setCount(count + 1) 里的 count 都是同一个值——当前渲染时的 count,因为它们都在同一个事件处理函数里,是同步执行的。React 会把这三次更新合并,最终 count 只增加 1。

如果想要每次基于前一次的结果累加,必须用函数式更新:

function handleClick() {
  setCount(prev => prev + 1); // React 会排队处理
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
}

React 内部会维护一个更新队列,按顺序应用每一次 setCount(prev => prev + 1),所以最终 count 增加 3。


React 18 的 flushSync 和 urgent 更新

React 18 引入了更细粒度的优先级概念:urgent 更新和 transition 更新。

默认情况下,所有 setState 都是 urgent 更新。但 startTransition API 可以把一个状态更新标记为 transition 更新,告诉 React 这个更新不需要立刻反映到屏幕上。

import { startTransition } from 'react';

function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value); // urgent:输入框要立刻更新

    startTransition(() => {
      setResults(search(value)); // transition:结果列表可以延迟更新
    });
  }
}

setQuery 会立刻更新用户看到的输入框,setResults 可以稍微延迟,在后台慢慢计算。当用户快速输入时,这种区分让输入保持响应,而复杂的结果渲染不会被频繁的输入打断。


状态更新的优先级

React 内部用 lane 模型来管理更新的优先级。每个 setState 都会被分配一个 lane(可以理解为一种优先级标记),React 根据 lane 决定哪些更新应该先处理。

lane 模型是 Fiber 架构的一部分。Fiber 把渲染工作拆成了可中断的工作单元,每个工作单元有对应的优先级。紧急的用户交互(如点击、输入)有最高优先级,异步的、数据加载类的更新优先级较低。

理解这个机制对调试有帮助。比如一个常见的困惑是:为什么 setState 在某个场景下没有立刻更新 DOM?

答案通常是:那次更新被更高优先级的更新挤到了后面,React 正在处理更紧急的事情。等高优先级任务处理完毕后,低优先级更新才会继续执行。


useTransition 和 useDeferredValue

useTransitionuseDeferredValue 是 React 18 引入的两个 Hook,用来处理并发渲染场景下的优先级问题。

useTransition 返回一个 isPending 标记和一个 startTransition 函数:

function App() {
  const [isPending, startTransition] = useTransition();
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleChange(e) {
    setQuery(e.target.value);

    startTransition(() => {
      setResults(searchResults(e.target.value));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending ? <Loading /> : <ResultsList results={results} />}
    </div>
  );
}

isPending 允许你在 transition 还在进行时显示一个 loading 状态,而用户依然能继续输入——输入框的更新是 urgent 的,结果列表的更新是 transition 的。

useDeferredValue 是另一种方式,用于处理"一个值变化了,但基于这个值的派生计算很重"的场景:

function Search({ query }) {
  const deferredQuery = useDeferredValue(query);
  // deferredQuery 可以稍微落后于 query
  // 可以在这个值变化不剧烈时使用,比如渲染一个很大的列表
  return <HeavyList query={deferredQuery} />;
}

这两个 Hook 的本质都是区分紧急和非紧急更新,让 React 能在并发渲染时保持界面的响应性。


这一章想说的

批处理是 React 的核心性能优化机制之一:多个 setState 在同一个事件处理函数里会合并成一次渲染,而不是各自触发一次。

React 18 把自动批处理扩展到了所有场景,包括原生事件绑定和异步代码。函数式更新 setCount(prev => prev + 1) 是保证在批处理下正确累加的必要方式。

React 18 还引入了 transition 和 urgent 更新的区分。startTransitionuseDeferredValue 让你能告诉 React 哪些更新可以延迟处理,保持用户界面的响应性。这是 Fiber 架构和 lane 模型在应用层的体现。