react 18 的自动批量更新为何失效?

export default function App() {
  const [v, setV] = useState(0);

  const handleClick = async () => {
    console.log(0);
    setV((v) => v + 1);
    Promise.resolve().then(() => {
      console.log(1);
      setV((v) => v + 1);
      console.log(2);
      Promise.resolve().then(() => {
        console.log(3);
      });
    });
  };
  const handleClick2 = async () => {
    console.log(0);
    Promise.resolve().then(() => {
      console.log(1);
      setV((v) => v + 1);
      console.log(2);
      Promise.resolve().then(() => {
        console.log(3);
      });
    });
    setV((v) => v + 1);
  };

  console.log("render");

  return (
    <div className="App">
      <h1 onClick={handleClick}>Hello CodeSandbox</h1>
      <h2>value: {v}</h2>
    </div>
  );
}

执行两个click方法,分别打印
handleClick:
image.png
handleClick2
image.png

为何handleClick中自动批量更新没有生效呢

阅读 10.6k
2 个回答

debug了一下,原因在于 handleClick 先调用了 setVcommit阶段 前置了导致的,下面详细拆解一下

首先需要知道,setV 其实就是 dispatchSetState, 它有两个核心动作

  • 同步更新内存中的状态值 v
  • 通过 queueMicroTask 派发一个异步调度任务,commit阶段 就包含在其中

而批量合并的条件是,需要在上一个 setV 派发的 commit阶段 执行之前,后续 setV 的同步阶段需要执行完毕,将内存中的状态更新,这样就能共用同一个 commit阶段

handleClick

分析一下就会发现,handleClick 的执行过程显然不满足这个条件

// 简化一下
const handleClick = async () => {
    setV((v) => v + 1);
    Promise.resolve().then(() => {
      setV((v) => v + 1);
    });
}

直接描述会比较抽象,直接看图,handleClick 中的同步代码执行完后,结果如下

image.png

这种场景下,清空微队列时,第一个 setVcommit阶段 最先执行,无法与后续的更新合并

handleClick2

// 简化一下
const handleClick2 = async () => {
    Promise.resolve().then(() => {
      setV((v) => v + 1);
    });
    setV((v) => v + 1);
}

handleClick2 中的同步代码执行完后,结果如下

image.png

在这种情况下,第一个 setVcommit阶段 被放置在了微队列的队尾,当清空微队列,执行到队尾时,后续 setV 的同步逻辑已经执行完毕,内存中的状态值已经更新,这样一来就能在同一个 commit阶段 完成更新了

分析 handleClickhandleClick2 的执行顺序

handleClick 函数

const handleClick = async () => {
  console.log(0);
  setV((v) => v + 1); // 状态更新1
  Promise.resolve().then(() => {
    console.log(1);
    setV((v) => v + 1); // 状态更新2
    console.log(2);
    Promise.resolve().then(() => {
      console.log(3);
    });
  });
};

执行顺序

  1. 主任务

    • console.log(0) 输出 0
    • setV((v) => v + 1) 触发状态更新1,但不会立即生效,而是进入更新队列。
    • Promise.resolve().then 进入微任务队列。
  2. 微任务

    • 当前宏任务执行完毕,进入微任务队列,执行 console.log(1)
    • setV((v) => v + 1) 触发状态更新2。
    • 输出 2
    • 再次进入微任务队列,输出 3

由于状态更新1和状态更新2发生在不同的事件循环中,React 无法将它们批处理在一起,因此会触发两次重新渲染。

handleClick2 函数

const handleClick2 = async () => {
  console.log(0);
  Promise.resolve().then(() => {
    console.log(1);
    setV((v) => v + 1);
    console.log(2);
    Promise.resolve().then(() => {
      console.log(3);
    });
  });
  setV((v) => v + 1);
};

执行顺序

  1. 主任务

    • console.log(0) 输出 0
    • Promise.resolve().then 进入微任务队列。
    • setV((v) => v + 1) 触发状态更新1,但不会立即生效,而是进入更新队列。
  2. 微任务

    • 当前宏任务执行完毕,进入微任务队列,执行 console.log(1)
    • setV((v) => v + 1) 触发状态更新2。
    • 输出 2
    • 再次进入微任务队列,输出 3

总结

  • handleClick:批量更新失效,因为状态更新发生在不同的事件循环中,React 无法将它们批处理在一起。
  • handleClick2:批量更新有效,因为状态更新虽然发生在不同的事件循环中,但由于 Promise 的微任务队列执行顺序,React 仍然能够批处理这些更新。

解决

方法一:将所有状态更新放在同一个微任务中

const handleClick = async () => {
  console.log(0);
  setV((v) => v + 1);
  await Promise.resolve();
  console.log(1);
  setV((v) => v + 1);
  console.log(2);
  await Promise.resolve();
  console.log(3);
};

优点

  • 简单直接,不需要额外的库或函数。
  • 保持了代码的异步特性。

缺点

  • 需要在每次状态更新前后添加 await Promise.resolve(),可能会使代码显得冗长。

方法二:使用 ReactDOM.flushSync 强制同步更新

import { flushSync } from 'react-dom';

const handleClick = async () => {
  console.log(0);
  flushSync(() => setV((v) => v + 1));
  Promise.resolve().then(() => {
    console.log(1);
    flushSync(() => setV((v) => v + 1));
    console.log(2);
    Promise.resolve().then(() => {
      console.log(3);
    });
  });
};

优点

  • 强制同步更新,确保批量更新生效。
  • 代码更简洁,不需要在每次状态更新前后添加 await Promise.resolve()

缺点

  • 需要引入 flushSync,增加了对 React DOM 的依赖。
  • 可能会影响性能,因为强制同步更新会阻塞渲染。

结论

  • 方法一:如果希望保持代码的异步特性并且不介意稍微冗长的代码,可以选择这种方法。
  • 方法二:如果更注重代码简洁性和确保批量更新生效,可以选择这种方法。

补充

JavaScript 事件循环说明和示例解析:

JavaScript 事件循环

JavaScript 使用事件循环来处理异步操作,通过将任务分为两类:宏任务(macro task)和微任务(micro task)。

宏任务(Macro Task)

宏任务是较大的任务,常见的例子包括:

  • setTimeout
  • setInterval
  • I/O 操作

微任务(Micro Task)

微任务是较小的任务,常见的例子包括:

  • Promise 回调
  • MutationObserver

执行顺序

  1. 执行一个宏任务。
  2. 执行所有的微任务。
  3. 更新渲染。
  4. 重复上述步骤。

示例

console.log('start');

setTimeout(() => {
  console.log('macro task1');
}, 0);

Promise.resolve().then(() => {
  console.log('micro task2');
});

console.log('end');

执行顺序

  1. console.log('start') 立即执行,输出 start

    • 输出:start
  2. setTimeout 是一个宏任务,放入宏任务队列,等待当前任务完成后执行。
  3. Promise.resolve().then 是一个微任务,放入微任务队列,等待当前任务完成后执行。
  4. console.log('end') 立即执行,输出 end

    • 输出:end
  5. 当前宏任务完成,开始执行微任务队列中的任务,输出 micro task2

    • 输出:micro task2
  6. 微任务队列清空后,开始执行宏任务队列中的任务,输出 macro task1

    • 输出:macro task1

所以最终的输出顺序是:

  1. start
  2. end
  3. micro task2
  4. macro task1

这种执行顺序确保了微任务能够在宏任务之前完成,从而保持事件循环的高效运转。这也解释了为什么在Promise中的状态更新会影响批量更新机制的行为。

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题
宣传栏