概述
所谓批量处理就是当在同时更新多个状态下,能够统一批量处理更新,避免了重复渲染。在React17及之前版本,React只会在合成事件以及生命周期内部进行批量处理,在setTimeout、Promise、Fetch等异步请求中,则不会自动批量处理,需要使用unstable_batchedUpdates
API手动处理。而在React18对其进行了优化,不管什么条件下,默认都会批量处理。本文主要就是从demo实例结合bugger源码的方式来解释在React17和18中对于状态批量更新的逻辑介绍。
React17
从概述可知,React17版本,默认只会在合成事件、生命周期内批量处理,在异步请求中需要手动处理,先看下面demo代码:
import React, { Fragment, useState } from 'react';
export default function Component() {
const [a, setA] = useState(1);
console.log('a', a);
// 异步请求,不会自动批量,会渲染多次,该示例中会render4次
function handleClickWithPromise() {
Promise.resolve().then(() => {
setA((a) => a + 1);
setA((a) => a + 1);
setA((a) => a + 1);
setA((a) => a + 1);
});
}
// 绑定点击事件,会自动批量,只会render一次
function handleClickWithoutPromise() {
setA((a) => a + 1);
setA((a) => a + 1);
setA((a) => a + 1);
setA((a) => a + 1);
}
return (
<Fragment>
<button onClick={handleClickWithPromise}>{a} 异步执行</button>
<button onClick={handleClickWithoutPromise}>{a} 同步执行</button>
</Fragment>
);
}
先解释一下上面说的合成事件:React 中的合成事件是 React 自己实现的一套跨浏览器兼容的事件处理机制。它将浏览器原生事件封装为统一的 API,以确保在不同浏览器中的行为一致。通过事件委托和事件池化,React 可以更高效地管理事件监听器,并减少内存开销。合成事件使得开发者能够以一致的方式处理各种用户交互事件,无需关心浏览器之间的差异。即如下图所示:
上面的demo在浏览器中(Chrome为例),点击同步按钮会打印5
,点击异步按钮则会打印2,3,4,5
render4次。
因为React会自动合并,所以只能通过
setA((a) => a + 1)
或者setA(2)
这种具体值的方式才会符合上述,由于React会自动尝试合并操作,如果书写为setA(a + 1)
则只会打印2次(由于 React 在异步上下文中处理状态更新时的行为,React 17 可能会导致组件重新渲染两次
)
接下来我们在浏览器开启debugger来了解其内部逻辑。
同步执行
同步执行下,会自动批量处理,只会render一次。
在bugger下我们点击同步按钮能看到整个函数执行的调用栈,其中主要看标记的几个函数:
其中顶层函数就是我们点击同步按钮执行的回调,然后继续单步调整会发现其进入了React的dispatchAction
来创建一个状态更新任务,然后会调用scheduleUpdateOnFiber
进入Scheduler调度器中等待执行,这个阶段本文不再介绍,有兴趣的可以查看这篇文章:【React Hooks原理 - useState】
直到最后一个状态更新执行完成,会根据当前调用栈往上回调,然后来到标记的第二个函数batchedEventUpdates$1
function batchedEventUpdates$1(fn, a) {
var prevExecutionContext = executionContext;
executionContext |= EventContext;
try {
return fn(a);
} finally {
executionContext = prevExecutionContext;
if (executionContext === NoContext) {
// Flush the immediate callbacks that were scheduled during this batch
resetRenderTimer();
flushSyncCallbackQueue();
}
}
}
当我们在页面点击同步按钮时,就会触发React的合成事件,进而进入上面的函数中,在其中主要做以下事:
- 保存当前上下文,并更新当前上下文为
EventContext
,默认是NoContext
- 执行传入的回调函数
- 回退当前上下文为自身的上下文,并判断是否执行更新
由于是同步执行,所以当状态更新回调执行完后,会进入上面的代码中的finally,此时所有的状态更新都保存在更新队列中的,然后执行flushSyncCallbackQueue
回调,进行批量更新,所以只会render一次。
异步执行
在setTimeout、Promise、Fetch等异步回调中,不会自动批量处理,需要手动使用unstable_batchedUpdates
。如上述所说,在异步条件下,上面的demo会render4次。
从图中能看出,点击按钮时也会经过batchedEventUpdates
函数的封装,并在其设置上下文进去批量更新(同步逻辑),但是在异步情况下,异步回调会在当前任务执行完成之后在执行,执行时已经脱离的设置的批量上下文,所以当进入finally中批量更新时,此时更新队列并没有当前新的更新任务,等到更新任务执行时,此时上下文已经不再是批量上下文,所以会依次执行状态更新而导致重复render。
unstable_batchedUpdates
使用该API可以强制将其中的回调同步执行,可以用于在异步请求中批量处理。其本质就是batchedUpdates$1
函数,所以当在异步请求中将状态更新放在其内部,会批量处理。
通过bugger也能发现,其实际还是执行的batchedUpdates$1
函数,逻辑和同步一致,通过设置上下文然后调用flushSyncCallbackQueue()
批量处理更新任务,区别就是由于其仍然处于异步回调用,所以执行时机仍然会延迟,等待同步代码执行完成之后执行。
总结
在点击按钮触发状态更新时,实际触发的是经过batchedUpdates$1
处理的合成事件。同步代码中在状态更新时将更新任务添加到队列中(此时上下文已经更新为批量上下文),最后在finally
中执行flushSyncCallbackQueue
批量更新状态。而在异步回调中会脱离批量上下文,通过使用unstable_batchedUpdates
包裹,收到执行batchedUpdates$1
函数,在执行时重新设置批量上下文,并调用flushSyncCallbackQueue
批量更新,本质还是通过batchedUpdates$1
函数执行批量,无非一个是自动一个是手动的区别。
React18
在React18之后,主要新增了并发特性和对批量更新进行了优化,不管在异步还是同步回调中都默认进行批量处理。下面同样使用上面的demo代码,在Chrome下不管点击同步还是异步按钮都只会render一次。下面在React18环境下进行bugger流程介绍。
同步执行
在浏览器调试我们知道,在React17中当对状态进行更新的时会通过dispatchAction
调用scheduleUpdateOnFiber
等待调度更新,而在18中对其进行了优化,不会直接调度更新,而是在dispatchSetState
中通过enqueueConcurrentHookUpdate
将状态更新添加到等待执行的队列中,待执行完成之后再统一批量更新。
同React17,在18中状态更新回调执行完成之后,会回到batchedUpdates$1
函数,此时所有的更新任务都以链表的方式保存在队列中。
function batchedUpdates$1(fn, a) {
var prevExecutionContext = executionContext;
executionContext |= BatchedContext;
try {
return fn(a);
} finally {
executionContext = prevExecutionContext; // If there were legacy sync updates, flush them at the end of the outer
// most batchedUpdates-like method.
if (executionContext === NoContext && // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
!( ReactCurrentActQueue$1.isBatchingLegacy)) {
resetRenderTimer();
flushSyncCallbacksOnlyInLegacyMode();
}
}
}
如上面所说,该函数主要就是设置批量上下文,执行传入的更新回调,然后在finally
中通过flushSyncCallbacksOnlyInLegacyMode
函数然后执行flushSyncCallbacks
同步更新状态。
function flushSyncCallbacksOnlyInLegacyMode() {
// Only flushes the queue if there's a legacy sync callback scheduled.
// TODO: There's only a single type of callback: performSyncOnWorkOnRoot. So
// it might make more sense for the queue to be a list of roots instead of a
// list of generic callbacks. Then we can have two: one for legacy roots, one
// for concurrent roots. And this method would only flush the legacy ones.
if (includesLegacySyncCallbacks) {
flushSyncCallbacks();
}
}
异步执行
React18对其优化之后,在异步请求中默认也会自动批量处理。和同步时一样也会通过enqueueConcurrentHookUpdate
将更新任务添加到更新队列中,并不会直接调度更新。当执行到最后一个状态更新的setState
时,会进入ensureRootIsScheduled
的这块逻辑进入微任务的处理(其他setState
会在上面就return,不会进入该逻辑):
scheduleMicrotask(function () {
// In Safari, appending an iframe forces microtasks to run.
// https://github.com/facebook/react/issues/22459
// We don't support running callbacks in the middle of render
// or commit so we need to check against that.
if ((executionContext & (RenderContext | CommitContext)) === NoContext) {
// Note that this would still prematurely flush the callbacks
// if this happens outside render or commit phase (e.g. in an event).
flushSyncCallbacks();
}
});
在其中会执行flushSyncCallbacks
函数统一处理更新队列中的任务,最后只渲染一次。所以在React18中不管是同步还是异步,都是先将更新任务保存在待执行队列中,最后都是通过flushSyncCallbacks
来批量处理状态更新的。
总结
同步更新: 状态更新任务入队并在 flushSyncCallbacks 中被批量处理。
异步更新: 状态更新任务同样入队,但在异步任务完成后,通过微任务调度机制调用 flushSyncCallbacks 来批量处理这些任务。