Skip to content

深入理解 setState 批量更新与自动批处理

同步/异步批处理机制、React 18 自动批处理原理、flushSync、更新队列合并策略

什么是批量更新?

定义:批量更新(Batching)是指 React 将多次 setState 合并为一次渲染的优化机制。如果没有批处理,每次 setState 都会触发一次完整的渲染流程,严重影响性能。

涉及场景

  • 事件处理器:一个 onClick 中调用多次 setState
  • 异步操作:setTimeout、Promise.then、fetch 回调中的 setState
  • 生命周期/Effect:useEffect / useLayoutEffect 中的多次 setState
  • 第三方库:事件监听回调中的 setState

一、React 17 的批处理行为

同步上下文:自动批处理 ✅

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

  function handleClick() {
    setCount(c => c + 1);  // 不立即渲染
    setFlag(f => !f);       // 不立即渲染
    // 退出事件处理器后,React 统一执行一次渲染
  }
  // 结果:只渲染 1 次 ✅

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

异步上下文:不批处理 ❌(React 17)

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

  function handleClick() {
    // React 17:异步回调中每次 setState 都立即渲染
    fetch('/api/data').then(() => {
      setCount(c => c + 1);  // 渲染第 1 次
      setFlag(f => !f);       // 渲染第 2 次
    });

    setTimeout(() => {
      setCount(c => c + 1);  // 渲染第 1 次
      setFlag(f => !f);       // 渲染第 2 次
    }, 0);
  }
  // 结果:渲染 2 次 ❌(性能浪费)
}

React 17 批处理的原理

javascript
// React 17 使用一个全局标志控制批处理
let isBatchingUpdates = false;

// 事件处理器执行前
function dispatchEvent(event) {
  isBatchingUpdates = true;  // 开启批处理
  try {
    handler(event);           // 执行用户代码(其中的 setState 被收集)
  } finally {
    isBatchingUpdates = false; // 关闭批处理
    flushUpdates();            // 统一执行渲染
  }
}

// setState 内部
function setState(update) {
  enqueueUpdate(fiber, update); // 将更新加入队列
  if (isBatchingUpdates) {
    return; // 批处理中,先不渲染
  }
  // 非批处理(如 setTimeout 回调),立即渲染
  scheduleUpdateOnFiber(fiber);
}

// 问题:setTimeout/Promise 回调不在 dispatchEvent 的 try 块中
// isBatchingUpdates 已经是 false → setState 立即触发渲染

二、React 18 的自动批处理

所有场景都自动批处理 ✅

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

  function handleClick() {
    // 事件处理器 — 批处理 ✅(和 17 一样)
    setCount(c => c + 1);
    setFlag(f => !f);
    // 1 次渲染
  }

  function handleAsync() {
    // setTimeout — 批处理 ✅(React 18 新增)
    setTimeout(() => {
      setCount(c => c + 1);
      setFlag(f => !f);
      // 1 次渲染(React 17 是 2 次)
    }, 0);

    // Promise — 批处理 ✅(React 18 新增)
    fetch('/api').then(() => {
      setCount(c => c + 1);
      setFlag(f => !f);
      // 1 次渲染(React 17 是 2 次)
    });

    // 原生事件 — 批处理 ✅(React 18 新增)
    ref.current.addEventListener('click', () => {
      setCount(c => c + 1);
      setFlag(f => !f);
      // 1 次渲染(React 17 是 2 次)
    });
  }
}

React 18 自动批处理的原理

javascript
// React 18 不再依赖 isBatchingUpdates 全局标志
// 改用 Lane 模型 + 微任务调度

function setState(update) {
  // 1. 将更新加入 Fiber 的 updateQueue
  enqueueUpdate(fiber, update);

  // 2. 标记更新的 Lane
  markUpdateLaneFromFiberToRoot(fiber, lane);

  // 3. 调度更新(关键:不是立即执行,而是调度到微任务)
  ensureRootIsScheduled(root);
}

function ensureRootIsScheduled(root) {
  // 检查是否已经调度了一个渲染任务
  if (existingCallbackNode !== null) {
    // 已有调度任务,不重复调度
    // 多次 setState 复用同一个调度任务 → 批处理!
    return;
  }

  // 首次调度:通过微任务(queueMicrotask / MessageChannel)
  const newCallbackNode = scheduleCallback(priority, performWork);
  root.callbackNode = newCallbackNode;
}

// 核心机制:
// 同一个事件循环中的多次 setState
// → 每次都调用 ensureRootIsScheduled
// → 但只有第一次会创建调度任务
// → 微任务执行时,一次性处理所有排队的更新
// → 只渲染一次

为什么 React 18 能做到全场景批处理?

React 17:
  isBatchingUpdates 是一个同步标志
  setTimeout 回调执行时,标志已经关闭
  → 无法批处理

React 18:
  调度基于微任务(microtask)
  同一事件循环中的所有 setState → 同一个微任务中处理
  无论在 onClick、setTimeout、Promise.then 中
  → 全部都能批处理

关键区别:
  React 17 = 同步标志控制(只在特定上下文中生效)
  React 18 = 异步调度控制(在事件循环级别生效)

三、flushSync —— 退出批处理

jsx
import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCount(c => c + 1);
  });
  // 这里 DOM 已经更新了!count 已经是新值

  flushSync(() => {
    setFlag(f => !f);
  });
  // 这里 DOM 再次更新

  // 结果:渲染了 2 次
}

flushSync 的原理

javascript
function flushSync(fn) {
  // 1. 将更新优先级提升为 SyncLane(最高)
  const previousPriority = getCurrentPriority();
  setCurrentPriority(SyncLane);

  try {
    fn(); // 执行用户代码,其中的 setState 标记为 SyncLane
  } finally {
    setCurrentPriority(previousPriority);
  }

  // 2. 立即同步执行渲染(不等微任务)
  flushSyncCallbacks();
  // 此时 DOM 已更新
}

使用场景

jsx
// 场景1:需要在下一行读取更新后的 DOM
function handleClick() {
  flushSync(() => setOpen(true));
  // DOM 已更新,dialog 已显示
  dialogRef.current.focus(); // 安全地聚焦
}

// 场景2:强制分开渲染(罕见)
function handleClick() {
  flushSync(() => setLoading(true));
  // 先显示 loading
  flushSync(() => setData(newData));
  // 再更新数据
}

// ⚠️ 尽量少用 flushSync,它会破坏批处理的性能优势

四、更新队列合并策略

函数式更新 vs 值更新

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

  function handleClick() {
    // ❌ 值更新:多次相同值会被合并
    setCount(count + 1);  // 基于闭包中的 count=0 → 1
    setCount(count + 1);  // 基于闭包中的 count=0 → 1
    setCount(count + 1);  // 基于闭包中的 count=0 → 1
    // 结果:count = 1(不是 3)

    // ✅ 函数式更新:每次基于最新值计算
    setCount(c => c + 1); // 0 → 1
    setCount(c => c + 1); // 1 → 2
    setCount(c => c + 1); // 2 → 3
    // 结果:count = 3
  }
}

更新队列的处理流程

javascript
// Fiber 节点的 updateQueue
fiber.updateQueue = {
  pending: null,  // 环形链表,存储所有待处理的 update
  // update = { action, next, lane }
};

// 处理更新队列
function processUpdateQueue(fiber) {
  let update = firstUpdate;
  let newState = fiber.memoizedState;

  do {
    if (isSubsetOfLanes(renderLanes, update.lane)) {
      if (typeof update.action === 'function') {
        // 函数式更新:用上一次的 newState 作为参数
        newState = update.action(newState);
      } else {
        // 值更新:直接替换
        newState = update.action;
      }
    }
    update = update.next;
  } while (update !== firstUpdate);

  fiber.memoizedState = newState;
}

// 示例:
// setCount(1) → update = { action: 1 }
// setCount(c => c + 1) → update = { action: (c) => c + 1 }
//
// 队列 [action: 1, action: (c) => c + 1]
// 处理:newState = 1 → newState = 1 + 1 = 2

优先级与更新跳过

javascript
// 如果某个 update 的 Lane 不在当前 renderLanes 中,会被跳过

// 场景:
// SyncLane 的 update: setCount(1)
// TransitionLane 的 update: setCount(2)

// 第一次渲染(SyncLane):
//   处理 setCount(1),跳过 setCount(2)
//   → count = 1

// 第二次渲染(TransitionLane):
//   从跳过点开始重新处理
//   → count = 2(最终一致)

五、常见陷阱

陷阱1:闭包中的旧值

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

  function handleClick() {
    // ❌ 三次都基于闭包中的 count(0)
    setCount(count + 1); // 0 + 1 = 1
    setCount(count + 1); // 0 + 1 = 1
    setCount(count + 1); // 0 + 1 = 1

    // ✅ 使用函数式更新
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1);
  }
}

陷阱2:认为 setState 后立即能读到新值

jsx
function handleClick() {
  setCount(42);
  console.log(count); // 还是旧值!(闭包捕获)

  // 正确方式1:使用 useEffect 监听
  // 正确方式2:在下一次渲染中读取
  // 正确方式3:flushSync 后读 DOM
}

陷阱3:在渲染期间 setState

jsx
// ❌ 无限循环
function Bad() {
  const [count, setCount] = useState(0);
  setCount(count + 1); // 渲染期间 setState → 立即重渲染 → 无限循环
  return <div>{count}</div>;
}

// ✅ 渲染期间的条件 setState(React 允许,用于派生状态)
function DerivedState({ items }) {
  const [prevItems, setPrevItems] = useState(items);
  const [selected, setSelected] = useState(null);

  if (items !== prevItems) {
    setPrevItems(items);
    setSelected(null); // items 变化时重置选择
    // React 检测到渲染期间 setState,立即重新渲染(不提交当前渲染)
  }
}

面试高频题

Q: React 18 的自动批处理和 React 17 有什么区别?

React 17 只在 React 事件处理器(onClick 等)中自动批处理。setTimeout、Promise、原生事件中的多次 setState 各自触发独立渲染。React 18 通过将调度改为微任务级别,实现了所有场景的自动批处理——同一事件循环中的所有 setState 合并为一次渲染。

Q: 连续调用 3 次 setCount(count + 1) 结果是多少?

结果是 count + 1(而非 +3)。因为 3 次调用都基于闭包中捕获的同一个 count 值,计算结果相同(0 + 1 = 1)。要实现 +3,需要使用函数式更新 setCount(c => c + 1),每次基于前一次的最新值计算。

Q: flushSync 的作用是什么?什么时候用?

flushSync 强制 React 同步执行包裹的 setState 并立即更新 DOM,跳出批处理。使用场景:需要在 setState 后的下一行代码中读取已更新的 DOM(如设置焦点、测量尺寸)。应尽量少用,因为它会破坏批处理的性能优势。

Q: React 18 自动批处理的底层原理是什么?

React 18 将渲染调度改为微任务(通过 queueMicrotaskMessageChannel)。多次 setState 都会调用 ensureRootIsScheduled,但只有第一次会创建调度任务,后续的复用同一个任务。当微任务执行时,一次性处理所有排队的更新,只渲染一次。