ReactFiber在并发模式下的运行机制

1,047 阅读8分钟
原文链接: zhuanlan.zhihu.com

新一年又开始了,React过去一年悄悄实现了Suspense,带来了React Hooks ,源码也大变换了。因此好有必须重新阅读一下,看它是如何实现这些功能,及未来准备开放的并发渲染功能。

React16早期将原来的diff过程拆分成两个阶段,第一个阶段叫 reconcile , 也就是原来diff虚拟DOM的过程。这阶段有大量叫reconcileXXX的方法参与其中,如reconcileSingleElement、reconcileSinglePortal、reconcileChildFibers、reconcileSingleTextNode、 reconcileChildrenArray、reconcileChildrenIterator、 reconcileChildFibers...会创建组件实例与真实DOM节点,执行一些轻量钩子。第二个阶段叫commit,在并发模式下,可能多次reconcile才有一次commit。commit就是将水面下的效果浮现出来,比如将节点插入到DOM树,修复节点的属性样式文本内容,执行如componentDidXXX这样的重量钩子,执行ref操作(这时可能涉及DOM操作)。

schedule源码剖析

现在,React在这两个阶段之前新添加了一个阶段,schedule。因为一个页面可能有多个ReactDOM.render,虚拟DOM树中也通常存在多个拥有自更新能力的组件(React Hooks的出现让无状态组件也具有自更新能力)。在并发模式下,组件setState不会立即更新视图,于是在一个时间段中,就有多个待更新的组件,这些组件叫root(渲染的起点),但谁是真正的nextRoot呢?需要一个调度算法进行决定。React根据当前时间给每个组件分配一个过期时间(相当优先级),数字越大,就越优先执行。

schedule的起点方法是scheduleWork。 ReactDOM.render, setState,forceUpdate, React Hooks的dispatchAction都要经过scheduleWork。

scheduleWork里面有一个scheduleWorkToRoot方法,负责将当前fiber及其alternate的过期时间推迟(或者叫加大)。由于在ReactFiber中,过期时间等价于优先级,换言之,一个组件在某个时间段setState频繁,那么它就越优先更新。

在并发模式下,setState后33ms执行(如果在动画中,为了保证流畅,增长到100ms间隔 )。如果更新的节点是一个受控组件(input),那么它是直接进入interactiveUpdates方法,不经过scheduleWork,是立即更新!React还有一个没登记到文档batchedUpdates方法,它可以让一大遍节点立即更新,并且无视shouldComponentUpdate return false!!类似batchedUpdates这样的特权方法,在React16中已经存在了许多了!

上面是ReactFiber的总流程。最下面的绿色方法都是贵族方法,拥有极高的优先执行权。

//by 司徒正美 QQ 370262116
function scheduleWork(fiber, expirationTime) {
	const root = scheduleWorkToRoot(fiber, expirationTime);
	if (root === null) {
		return;
	}
	if (
		!isWorking &&
		nextRenderExpirationTime !== NoWork &&
		expirationTime > nextRenderExpirationTime
	) {
		resetStack();
	}
	markPendingPriorityLevel(root, expirationTime);
	if (
		//如果在准备阶段或commitRoot阶段或渲染另一个节点
		!isWorking ||
		isCommitting ||
		// ...unless this is a different root than the one we're rendering.
		nextRoot !== root
	) {
		const rootExpirationTime = root.expirationTime;
		requestWork(root, rootExpirationTime);
	}
}

requestWork是决定以什么方式进入performWorkOnRoot。有四种情况,一如果已经开始渲染,那么就立即返回,二是直接进入performWorkOnRoot, 三是先进入performSyncWork,然后到performSync到performWork到performWorkOnRoot,四是异步方法,从scheduleCallbackWithExpirationTime进入performAsyncWork到performWork到performWorkOnRoot。

performWork就是用来裁定是同步渲染还是异步渲染。performWork的第一行就是findHighestPriorityRoot方法,将最高优先级的root挑出来,丢给performWorkOnRoot。

performWorkOnRoot是决定直接执行commitRoot还是 先执行renderRoot再执行commitRoot。

completeRoot只是一个renderRoot的一个简单包装,它执行了一些至少我们都没有用到ReactBatch的东西。

整个过程如下:

scheduleWork --> requestWork --> performWork --> findHighestPriorityRoot -->
performWorkOnRoot --> completeRoot --> renderRoot --> commitRoot

从scheduleWork到completeRoot,就是schedule阶段,决定哪个子树优先执行,何时执行。

renderRoot,就是reconcile阶段,让组件生成子级的虚拟DOM,生成组件实例与真实DOM,各种打tag( effectTag)。

commitRoot,就是commit阶段,更新视图,执行重型钩子与Ref。

commit阶级的源码分析

至于React在commit阶段是怎么更新,也是一个令人眼花的过程。上面讲过,在reconcile阶段,会对fiber进行打tag。fiber对象是新时代的虚拟DOM,它是用来承载着组件实例与真实DOM等重要数据。这些重要数据在更新过程是不需要重新生成的。但React希望能像git那样按分支开发,遇错回滚。于是fiber拥有一个辅助属性alternate,你可以叫他备胎,也可以叫替万鬼,也可以叫踩雷的。我们打开performUnitOfWork方法就知道了

function performUnitOfWork(workInProgress) {
   var current = workInProgress.alternate;
   next = beginWork(current, workInProgress, nextRenderExpirationTime);
   //...
}

workInProgress为一个fiber,最开始时它是没有alternate,beginWork就是根据它是否有alternate决定执行mount还是update操作。而伟大的一开始就有alternate,它总是更新的,它的

//by 司徒正美 QQ 370262116
function createFiberRoot(containerInfo, isConcurrent, hydrate) {
 var uninitializedFiber = createHostRootFiber(isConcurrent);
 var root = {
    current: uninitializedFiber,
    containerInfo: containerInfo,
    pendingChildren: null,
    //...略
  }
  uninitializedFiber.stateNode = root;
  return root;
}

从scheduleWork到completeRoot的root指的就是HostRootFiber的current属性,它本身为一个fiber。

在performWorkOnRoot中出现一个叫finishedWork的东西,它以后在许多方法中都露脸,通过调试可知,在renderRoot前,root是没有finishedWork属性,在renderRoot后就有这属性了。发现这个方法是在renderRoot中的onComplete方法中加上的。

var rootWorkInProgress = root.current.alternate; 
// Ready to commit.
 onComplete(root, rootWorkInProgress, expirationTime);

function onComplete(root, finishedWork, expirationTime) {
  root.pendingCommitExpirationTime = expirationTime;
  root.finishedWork = finishedWork;
}

那么root.current.alternate是怎么来的呢?发现只有createWorkInProgress能为fiber创建副本。renderRoot的前半部分有这几行

 if (expirationTime !== nextRenderExpirationTime || root !== nextRoot || nextUnitOfWork === null) {
 // Reset the stack and start working from the root.
     resetStack();
     nextRoot = root;
     nextRenderExpirationTime = expirationTime;
     nextUnitOfWork = createWorkInProgress(nextRoot.current, null, nextRenderExpirationTime);
     root.pendingCommitExpirationTime = NoWork;
  }

有了finishedWork,就可以在commitRoot中得到最重要的nextEffect。

 firstEffect = finishedWork.firstEffect;
 nextEffect = firstEffect

nextEffect是一个全局变量。ReactFiberScheduler.js这个文件2500行,定义大量全局变量与N多上百行的巨型函数,真是想骂人。

commit阶段就是四大commitXXX方法,commitBeforeMutationLifecycles, commitAllHostEffects, commitAllLifeCycles都会用到nextEffect。commitPassiveEffects则比较人道,直接将firstEffect bind了一下。commitPassiveEffects就是React Hooks中的useEffect,最晚才执行的钩子。

这时我们要研究finishedWork.firstEffect是怎么来的。finishedWork就是HostRootFiber的current对象经过createWorkInProgress产生的。在completeUnitOfWork中有这样一行:

var effectTag = workInProgress.effectTag;
 // Skip both NoWork and PerformedWork tags when creating the effect list.
 // PerformedWork effect is read by React DevTools but shouldn't be committed.
if (effectTag > PerformedWork) {
     if (returnFiber.lastEffect !== null) {
         returnFiber.lastEffect.nextEffect = workInProgress;
     } else {
         returnFiber.firstEffect = workInProgress;
     }
     returnFiber.lastEffect = workInProgress;
}

每个父fiber会将它发生更新的孩子当为lastEffect或lastEffect.nextEffect属性存起来,本来它的孩子可能是数组结构,也可能数组包含数组,现在全部变成链表了。

completeUnitOfWork里面还有一个completeWork方法,它会将新生成的孩子立即添加到父节元素上。

var instance = createInstance(type, newProps, rootContainerInstance, 
    currentHostContext, workInProgress);
appendAllChildren(instance, workInProgress, false, false);

//-----------
//by 司徒正美 QQ 370262116

appendAllChildren = function (parent, workInProgress, needsVisibilityToggle, isHidden) {
    // We only have the top Fiber that was created but we need recurse down its
    // children to find all the terminal nodes.
    var node = workInProgress.child;
    while (node !== null) {
      if (node.tag === HostComponent || node.tag === HostText) {
        appendInitialChild(parent, node.stateNode);
      } else if (node.tag === HostPortal) {
        // If we have a portal child, then we don't want to traverse
        // down its children. Instead, we'll get insertions from each child in
        // the portal directly.
      } else if (node.child !== null) {
        node.child.return = node;
        node = node.child;
        continue;
      }
      if (node === workInProgress) {
        return;
      }
      while (node.sibling === null) {
        if (node.return === null || node.return === workInProgress) {
          return;
        }
        node = node.return;
      }
      node.sibling.return = node.return;
      node = node.sibling;
    }
 };

function appendInitialChild(parentInstance, child) {
  parentInstance.appendChild(child);
}

因此在commit阶段,对于元素节点,它只有移动,删除,修改样式与文本这几种任务(副作用)

其他几个commitXXX就是很简单了。

commitBeforeMutationLifecycles就是执行getSnapshotBeforeUpdate

commitAllHostEffects执行DOM节点相关的操作

commitAllLifeCycles执行组件实例相关操作

Suspense与懒加载的实现

Suspense是一个虚拟组件,如果它正下方不是一个LazyComponent(通过React.lazy产生),那么它与Fragment, Profiler, StrictMode这几个组件没什么差别,不会渲染自身。

//官网上都是用动态import语句实现,其实使用Promise+setTimeout也能模抋
var LazyComponent = React.lazy(function(){
  return new Promise(function(resolve){
     setTimeout(function(){
         resolve()
      }, 1500)//setTimeout模拟网络请求,方便能看到Loading
    }).then(function(){ //then方法必须返回一个带default属性的对象
      return {
        default: function(){
           return <div>这是动态组件</div>
        }
      }
    })
});

function App (){
  return <div> 
             <React.Suspense fallback={<div>Loading...</div>}>
                  <LazyComponent />
             </React.Suspense>
        </div>
}
ReactDOM.render(<App />, container)

我们看fiber如何处理它的,从renderRoot到workLoop到performUnitOfWork。performUnitOfWork会逐个处理所有fiber。performUnitOfWork又分为beginWork与completeWork两个阶段。beginWork遇到SuspenseComponent时,就丢到updateSuspenseComponent方法,一开始进入if ((workInProgress.effectTag & DidCapture) === NoEffect) {} 分支,这时nextDidTimeout为false,于是直接解析其子节点LazyComponent。

 child = next = mountChildFibers(workInProgress, null,
 nextPrimaryChildren, renderExpirationTime);

mountChildFibers里面遇到LazyComponent,会调用mountLazyComponent处理,mountLazyComponent又调用readLazyComponentType处理。readLazyComponentType会读取组件的_status属性,决定是返回result组件,还是抛错。基本上除了Resolved情况下,都抛错了。

function readLazyComponentType(lazyComponent) {
    var status = lazyComponent._status;
    var result = lazyComponent._result;
    switch (status) {
        case Resolved:
            var Component = result;
            return Component;
        case Rejected:
            var error = result;
            throw error;
        case Pending:
            var thenable = result;
            throw thenable;

        default:
            lazyComponent._status = Pending;
            var ctor = lazyComponent._ctor;
            var _thenable = ctor();
            _thenable.then(function (moduleObject) {
                if (lazyComponent._status === Pending) {
                    var defaultExport = moduleObject.default; {
                        if (defaultExport === undefined) {
                            warning$1(false, 'lazy: Expected the result of a dynamic import() call. ' + 'Instead received: %s\n\nYour code should look like: \n  ' + "const MyComponent = lazy(() => import('./MyComponent'))", moduleObject);
                        }
                    }
                    lazyComponent._status = Resolved;
                    lazyComponent._result = defaultExport;
                }
            }, function (error) {
                if (lazyComponent._status === Pending) {
                    lazyComponent._status = Rejected;
                    lazyComponent._result = error;
                }
            });
            lazyComponent._result = _thenable;
            throw _thenable;
    }
}

抛错了怎么办?不用担心,workLoop外面是包着try catch。

do {
    try {
        workLoop(isYieldy);
    } catch (thrownValue) {
        if (nextUnitOfWork === null) {
            // This is a fatal error.
            didFatal = true;
            onUncaughtError(thrownValue);
        } else {
            var sourceFiber = nextUnitOfWork;
            var returnFiber = sourceFiber.return;
            if (returnFiber === null) {
                didFatal = true;
                onUncaughtError(thrownValue);
            } else {
                //lazyComponent组件的抛错会在这里被接住
                throwException(root, returnFiber, sourceFiber, thrownValue, nextRenderExpirationTime);
                //继续处理子节点或兄弟节点
                nextUnitOfWork = completeUnitOfWork(sourceFiber);
               //nextUnitOfWork为lazyComponent的父节点SuspenseComponent
               continue;
            }
        }
    }
    break;
} while (true);

我们看到一个有处理的处理,continue语句之前nextUnitOfWork为lazyComponent的父节点SuspenseComponent,于是SuspenseComponent又跑到workLoop中,又到performUnitOfWork再到beginWork再到updateSuspenseComponent。这时程序会进入updateSuspenseComponent的另一分支,nextDidTimeout为true,这个分支中会取得SuspenseComponent的fallback函数,解析得到里面内容,于是这时Loading就出来了!!!!

在throwException中这样的语句

//by 司徒正美 https://rubylouvre.github.io/nanachi/

if (_workInProgress.tag === SuspenseComponent &&
    shouldCaptureSuspense(_workInProgress.alternate, _workInProgress)) {
    // Found the nearest boundary.
    // If the boundary is not in concurrent mode, we should not suspend, and
    // likewise, when the promise resolves, we should ping synchronously.
    var pingTime = (_workInProgress.mode & ConcurrentMode) === NoEffect ? Sync : renderExpirationTime;

    // Attach a listener to the promise to "ping" the root and retry.
    var onResolveOrReject = retrySuspendedRoot.bind(null, root, _workInProgress, sourceFiber, pingTime);
    if (enableSchedulerTracing) {
        onResolveOrReject = unstable_wrap(onResolveOrReject);
    }
    thenable.then(onResolveOrReject, onResolveOrReject);
}

而retrySuspendedRoot会重新调起scheduleWorkToRoot,开始新一轮的渲染流程,这次Promise返回 的内容会取代fallback生成的节点,实现lazyload效果!