React18中setState是同步还是异步?

2 阅读6分钟

react团队已经在react18中对批处理的行为做了更改, 会尽可能的将所有能进行批处理的内容都进行批处理, 以获取更好的性能, 今天我们就来聊聊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 (
      <div>
        <button onClick={this.test}>{this.state.data}</button>
      </div>
    )
  }
}

大家觉得输出的data值会是什么, 这个render又会打印几次?

在react 17.x下, 我们能够同步的获取到data的值, 所以输出会是2, 3.同时也会经历两次react的整个render过程, 所以也会导致render被打印两次, 这都是因为setTimeout带来的影响.

image.png

而在最新版的react 18上, 这两个setData也会被异步的批处理, 合并为一次进行更新, 所以我们拿到的值始终是1, render函数也只会被打印一次.

image.png

react 18做了什么修改

官方的说明在这里: github.com/reactwg/rea…

总结一下就是:

从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(<App />);
 
// 旧版
// import React from 'react';
// import ReactDOM from 'react-dom';
// import './index.css';
// import App from './App';
// import reportWebVitals from './reportWebVitals';
 
// ReactDOM.render(
//   <React.StrictMode>
//     <App />
//   </React.StrictMode>,
//   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 === SyncLane

这个其实没什么好说的, 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内部各种不同优先级的任务.

(fiber.mode & ConcurrentMode) === NoMode

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其实是一个二进制值, 不理解这块的同学可以再想想二进制值的&、|操作.

!( ReactCurrentActQueue$1.isBatchingLegacy)

其实这个条件注释已经写的很清楚了

// 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

我们之前还提到, 如果我们想进行同步更新可以使用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 (
      <div>
        <button onClick={this.test}>{this.state.data}</button>
      </div>
    )
  }
}
 
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 (
      <div>
        <button onClick={this.test}>{this.state.data}</button>
      </div>
    )
  }
}

ps: 我们一直说的同步异步并不是指setState本身, setState本身一直一个同步函数, 我们指的是调用完setState后react会同步的去执行后续的步骤还是会异步的去执行后续的步骤.

结语

react官方做出这个改变其实也是为了更好的性能去考虑的, 毕竟调用完setState之后同步的进行渲染有时候会导致很多没必要的开销, 特别是在进行数据请求时, 很容易写出多个同步的setState.

而随着这波更新, 可能以后这道经典的面试题也会随之消散, 毕竟往后不论在什么情况下, 默认行为都会帮你对setState进行合并更新, 不再会进行同步处理了.

本文非原创

原文链接:blog.csdn.net/GMLGDJ/arti…