在 React 中,setState 调用通常是批处理的。但是,当包含 microTask(例如 Promise)的本机事件处理程序中发生 setState 调用时,批处理行为会发生变化。具体来说,如果首先在处理程序的同步部分中进行 setState 调用,然后进行具有状态更新的 microTask 操作,然后在 microTask 后进行另一个 setState 调用,则这些 setState 调用不会按预期进行批处理。 Promise 内部的状态更新不是批量的。
反应版本:18.3.1
重现步骤
我在下面附加了一个代码沙箱来重现该问题。您可以运行代码并观察呈现的计数值,该值与预期的 React 行为不一致。
代码示例链接:
https://stackblitz.com/edit/vitejs-vite-qhrv9o?file=src%2FApp.tsx,src%2FCounter.tsx&terminal=dev
预期行为
Promise 内部的状态更新调用应该是批量的,导致增量点击后计数值为 1。
...然后在 microTask
之后进行另一个setState
调用
这是一个误会。该调用不会在微任务之后进行。
重点是,您在
setCount
回调之外进行的 then
调用都在同一个同步执行流中执行。仅当调用堆栈为空时才会执行 then
回调。只要当前运行的点击处理程序中有要执行的代码,情况就不是这样。
这就是代码中发生的事情的顺序:
countRef.current
,即0setCount
,参数为 0:批量更新。then
方法。提供给它的回调尚未执行。该回调作为微任务排队。countRef.current
,仍然是0,因为之前的更新是批量的。setCount
,再次使用参数 0:此更新也是批处理的。点击处理程序返回,调用堆栈变空。现在,JS 引擎找到了将异步执行批量更新的 React 微任务。这导致
countRef.current
更新为 1。
还没有渲染周期可以执行,因为我们自己的与 Promise 相关的微任务现在轮到执行了:
countRef.current
,现在为1,因为之前的批量更新已经执行了。setCount
,现在使用参数 1:此更新是批量更新。then
回调返回并且调用堆栈变空。 JS 引擎找到另一个 React 微任务来执行新批量的更新(这次只有一个)。这导致 countRef.current
更新为 2。
调用堆栈再次为空,最终执行绘制作业,渲染 2。请注意,您永远不会看到显示 1,因为这是执行单击处理程序后运行的第一个绘制作业。
如果您移动了如下代码,批处理和渲染的行为不会有所不同:
useLayoutEffect(() => {
countRef.current = count;
if (buttonRef.current) {
buttonRef.current.onclick = () => {
console.log('Click handler start');
setCount(countRef.current + 1);
console.log('After first setCount');
setCount(countRef.current + 1); // Moved this block here
console.log('After last setCount');
Promise.resolve().then(() => { // ...and only then this block
setCount(countRef.current + 1);
console.log('Inside Promise');
});
};
}
});
无论您将
then
调用放在单击处理程序主体的开头、中间还是末尾,都不会影响这些 setCount
调用的执行顺序,也不会影响渲染。
你写道:
Promise 内部的状态更新调用应该是批量的,导致增量点击后计数值为 1。
它是批处理的,但是after反应已经清除了由点击处理程序的同步部分收集的批处理。请注意,在该单击处理程序中进行的两次
setCount
调用都是作为同一同步流的一部分执行的。当点击处理程序返回时,JS 引擎开始执行反应作业来处理批量更新。这发生在您自己的 then
回调出队并执行之前。