相信大家对于react的setState肯定是不陌生了, 这是一个用于更新状态的函数. 但是在之前有一道非常经典的面试题就是关于setState是同步还是异步的问题, 具体可以参考我之前写的一篇文章: 一篇文章彻底理解setState是同步还是异步!. 对于react 18之前的版本, 上文说的东西确实没错, 但是react团队已经在18中对批处理的行为做了更改, 会尽可能的将所有能进行批处理的内容都进行批处理, 以获取更好的性能, 今天我们就来聊聊react 18中对这一行为做了哪些更改.
我们都用下面这段代码在不同版本的react上进行测试
class App extends React.Component {
state = {
data: 1
}
test = () => {
setTimeout(() => {
this.setState({data: 2});
console.log('data', this.state.data);
this.setState({data: 3});
console.log('data', this.state.data);
}, 0);
}
render() {
console.log("render");
return (
)
}
}
大家觉得输出的data值会是什么, 这个render又会打印几次?
在react 17.x下, 我们能够同步的获取到data的值, 所以输出会是2, 3.同时也会经历两次react的整个render过程, 所以也会导致render被打印两次, 这都是因为setTimeout带来的影响, 具体的解释可以参考之前的文章.
而在最新版的react 18上, 这两个setData也会被异步的批处理, 合并为一次进行更新, 所以我们拿到的值始终是1, render函数也只会被打印一次.
官方的说明在这里: https://github.com/reactwg/react-18/discussions/21
总结一下就是:
从react 18开始, 使用了createRoot创建应用后, 所有的更新都会自动进行批处理(也就是异步合并).使用render的应用会保持之前的行为.
如果你想保持同步更新行为, 可以使用ReactDOM.flushSync().
// 新版
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
const container = document.getElementById('root');
// Create a root.
const root = ReactDOM.createRoot(container);
// Render the top component to the root.
root.render( );
// 旧版
// import React from 'react';
// import ReactDOM from 'react-dom';
// import './index.css';
// import App from './App';
// import reportWebVitals from './reportWebVitals';
// ReactDOM.render(
//
//
// ,
// document.getElementById('root')
// );
// // If you want to start measuring performance in your app, pass a function
// // to log results (for example: reportWebVitals(console.log))
// // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
// reportWebVitals();
至于为什么会这样, 我们直接从源码入手就好了.
我们知道, react的setState最终会走到scheduleUpdateOnFiber来进行更新, 之前最关键的一段代码就在这里
// react 17.x
if (executionContext === NoContext) {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
resetRenderTimer();
flushSyncCallbackQueue();
}
executionContext代表了react当前的调度状态, 如果退出了react的调度这个值就会重新变成NoContext. 也就是说, 如果你调用setState的时候并不处于react的调度状态中, 那么就会同步的去执行你的setState.这也是为什么一旦我们使用一些异步操作就会导致setState变成同步的原因, 而在react 18中这段代码变成了这样
// react 18.x
if (
lane === SyncLane &&
executionContext === NoContext &&
(fiber.mode & ConcurrentMode) === NoMode &&
// Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
!( ReactCurrentActQueue$1.isBatchingLegacy)) {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
resetRenderTimer();
flushSyncCallbacksOnlyInLegacyMode();
}
可以看到我们多出了好几个判断条件, 除了之前的 executionContext === NoContext 之外, 还多了三个判断条件, 我们一个一个来看看.
这个其实没什么好说的, lane是react中和优先级有关的概念, 从函数命名就可以看出, 只有是同步任务才会进到这个if.
var TotalLanes = 31;
var NoLanes =
/* */
0;
var NoLane =
/* */
0;
var SyncLane =
/* */
1;
var InputContinuousHydrationLane =
/* */
2;
var InputContinuousLane =
/* */
4;
var DefaultHydrationLane =
/* */
8;
var DefaultLane =
/* */
16;
var TransitionHydrationLane =
/* */
32;
var TransitionLanes =
/* */
4194240;
var TransitionLane1 =
/* */
64;
var TransitionLane2 =
/* */
128;
var TransitionLane3 =
/* */
256;
var TransitionLane4 =
/* */
512;
var TransitionLane5 =
/* */
1024;
var TransitionLane6 =
/* */
2048;
var TransitionLane7 =
/* */
4096;
var TransitionLane8 =
/* */
8192;
var TransitionLane9 =
/* */
16384;
var TransitionLane10 =
/* */
32768;
var TransitionLane11 =
/* */
65536;
var TransitionLane12 =
/* */
131072;
var TransitionLane13 =
/* */
262144;
var TransitionLane14 =
/* */
524288;
var TransitionLane15 =
/* */
1048576;
var TransitionLane16 =
/* */
2097152;
var RetryLanes =
/* */
130023424;
var RetryLane1 =
/* */
4194304;
var RetryLane2 =
/* */
8388608;
var RetryLane3 =
/* */
16777216;
var RetryLane4 =
/* */
33554432;
var RetryLane5 =
/* */
67108864;
var SomeRetryLane = RetryLane1;
var SelectiveHydrationLane =
/* */
134217728;
var NonIdleLanes =
/* */
268435455;
var IdleHydrationLane =
/* */
268435456;
var IdleLane =
/* */
536870912;
var OffscreenLane =
/* */
1073741824; // This function is used for the experimental timeline (react-devtools-timeline)
// It should be kept in sync with the Lanes values above.
这一堆东西就代表了react内部各种不同优先级的任务.
react fiber的mode属性代表了不同的渲染模式
var NoMode =
/* */
0; // TODO: Remove ConcurrentMode by reading from the root tag instead
var ConcurrentMode =
/* */
1;
var ProfileMode =
/* */
2;
var DebugTracingMode =
/* */
4;
var StrictLegacyMode =
/* */
8;
var StrictEffectsMode =
/* */
16;
具体是什么意思呢, 比如说ProfileMode代表了性能调试模式, 我们的开发环境的mode就会被赋予这个值, 可以在控制台中给开发者输出一些提示信息.而其他的值是啥意思呢..其实我目前也不能很明白的给大家说清楚, 因为我也没研究过, 但是只要知道这是个区分不同模式的变量就行了.
而这个值会在什么地方赋给fiber的mode属性呢, 会在我们整个应用的入口, 然后经过一系列的函数调用, 最终会走到createHostRootFiber方法
function createHostRootFiber(tag, isStrictMode, concurrentUpdatesByDefaultOverride) {
var mode;
if (tag === ConcurrentRoot) {
mode = ConcurrentMode;
if (isStrictMode === true) {
mode |= StrictLegacyMode;
{
mode |= StrictEffectsMode;
}
}
} else {
mode = NoMode;
}
if ( isDevToolsPresent) {
// Always collect profile timings when DevTools are present.
// This enables DevTools to start capturing timing at any point–
// Without some nodes in the tree having empty base times.
mode |= ProfileMode;
}
return createFiber(HostRoot, null, null, mode);
}
其中tag的值就是根据入口函数一路传下来的. 我们之前提到, 要想体验自动批处理需要在应用入口将ReactDOM.render替换为ReactDOM.createRoot, 这两者有什么区别呢:
function render(element, container, callback) {
// 省略...
return legacyRenderSubtreeIntoContainer(null, element, container, false, callback);
}
function legacyRenderSubtreeIntoContainer(parentComponent, children, container, forceHydrate, callback) {
// 省略...
if (!root) {
// Initial mount
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container, forceHydrate);
fiberRoot = root;
// 省略...
} else {
// 省略...
}
return getPublicRootInstance(fiberRoot);
}
function legacyCreateRootFromDOMContainer(container, forceHydrate) {
// 省略...
var root = createContainer(container, LegacyRoot, forceHydrate, null, // hydrationCallbacks
false, // isStrictMode
false, // concurrentUpdatesByDefaultOverride,
'' // identiferPrefix
);
// 省略...
return root;
}
function createContainer(containerInfo, tag, hydrate, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix) {
return createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix);
}
function createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix) {
// 省略...
var uninitializedFiber = createHostRootFiber(tag, isStrictMode);
root.current = uninitializedFiber;
uninitializedFiber.stateNode = root;
// 省略...
return root;
}
function createHostRootFiber(tag, isStrictMode, concurrentUpdatesByDefaultOverride) {
var mode;
if (tag === ConcurrentRoot) {
mode = ConcurrentMode;
if (isStrictMode === true) {
mode |= StrictLegacyMode;
{
mode |= StrictEffectsMode;
}
}
} else {
mode = NoMode;
}
if ( isDevToolsPresent) {
// Always collect profile timings when DevTools are present.
// This enables DevTools to start capturing timing at any point–
// Without some nodes in the tree having empty base times.
mode |= ProfileMode;
}
return createFiber(HostRoot, null, null, mode);
}
我这里把从ReactDOM.render到createHostRootFiber的全部函数都放在了这里, 可以看到最终createHostRootFiber拿到的tag值其实是LegacyRoot, 所以mode最终会等于NoMode. 随后如果在开发环境下还会被赋予一个ProfileMode, 也就是我们之前说的调试模式.
所以即使我们升级到了18.x的版本, 但是如果仍然使用ReactDOM.render来创建我们的应用, 我们就会在这个条件得到false
(fiber.mode & ConcurrentMode) === NoMode
因为我们的fiber.mode并没有被赋值为ConcurrentMode.而当我们使用ReactDOM.createRoot来创建应用时, 也是一路跟着函数找下去, 会发现tag最后拿到的是ConcurrentRoot, 也就是说mode会被赋值为ConcurrentMode, 所以也就是为什么只有使用了ReactDOM.createRoot的应用才会有该特性的原因.
ps: mode是可以被赋值为多个值的, 区分这多个值是通过&操作和|操作, 因为mode其实是一个二进制值, 不理解这块的同学可以再想想二进制值的&、|操作.
其实这个条件注释已经写的很清楚了
// Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
就是在act函数调用的时候也进行同步更新, act是个啥玩意呢.我们直接抄一下官网的介绍:
在编写UI测试时,可以将渲染、用户事件或数据获取等任务视为与用户界面交互的“单元”。react-dom/test-utils提供了一个名为act()的 helper, 它确保在进行任何断言之前, 与这些“单元”相关的所有更新都已处理并应用于DOM:
act(() => {
// 渲染组件
});
// 进行断言
这有助于使测试运行更接近真实用户在使用应用程序时的体验。这些示例的其余部分使用act()来作出这些保证。
而isBatchingLegacy也正是在act函数中被赋值为true
function act(callback) {
{
// `act` calls can be nested, so we track the depth. This represents the
// number of `act` scopes on the stack.
var prevActScopeDepth = actScopeDepth;
actScopeDepth++;
if (ReactCurrentActQueue.current === null) {
// This is the outermost `act` scope. Initialize the queue. The reconciler
// will detect the queue and use it instead of Scheduler.
ReactCurrentActQueue.current = [];
}
var prevIsBatchingLegacy = ReactCurrentActQueue.isBatchingLegacy;
var result;
try {
// Used to reproduce behavior of `batchedUpdates` in legacy mode. Only
// set to `true` while the given callback is executed, not for updates
// triggered during an async event, because this is how the legacy
// implementation of `act` behaved.
ReactCurrentActQueue.isBatchingLegacy = true;
result = callback(); // Replicate behavior of original `act` implementation in legacy mode,
// which flushed updates immediately after the scope function exits, even
// if it's an async function.
if (!prevIsBatchingLegacy && ReactCurrentActQueue.didScheduleLegacyUpdate) {
var queue = ReactCurrentActQueue.current;
if (queue !== null) {
ReactCurrentActQueue.didScheduleLegacyUpdate = false;
flushActQueue(queue);
}
}
} catch (error) {
popActScope(prevActScopeDepth);
throw error;
} finally {
ReactCurrentActQueue.isBatchingLegacy = prevIsBatchingLegacy;
}
//...省略
}
}
所以, 在升级到18版本之后的react只有在你使用ReactDOM.render的时候(LegacyMode)才会保持之前的行为, 否则都会对你的更新进行合并处理, 也就是自动批处理. 从我们最后调用的函数名也能看出这一点: flushSyncCallbacksOnlyInLegacyMode
我们之前还提到, 如果我们想进行同步更新可以使用flushSync函数, 那么它又干了啥.
function flushSync(fn) {
// In legacy mode, we flush pending passive effects at the beginning of the
// next event, not at the end of the previous one.
if (rootWithPendingPassiveEffects !== null && rootWithPendingPassiveEffects.tag === LegacyRoot && (executionContext & (RenderContext | CommitContext)) === NoContext) {
flushPassiveEffects();
}
var prevExecutionContext = executionContext;
executionContext |= BatchedContext;
var prevTransition = ReactCurrentBatchConfig$3.transition;
var previousPriority = getCurrentUpdatePriority();
try {
ReactCurrentBatchConfig$3.transition = 0;
setCurrentUpdatePriority(DiscreteEventPriority);
if (fn) {
return fn();
} else {
return undefined;
}
} finally {
setCurrentUpdatePriority(previousPriority);
ReactCurrentBatchConfig$3.transition = prevTransition;
executionContext = prevExecutionContext; // Flush the immediate callbacks that were scheduled during this batch.
// Note that this will happen even if batchedUpdates is higher up
// the stack.
if ((executionContext & (RenderContext | CommitContext)) === NoContext) {
flushSyncCallbacks();
}
}
}
可以看到, 这个函数会在执行完传给他的fn函数后马上去清空一次更新队列, 也就是调用flushSyncCallbacks方法, 就是我们之前在异步中调用setState的行为.
值得一提的是, 如果我们在react 18中想达到之前的效果, 这样写是不行的:
import React from 'react';
import { flushSync } from 'react-dom';
class App extends React.Component {
state = {
data: 1
}
test = () => {
setTimeout(() => {
flushSync(() => {
this.setState({data: 2});
console.log('data', this.state.data);
this.setState({data: 3});
console.log('data', this.state.data);
});
}, 0);
}
render() {
console.log("render");
return (
)
}
}
export default App;
这样写两个setState还是会被合并为同一个, 因为调用完setState之后并不会马上去刷新更新队列, 只有在整个函数执行完以后才会对队列进行刷新. 所以如果两个setState都写在一个flushSync里面是没有效果的.要想达到之前的效果需要这样写:
import React from 'react';
import { flushSync } from 'react-dom';
class App extends React.Component {
state = {
data: 1
}
test = () => {
setTimeout(() => {
flushSync(() => {
this.setState({data: 2});
});
console.log('data', this.state.data);
flushSync(() => {
this.setState({data: 3});
});
console.log('data', this.state.data);
}, 0);
}
render() {
console.log("render");
return (
)
}
}
ps: 我们一直说的同步异步并不是指setState本身, setState本身一直一个同步函数, 我们指的是调用完setState后react会同步的去执行后续的步骤还是会异步的去执行后续的步骤.
react官方做出这个改变其实也是为了更好的性能去考虑的, 毕竟调用完setState之后同步的进行渲染有时候会导致很多没必要的开销, 特别是在进行数据请求时, 很容易写出多个同步的setState.
而随着这波更新, 可能以后这道经典的面试题也会随之消散, 毕竟往后不论在什么情况下, 默认行为都会帮你对setState进行合并更新, 不再会进行同步处理了.