React18新特性之Automatic batching

247 阅读4分钟

很久没有学习React,正好React18也出来了一段时间,根据 React 18的介绍,最新的React将带来主要3个方面的新特性:

  • 自动批处理state更新
  • 支持Suspense的新SSR架构
  • Concurrent features

要学习,那最好带着问题出发,这次就要研究React18的第一个新特性,React是如何实现Automatic batching(批处理)的,以前对于React也没太多的研究(反正就是自己懒呗,再加上996),主要是根据一些博客和简单的debug React源码简单了解。直接看一段代码简单了解什么是React批处理吧(多次setState,一次render):

React17更新批处理

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>react是如何实现批量更新的?</title>
    <script src="https://unpkg.com/babel-standalone@6.26.0/babel.js"></script>
    <script src="./react@18.0.0.umd.js"></script>
    <script src="./react-dom@18.0.0.umd.js"></script>
  </head>
  <body>
    <div id="container" style="text-align: center;"></div>
    <script type="text/babel">
      const getDataSync = () => {
        return new Promise((resolve) => {
          setTimeout(() => {
            resolve();
          }, 1000);
        });
      };
      class Root extends React.Component {
        constructor(props) {
          super(props);
          this.state = {
            num1: 0,
            num2: 0,
            num3: 0,
          };
        }

        updateNums = () => {
          getDataSync().then(() => {
            debugger;
            this.setState({
              num1: this.state.num1 + 1,
            });
            debugger;
            this.setState({
              num2: this.state.num2 + 1,
            });
            debugger;
            this.setState({
              num3: this.state.num3 + 1,
            });
          });
        };

        render() {
          console.log("------- render -----------");
          const { num1, num2, num3 } = this.state;
          return (
            <div onClick={this.updateNums}>
              {num1}
              {num2}
              {num3}
            </div>
          );
        }
      }

      const container = document.getElementById("container");
      const root = ReactDOM.createRoot(container);
      root.render(<Root />);
    </script>
  </body>
</html>

以上代码在react之前会执行3次render^_^,在这里,之后执行一次,一步步来,先来了解经典的批处理,即事件中多次setState:

updateNums = () => {
  debugger;
  this.setState({
    num1: this.state.num1 + 1,
  });
  debugger;
  this.setState({
    num2: this.state.num2 + 1,
  });
  debugger;
  this.setState({
    num3: this.state.num3 + 1,
  });
};

<div onClick={updateNums}></div>

上面的代码中onClick事件并非真正的dom事件,经过babel转换后:

<div onClick={updateNums}></div>

React在createRoot(18中的新ReactDOM api)方法中代理了所有原生事件,在react虚拟dom转换成真实dom的时候,会在真实dom上绑当前的fiber

var listeningMarker = "_reactListening" + Math.random().toString(36).slice(2);
function listenToAllSupportedEvents(rootContainerElement) {
  console.log('------ listenToAllSupportedEvents --------');
  if (!rootContainerElement[listeningMarker]) {
    rootContainerElement[listeningMarker] = true;
    allNativeEvents.forEach(function (domEventName) {
      // We handle selectionchange separately because it
      // doesn't bubble and needs to be on the document.
      if (domEventName !== "selectionchange") {
        if (!nonDelegatedEvents.has(domEventName)) {
          listenToNativeEvent(domEventName, false, rootContainerElement);
        }

        listenToNativeEvent(domEventName, true, rootContainerElement);
      }
    });
    var ownerDocument =
      rootContainerElement.nodeType === DOCUMENT_NODE
        ? rootContainerElement
        : rootContainerElement.ownerDocument;

    if (ownerDocument !== null) {
      // The selectionchange event also needs deduplication
      // but it is attached to the document.
      if (!ownerDocument[listeningMarker]) {
        ownerDocument[listeningMarker] = true;
        listenToNativeEvent("selectionchange", false, ownerDocument);
      }
    }
  }
}

从点击target中取到fiber

现在可以从触发的事件找到对应的fiber了,一切都准备好了,就开始执行batchUpdate,再找到当前节点上onClick对应的方法:

执行onClick方法,即调用setState,setState主要就是enqueue TaskQueue,并在port.postMessage创建的宏任务中消费(flushWork -> workLoop),React在这里就不会让每次setState执行port.postMessage,具体控制逻辑在方法ensureRootIsScheduled中:

// react@17.0.1
// existingCallbackNode = scheduleSyncCallback();

// Check if there's an existing task. We may be able to reuse it.
if (existingCallbackNode !== null) {
  var existingCallbackPriority = root.callbackPriority;
  if (existingCallbackPriority === newCallbackPriority) {
    // The priority hasn't changed. We can reuse the existing task. Exit.
    return;
  }
  // The priority changed. Cancel the existing callback. We'll schedule a new
  // one below.
  cancelCallback(existingCallbackNode);
}

调试发现scheduleSyncCallback返回空对象 {} ~; 所以这里就只要判断existingCallbackPriority === newCallbackPriority 就直接return。第一次执行existingCallbackNode空执行scheduleSyncCallback,后续的setState只执行了enqueue TaskQueue,这就是react的更新事务的实现。

以上就是React17的更新批处理。在事件中执行setState批处理18也差不多。

脱离React事件系统的批量更新

上述内容都是React一直存在的批量更新事务逻辑,这次更新的点在脱离React事件系统的批量更新。上面提到事件中调用setState后最终更新逻辑在下一个port.postMessage宏任务中,在最上面的例子中,setState在第一次port.postMessage之后:

updateNums = () => {
  getDataSync().then(() => {
    debugger;
    this.setState({
      num1: this.state.num1 + 1,
    });
    debugger;
    this.setState({
      num2: this.state.num2 + 1,
    });
    debugger;
    this.setState({
      num3: this.state.num3 + 1,
    });
  });
};

先看看react17如何处理:

// var executionContext = NoContext; // The root we're working on

scheduleUpdateOnFiber() {
  // ...此处省略N行react源代码
  if (lane === SyncLane) {
    // ...此处省略N行react源代码
    } else {
      ensureRootIsScheduled(root, eventTime);
      schedulePendingInteractions(root, lane);

      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();
      }
    }
  }
  // ...此处省略N行react源代码
}

这里executionContext为NoContext,就直接执行了flushSyncCallbackQueue,也就是说这里setState enqueue TaskQueue之后,直接flush掉,不再在port.postMessage后flush了。这也就是为什么18之前的版本多次render的原因,拓展思考一下,queueMicrotask也会多次render,因为这里直接同步执行了。

再看看react18的处理逻辑:

scheduleUpdateOnFiber() {
  if (
    (executionContext & RenderContext) !== NoLanes &&
    root === workInProgressRoot
  ) {
    // ...此处省略N行react源代码
  } else {
    // ...此处省略N行react源代码
    ensureRootIsScheduled(root, eventTime);
  
    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();
    }
  }
}

很明显的看出来flushSyncCallbackQueue可调用判断严格了很多,反正是肯定进不去了

再细看ensureRootIsScheduled方法:

这就很明显了,fiber.mode直接默认为concurrentMode,命名上就能知道要并发渲染,第二次setState时:

if (
  existingCallbackPriority === newCallbackPriority && // Special case related to `act`. If the currently scheduled task is a
  // Scheduler task, rather than an `act` task, cancel it and re-scheduled
  // on the `act` queue.
  !(
    ReactCurrentActQueue$1.current !== null &&
    existingCallbackNode !== fakeActCallbackNode
  )
) {
  return;
}

所以react18相当于屏蔽了ensureRootIsScheduled()后flushSyncCallbackQueue的执行来处理批量更新,当然了,具体细节有很多,这里仅仅分析宏观上的代码逻辑。

写这篇文章仅为记录react18 Automatic batching 新特性的实现源码的学习(上班时间写的☠)。

新特性总结:妈妈再也不用担心我的代码会执行多次render了~