深入React18下的批量更新

702 阅读4分钟

前言

本文仅分析react-dom在开启concurrent mode的下和legacy mode下的setStatesetTimeout,Promise非React合成事件不同的表现的部分源码(即批量更新的部分源码)。 注: 在下文中的源码其实来自react-dom,但在书写的过程中一直写作React。

Legacy Mode 下的批量更新

首先我们通过cra创建一个React项目后使用以下的代码。

import React, { useState } from 'react';
import ReactDOM from 'react-dom'; 

const App = () => {
	const [count, setCount] = useState(0);
	console.log('render');
        
        // react合成事件中触发setState
	function syncFunc(){
          setCount(count + 1);
	  setCount(count + 1);
	}

        // 非react合成事件中触发setState
	function asyncFunc(){
		setTimeout(() => {
			setCount(count + 1);
			setCount(count + 1);
		},0);
	}
        
	return (
		<>
			<span>count: {count}</span>
			<br/>
			<button onClick={syncFunc}>+1</button>
			<br/>
			<button onClick={asyncFunc}>setTimeout+1</button>
		</>
	)
}

ReactDOM.render(<App />, document.getElementById('root'))

当我们点击+1的button时会出现如下的打印

17-render.jpg 当我们点击setTimeout+1的button时会出现如下的打印

17 async-render.jpg 也就是说当我们在setTimeout中调用setState时,一共触发了两次App组件的render,而在React的合成事件中只触发了一次App组件的render。

揭秘Legacy Mode下的批量更新

如上文所示,Legacy Mode中两种不同的方式调用的setState的最大的不同点其实是在setTimeout中调用的setState造成了App组件的两次render。那么我们就从App组件进行render时的函数调用栈来查看两者的不同。

以下是React合成事件(即上文的syncFunc函数)调用后的render函数的调用栈 17 -render.jpg 注意: Apprender函数是在syncFunc调用结束之后才调用,所以该调用栈中并没有syncFunc的执行上下文。我们可以当作syncFunc函数在batchedUpdates$1函数中进行调用。 以下是React非合成事件(即上文的asyncFunc函数)调用后的render函数的调用栈。

17 -async .jpg 注意: anonymous就是我们在setTimeout中传入的匿名回调函数。dispatchSetState其实就是setCount方法,所以以上的函数调用最终会走两次,所以在setTimeout中最后会打印出两次render。 对比两者之前的差异,我们判断当进入flushSyncCallbacksOnlyInLegacyMode这一函数调用,React进入”render“阶段,即根据当前的需要更新的state的队列来生成新的state并在最后执行App函数后获得新的"App组件"。那么,也就是说在setTimeout函数中的setState会进入到scheduleUpdateOnFiber并且在该方法中最终会调用到flushSyncCallbacksOnlyInLegacyMode从而触发App组件的render。而syncFunc中的setState(dispatchSetState)并没有进入到后续的Reactrender阶段。 其中scheduleUpdateOnFiber的关键代码如下所示:

// SyncLane为同步更新的标识
// executionContext是否为React合成事件中执行的标识 / 进行批量更新的标识
// fiber.mode & ConcurrentMode) === NoMode 判断当前是否为fiber的模式,
// isBatchingLegacy: react源码中act函数调用后的批量更新标识
if (lane === SyncLane && executionContext === NoContext && (fiber.mode & ConcurrentMode) === NoMode && !( ReactCurrentActQueue$1.isBatchingLegacy)) {
      resetRenderTimer();
      flushSyncCallbacksOnlyInLegacyMode();
    }

那么在知道了executionContext的变量的含义之后,发现executionContext该参数初始化是被赋值为NoContext,后续的逻辑就比较简单了,我们只需要会调用栈中会造成executionContext变化的函数即可,而这个函数就是batchedUpdates$1,该函数源码如下

// fn可以简单理解为我们传递给React中的合成事件,例如上述的syncFunc以及asyncFunc
function batchedUpdates$1(fn, a) {
  var prevExecutionContext = executionContext; // 保存原标识
  executionContext |= BatchedContext; // 将标识更新为批量更新标识

  try {
    return fn(a);
  } finally {
    // 在React合成事件执行之后
    executionContext = prevExecutionContext; //恢复原标识 -> NoContext

    // 在Legacy mode下 进入React的”render“逻辑
    if (executionContext === NoContext && !( ReactCurrentActQueue$1.isBatchingLegacy)) {
      resetRenderTimer();
      flushSyncCallbacksOnlyInLegacyMode();
    }
  }
}

根据以上的源码和注释我们就可以发现ReactsetState在两个函数中展示不同的原因其实是在batchedUpdates$1函数中会在执行fn函数之前将executionContext变量设置为BatchedContext,在fn函数执行之后再将executionContext修改回原来的值,并且执行flushSyncCallbacksOnlyInLegacyMode进行render

Concurrent mode中的批量更新

我们使用以下的代码来开启React的Concurrent mode

import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';
​
const App = () => {
  const [count, setCount] = useState(0);
  console.log('render')
​
  function syncFunc(){
    setCount(count + 1)
    setCount(count + 1)
  }
​
  function asyncFunc(){
    setTimeout(() => {
      setCount(count + 1)
      setCount(count + 1)
    }, 0);
  }
​
  return (
    <div>
      <span>{count}</span>    
      <br />
      <button onClick={syncFunc}>+1</button>  
      <br />
      <button onClick={asyncFunc}>setTimeout+1</button>
    </div>
  )
}
​
const root = ReactDOM.createRoot(document.getElementById('root'));
​
root.render(
    <App />
);

在开启Concurrent mode下无论是syncFunc函数还是asyncFunc函数最终都会出现如下的打印。

17-render.jpg

揭秘Concurrent mode的批量更新

以下是syncFunc调用后触发render的函数调用栈

react-18.png

其中是由一个匿名函数的调用flushSyncCallbacks来触发React中的后续的render逻辑。那么我们看看调用这个匿名函数的ensureRootIsScheduled的函数逻辑,以下为ensureRootIsScheduled的部分源码以及注释。

// root -> 当前的FiberRootNode
function ensureRootIsScheduled(root, currentTime) {
   
  var existingCallbackPriority = root.callbackPriority;
  // 当existingCallbackPriority为newCallbackPriority,不再调用flushSyncCallbacks
  if (existingCallbackPriority === newCallbackPriority){
    return;
  }
  
  // 是否为同步更新
  if (newCallbackPriority === SyncLane) {
    // FirbeRootNode中的tag是否为LegacyRoot,如果是则执行performSyncWorkOnRoot
    if (root.tag === LegacyRoot) {
      // 将当前的react-dom设置为触发渲染的状态 - 最终也会调用scheduleSyncCallback
      scheduleLegacySyncCallback(performSyncWorkOnRoot.bind(null, root));
    } else {
      // 将当前的更新渲染函数加入到syncQueue队列中,在flushSyncCallbacks
      // 会进行出队并进行更新
      scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
    }
        // 用Promise实现微任务 / setTimeout模拟微任务
        scheduleMicrotask(function () {
          // 当前不在React的合成事件中
          if (executionContext === NoContext) {
            flushSyncCallbacks();
          }
        });
       }
  }else{
  //不是同步更新
    ...
}
  
  // 设置root.callbackPriority为 newCallbackPriority
  root.callbackPriority = newCallbackPriority
    ...
}

以下是syncFunc调用后触发render的函数调用栈

18 - async.jpg 注意: anonymous就是我们传给setTimeout的回调函数,所以与React的合成事件相比的差异是在ensureRootIsScheduled中调用scheduleCallback$2。那么我们就继续分析ensureRootIsScheduled的部分源码.

// root -> 当前的FiberRootNode
function ensureRootIsScheduled(root, currentTime) {
   
 // 个人认为: 获得当前render的优先级 -> setTimeout中返回16
 var newCallbackPriority = getHighestPriorityLane(nextLanes); 
​
  var existingCallbackPriority = root.callbackPriority;
  // 当existingCallbackPriority为newCallbackPriority,不再调用flushSyncCallbacks
  if (existingCallbackPriority === newCallbackPriority){
    return;
  }
  
  // 是否为同步更新
  if (newCallbackPriority === SyncLane) {
      ...
  else{
      //不是同步更新
      // 个人认为schedulerPriorityLevel为当前的任务等级
          newCallbackNode = scheduleCallback$2(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
  }
  root.callbackPriority = newCallbackPriority;
  root.callbackNode = newCallbackNode;
}

而这里的nextLane与React的currentUpdatePriority参数息息相关,有关syncFuncasyncFunc中的对nextLane出现的差异也来自这个参数。有关修改这个参数的函数调用是dispatchDiscreteEvent函数,dispatchDiscreteEvent部分源码如下.

function dispatchDiscreteEvent(domEventName, eventSystemFlags, container, nativeEvent) {
  // 保存上一次的优先级 -> 默认为NoLane = 0
  var previousPriority = getCurrentUpdatePriority();
  try {
    // 修改currentUpdatePriority为SyncLane
    setCurrentUpdatePriority(DiscreteEventPriority);
    // 以下为触发React的合成事件即上述的syncFunc以及asyncFunc
    dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
  } finally {
    // 修改回上一次的优先级
    setCurrentUpdatePriority(previousPriority);
  }
}

dispatchDiscreteEvent的方法也是在调用React合成事件时先保存currentUpdatePriority后,将currentUpdatePrioritySyncLane再调用React的合成事件,从而保证React合成事件中的setState的优先级。而在调用结束后则替换为原先的currentUpdatePriority。 而scheduleCallback$2方法中做了什么呢,其实是React中的调度算法,他会根据任务的优先级而来调用“render”任务。最终React是使用MessageChannel中调用performWorkUntilDeadline

结束语

以上就是有关react-domconcurrent modelegacy mode两种模式下的批量更新的部分源码阅读,如果对以上的源码阅读有异议,欢迎纠正。