重新捋一捋React源码之更新渲染流程

发布时间 2023-03-22 19:08:04作者: 爱喝可乐的咖啡

前言

前些天在看Dan Abramov个人博客(推荐阅读,站在React开发者的角度去解读一些API的设计初衷和最佳实践)里的一篇文章,其重点部分的思想就是即使不使用Memo(),也可以通过组合的方式来减少组不必要的渲染。

作者在放出代码讲述结论的时候并没有细说原理只是一笔带过,所以笔者自己想着从React内部的更新渲染机制去思考其原理时,发现并不顺畅,在一些关键位置的理解有些模棱两可,遂意识到自己对于React的理解并没有想象中的那么深。 因此打算重新捋一捋React源码中涉及更新渲染时的整个流程并记录下来。

背景

在进入正文前,需要先声明下该文章的读者需要了解过一些基本的React原理知识。例如虚拟DOM(Virtual Dom),Diff算法、fiber架构等此类概念。我们大致过一下:

Virtual Dom

React官方文档:Virtual DOM 是一种编程概念。在这个概念里, UI 以一种理想化的,或者说“虚拟的”表现形式被保存于内存中,并通过如 ReactDOM 等类库使之与“真实的” DOM 同步。这一过程叫做协调。

Virtual Dom是一棵与DOM类似的树形结构。当我们创建或者更新组件时,通过协调(reconcile)过程构建新的Virtual Dom树,并与老的树比较计算出需要修改DOM的地方。

Fiber

自16版本之后,React引入了全新的fiber架构。React在构建的Virtual Dom树上的每个节点都被称为fiber节点。这种将fiber节点作为最小的工作单元的组织方式,为React未来版本的可中断的异步更新提供了最底层的支持。本文的源码解析基于17版本。

双缓冲fiber树

在React完成初次渲染之后会同时存在两棵fiber树。当前已经渲染到页面上的内容对应的fiber树称为current fiber树,正在内存中构建的被称为workInProgress fiber树

Diff算法就是发生在构建workInProgress fiber树的过程中,边构建边与current fiber树做比较并尽可能地复用节点。当一次更新渲染完成后,在根节点上直接通过将current指针指向workInProgress fiber树来执行两棵树的切换。

fiber树的遍历方式

考虑组织树形数据结构的方式,最常见的一种方式就是通过children字段:

{
    "name": "A",
    "children": [
        { "name": "B" },
        {
            "name": "C",
            "children": [
                { "name": "D" }
            ]
        }
    ]
}

但React并不是这种方式,而是采用了通过指针来关联节点的方式:无论有多少个子节点,只存储一个child字段来指向第一个子节点;多个子节点中通过sibling指向下一个兄弟节点;所有的子节点都有return字段指向同一个父节点。

来个示例图:

这种方式非常符合Fiber架构的需求。方便以各种方式遍历整棵树,并且在调整树结构时,只需操作指针指向就可以了。

React对该树的遍历方式采取的是深度优先遍历。深度优先遍历有两个不同的阶段,分别是是“递”和“归”阶段;对应的[源码]简化后:

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;

  let next;
  next = beginWork(current, unitOfWork, subtreeRenderLanes);

  if (next === null) {
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

workInProgress指针指向当前遍历到的节点。对于每个遍历到的fiber节点,会调用beginWork方法。该方法根据传入的fiber节点创建第一个子fiber节点,并将这两个节点通过child字段连接起来。这是“递”阶段。

当遍历到叶子节点 即没有子节点的组件时,就进入到“归”阶段:对该fiber节点执行completeUnitOfWork方法。执行完该节点的“归”阶段后,会检查其是否存在兄弟节点 即sibling字段。如果存在则进入到兄弟fiber节点的“递”阶段;如果没有兄弟节点了,则向上开始父fiber节点的“归”阶段。

“递”和“归”阶段不断交错执行直至“归”到根节点结束。示意如下:

状态更新的起点

前面说了那么多,终于要进入到正文啦。

触发React更新的方式有许多种:

  • this.setState
  • this.forceUpdate
  • useState
  • useReducer
  • ......

不管是何种方式更新状态,都会创建一个用于保存更新状态相关信息的对象,称为Update。并将其插入到对应fiber节点的updateQueue上(enqueueUpdate(fiber, update)函数中完成),并加入调度update(scheduleUpdateOnFiber(fiber, lane, eventTime))。

我们以ClassComponent的this.setState为例,将其作为状态更新的起点。

[源码]中看下setState方法的定义:

Component.prototype.setState = function(partialState, callback) {
  invariant(
    typeof partialState === 'object' ||
      typeof partialState === 'function' ||
      partialState == null,
    'setState(...): takes an object of state variables to update or a ' +
      'function which returns an object of state variables.',
  );
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
}

先是做对参数的检查,接着调用updater上的enqueueSetState。这里的updater并不是在react包中定义的,而是通过依赖注入的方式在组件的初始化构建时注入[源码]的。所以在ClassComponent的constructor()中调this.setState是非法的。

进入到enqueueSetState的方法[源码]中:

enqueueSetState(inst, payload, callback) {
    // 根据当前的组件实例获取对应的fiber节点
    const fiber = getInstance(inst);
    
    // 优先级相关的数据
    const eventTime = requestEventTime();
    const lane = requestUpdateLane(fiber);
    
    // 创建update
    const update = createUpdate(eventTime, lane);
    update.payload = payload;
    // update.tag = ForceUpdate; 如果是通过this.forceUpdate()更新的话,这里还会加个标记为强制更新,防止组件在后续的优化手段中被跳过重渲染
    
    // 处理回调函数
    if (callback !== undefined && callback !== null) {
      update.callback = callback;
    }
    
    // 将update插入到updateQueue中
    enqueueUpdate(fiber, update);
    // 调度update
    scheduleUpdateOnFiber(fiber, lane, eventTime);
}

无论是以何种方式进行更新,最后都会统一进入到scheduleUpdateOnFiber函数[源码]中来

function scheduleUpdateOnFiber(fiber, lane, eventTime) {
  // 检查是否陷入无限循环更新;例如在render()函数中调用setState()就会导致无限更新
  checkForNestedUpdates();
  
  // 自底向上收集fiber.childLanes
  const root = markUpdateLaneFromFiberToRoot(fiber, lane);
  if (root === null) {
    return null;
  }
    
  if (lane === SyncLane) {
      if(
          (executionContext & LegacyUnbatchedContext) !== NoContext &&	// 当前处于unbatched的上下文环境中;组件在mount和卸载时会拥有该上下文
          (executionContext & (RenderContext | CommitContext)) === NoContext	// 当前不是处于render或commit阶段
      ) {
          performSynWorkOnRoot(root);
      } else {
          ensureRootIsScheduled(root, eventTime);
          
          if(executionContext === NoContext) {
              flushSyncCallbackQueue();
          }
      }
  } else {
      ...
      ensureRootIsScheduled(root, eventTime);
  }
}

从上往下看:

  1. 因为该方法是所有状态更新都必经的入口,所以最先做的是检查死循环的可能。

  2. markUpdateLaneFromFiberToRoot的作用是从当前需要更新的fiber节点开始,根据父节点指针不断向上遍历直至root节点,并将fiber.lane存入这一向上路径上所有祖先节点的childLanes中。childLanes字段在后续的render阶段遍历fiber树时用于判断子树中是否存在更新的依据。

  3. 接下来根据渲染方式分流

    • 如果是传统的同步渲染方式,则进入第一个if的逻辑中;老版本中最常见的ReactDOM.render就是该模式。

      • 判断代码注释中说明的条件,如果都满足的话(通常就是第一次渲染的时候)直接执行同步更新performSynWorkOnRoot(),开始render阶段。

      • 否则调用ensureRootIsScheduled(root, eventTime),调度此次更新。

        在这之后会有一条if(executionContext === NoContext)判断当前是否没有任何的执行上下文,如果为true就表现此次更新并不是处在React的上下文中,则调用flushSyncCallbackQueue()立即同步执行此次更新。 这里涉及到一个常见的React问题:“this.setState什么时候同步更新,什么时候是异步(批量)更新”? 答:当处于React相关生命周期函数和事件处理回调中时,代码层面上看就是拥有执行上下文executionContext,这时候this.setState是批量更新的;而在脱离这些环境(如fetch网络请求返回或者setTimeout延迟执行)时,if(executionContext === NoContext)成立,就会同步执行this.setState的更新。

    • 异步渲染的方式,也是调用ensureRootIsScheduled(root, eventTime)

ensureRootIsScheduled()函数[源码]中涉及到对过期任务的立即同步执行,对旧任务的复用等逻辑;这一块不是关注的重点先忽略掉(scheduler的调度的对象是任务(task),而不是指单个fiber节点的更新,这里要注意下。任务是指根据current fiber树构建新的fiber树并diff的整个过程 即整个render阶段。而ReactensureRootIsScheduled()函数里做的只是根据旧任务和此次更新的优先级来决定是否要复用旧任务 亦或生成新任务,剩下的就丢给scheduler去调度了。这一块的内容要拉出来细说的话得相当大的篇幅,之后再单独写一篇关于任务调度部分的文章 : ) 这里我们只要知道scheduler是通过MessageChannel来实现task(宏任务),以此达到异步调度执行任务的目的。

关注在流程上最核心的代码部分:

function ensureRootIsScheduled(root, current) {
    ...
    if (existingCallbackNode !== null) {
        const existingCallbackPriority = root.callbackPriority;
        if (existingCallbackPriority === newCallbackPriority) {
          // 优先级不变,则直接复用已有任务
          return;
        }
        // 优先级改变了,取消已有任务,下面开始调度一个新任务
        cancelCallback(existingCallbackNode);
    }
    
    // 调度一个新任务
    let newCallbackNode;
    if (newCallbackPriority === SyncLanePriority) {
        newCallbackNode = scheduleSyncCallback(
          performSyncWorkOnRoot.bind(null, root),
        );
     } else {
        const schedulerPriorityLevel = lanePriorityToSchedulerPriority(
          newCallbackPriority,
        );
        newCallbackNode = scheduleCallback(
          schedulerPriorityLevel,
          performConcurrentWorkOnRoot.bind(null, root),
        );
     }
    
     root.callbackPriority = newCallbackPriority;
     root.callbackNode = newCallbackNode;
}

其中scheduleSyncCallbackscheduleCallback是由scheduler包提供的方法,用于根据优先级调度传入的回调函数。

被调度的函数performSyncWorkOnRootperformConcurrentWorkOnRoot,取决于本次更新是同步更新还是异步更新;这两个函数即是render阶段的入口。

总结

梳理下从触发更新为起始到进入渲染阶段流程中的关键节点:

render阶段

performSyncWorkOnRoot/performConcurrentWorkOnRoot里会调用renderRootSync(root, lanes)/renderRootConcurrent(root, lanes)commitRoot(root),这两者分别就是React更新中的render阶段和cmooit阶段了。

首先讲解的是render阶段的工作流程。进到renderRootSync(root, lanes)/renderRootConcurrent(root, lanes)中,看到关键函数workLoopSync/workLoopConcurrent

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

它们的区别在于是否有调用shouldYield()shouldYield()用于判断当前浏览器帧的时间还足够,不够了就打断此次fiber树的构建,等到空闲时再次执行;这也是为什么引入异步更新后可能会导致组件会多次render的原因。

workInProgress变量表示当前已创建的workInProgress fiber节点。performUnitOfWork会根据当前的workInProgress fiber节点以前文所说的深度遍历的方式决定下一个fiber节点是哪个并创建出来,然后将workInProgress与新创建的子fiber节点连接起来,再将新创建的节点赋值给workInProgress

while循环中不断调用performUnitOfWork(workInProgress),重复上述工作 就构造出了一棵完整的fiber树。现在看到performUnitOfWork中的代码[源码]

function performUnitOfWork(unitOfWork: Fiber): void {
    const current = unitOfWork.alternate;
    
    let next;
    next = beginWork(current, unitOfWork, subtreeRenderLanes);	// “递”阶段
	...
    if(next === null) {
        completeUnitOfWork(unitOfWork);		// “归”阶段
    } else {
        workInProgress = next;
    }
}

前文说过,beginWorkcompleteUnitOfWork分别对应的就是fiber树更新的“递”和“归”阶段。

beginWork

beginWork简化处理后如下[源码]

function beginWork(current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes):Fiber | null {
    const updateLanes = workInProgress.lanes;
    
    if(current !== null) {
        const oldProps = current.memoizedProps;
	    const newProps = workInProgress.pendingProps;
        
        if(oldProps !== newProps || hasLegacyContextChanges()) {
            didReceiveUpdate = true;
        } else if(!includesSomeLane(renderLanes, updateLanes)) {
            // 当前更新优先级renderLanes不包括fiber.lanes
            didReceiveUpdate = false;
            ...
            return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
        } else {
            didReceiveUpdate = false;
        }
    } else {
        didReceiveUpdate = false;
    }
    
    switch(workInProgress.tag) {
		...
        case FunctionComponent:
            return updateFunctionComponent(...);
  	case ClassComponent:
  	    return updateClassComponent(...);
        case MemoComponent:
            return updateMemoComponent(...);
        ...
    }
}

我们已经知道React会有两棵fiber树,那么除根节点rootFiber外,任何节点在初次渲染时其current都为空 即不存在workInProgress.alternate。因此可以根据if(current !== null)来区分当前是mount还会update

这里的didReceiveUpdate从变量名就能看出来,是用于标记该节点是否有接收到更新;在后续的流程代码中我们会看到需要使用该变量来判断优化逻辑的地方。

current为空时很简单,将didReceiveUpdate置为false,就直接就进入最下面根据tag字段区分节点类型,创建并返回新的子fiber节点

current !== null即更新时,React会根据一定条件尽可能地去复用current节点:

  1. props或者context有变动的;这种情况是无法复用current节点的;将didReceiveUpdate置为true。这里有两个需要注意的点:

    • 这里的oldPropsnewProps是由组件外部传入的所有属性创建而来的对象。即使我们在每次重渲染时向子组件传递的所有props属性字段值都是一样的,但在子组件对应的fiber中总是会new一个新对象再将属性存入其中。因此,这里的oldProps !== newProps总是为真。这也是为什么当父组件重新render时,即使传给子组件的props属性都不变也会导致子组件都重渲染。
    • 如果使用了一些优化手段,例如当前节点是Memo包裹的组件;那么在创建Memo类型的节点时会对oldPropsnewProps做一次浅比较。如果浅比较发现所有字段值都相同,则会将didReceiveUpdate置回false。
  2. 否则判断 当前fiber的更新优先级与此次fiber树的更新优先级判断,如果存在更新且更新优先级与fiber树优先级一致则includesSomeLane(renderLanes, updateLanes)会返回true。当前述两点不满足时(说明不存在更新或优先级不够),则进入该分支执行bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes),复用fiber节点(后续会细说这个方法)。

  3. 前两个分支都无法满足,说明该fiber节点存在更新但无外部变化(props或是context改变),didReceiveUpdate置为false

在进一步讲解beginWork方法之前,先来看下一bailoutOnAlreadyFinishedWork的实现[源码]

function bailoutOnAlreadyFinishedWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
      // ...
      
      if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
        return null;
      } else {
        cloneChildFibers(current, workInProgress);
        return workInProgress.child;
      }
  }

bailoutOnAlreadyFinishedWork中最主要的就是这两个判断分支。在前面讲解scheduleUpdateOnFiber方法中说到,执行markUpdateLaneFromFiberToRoot(fiber, lane)语句,对于产生更新的fiber节点,会将其更新优先级的信息lane变量自底向上,赋值给所有祖先节点的childLanes变量中。那么:

  • includesSomeLane(renderLanes, workInProgress.childLanes)语句判断当前fiber节点的childLanes是否在本次更新的优先级信息renderLanes中;如果不是,则说明workInProgress的整棵子树中都不存在更新,所以直接返回null,上文说到的performUnitOfWork方法中,如果判断返回的变量是null则表示“递”阶段完成,开始此fiber节点的“归”阶段。再来回顾下performUnitOfWork的代码:

    function performUnitOfWork(unitOfWork: Fiber): void {
        const current = unitOfWork.alternate;
        
        let next;
        next = beginWork(current, unitOfWork, subtreeRenderLanes);	// “递”阶段
    	...
        if(next === null) {
            completeUnitOfWork(unitOfWork);		// “归”阶段
        } else {
            workInProgress = next;
        }
    }
    
  • 否则的话说明子树中存在更新,需要继续往下“递”。但是当前的fiber节点是可以复用的,执行cloneChildFibers(current, workInProgress)克隆所有子节点;返回下一个需要更新的fiber节点 即第一个子节点。

beginWork方法体的最下面就是根据当前fiber节点的类型调用各自的创建函数,并返回下一个要更新的fiber节点。在这一过程中,会执行各种优化措施和生命周期相关的钩子 例如:

  • 对于Class组件,如果有重写了componentWillReceiveProps方法的会在此时调用;有重写了shouldComponentUpdate方法的话,会将执行结果加入到是否要跳过更新的判断中去[updateClassComponent -> updateClassInstance]。如果最终判断可以跳过更新,也是进入到bailoutOnAlreadyFinishedWork函数[updateClassComponent -> finishClassComponent]

  • 如果是Function组件,进入到updateFunctionComponent函数[源码]中:

    function updateFunctionComponent(
      current,
      workInProgress,
      Component,
      nextProps: any,
      renderLanes
    ) {
          nextChildren = renderWithHooks();	// 返回函数组件执行后return的JSX内容
          
          if (current !== null && !didReceiveUpdate) {
            bailoutHooks(current, workInProgress, renderLanes);	// 移除副作用和更新标记
            return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
          }
          // 创建并返回下一个fiber节点
          reconcileChildren(current, workInProgress, nextChildren, renderLanes);
    	  return workInProgress.child;
    }
    

    在这里我们又看到了didReceiveUpdate变量。在这一行代码中判断如果是更新行为(current !== null)且此前在beginWork中设置了didReceiveUpdatefalse的话,则复用fiber节点;同样也是进到bailoutOnAlreadyFinishedWork方法。

  • 如果是Memo组件,则会对新旧props做浅比较,相等的话则会复用[源码]

    const currentChild = ((current.child: any): Fiber);	// 取出唯一的一个child,即我们写在React.memo(...)中的组件
    if (!includesSomeLane(updateLanes, renderLanes)) {	// 当前fiber节点中是没有更新的
        const prevProps = currentChild.memoizedProps;
        
        let compare = Component.compare;
        compare = compare !== null ? compare : shallowEqual;
        if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {	// 浅比较新旧props
            return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
        }
    }
    
  • 其他组件等等......

对于常见的组件类型 如ClassComponent/HostComponent/FunctionComponent/ForwardRef 等,如果没有命中优化手段,最终都会进入都reconcileChildren中。

reconcileChildren函数的内容不多,如下[源码]

function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes
) {
  if (current === null) {		// mount组件时
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes
    );
  } else {						// update组件时
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes
    );
  }
}

beginWork中一样,这里也是根据current === null来区分当前节点是第一次挂载还是更新的。mountChildFibers/reconcileChildFibers创建新的所有子fiber节点,并将第一个子节点赋值给workInProgress.child以此将两者相连起来。

mountChildFibersreconcileChildFibers方法的逻辑差不多一样。主要不同的地方在于mount时根据JSX(也就是上面的nextChildren参数)创建子fiber节点。而update时会将JSX与上次更新时的fiber节点做比较(这个过程就是“众所周知”的Diff算法),根据比较结果生成新的子fiber节点;此外还会在fiber节点上设置flags,即副作用标记。flags通过二进制位的方式存储了需要对fiber节点对应DOM节点执行的操作,例如这些[源码]

// 插入DOM
export const Placement = /*                    */ 0b000000000000000010;
// 更新DOM
export const Update = /*                       */ 0b000000000000000100;
// 插入并更新DOM
export const PlacementAndUpdate = /*           */ 0b000000000000000110;
// 删除DOM
export const Deletion = /*                     */ 0b000000000000001000;

传入reconcileChildFibers方法的前三个参数,分别是workInProgress fiber节点current fiber节点和我们写的组件中render返回的JSX内容。在这里需要清楚一些概念,就是在页面上的DOM节点,每次更新渲染时都有四种节点与之对应:

  1. 页面上的DOM节点
  2. 组件render返回的JSX内容
  3. current fiber节点
  4. workInProgress fiber节点

React的Diff操作要做的其实就是比较【2】和【3】,生成【4】。

进入到reconcileChildFibers方法,也就是Diff操作的入口[源码],看下它的整体流程:

function reconcileChildFibers(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any,
    lanes: Lanes,
  ): Fiber | null {
        // children是否为一个对象
        const isObject = typeof newChild === 'object' && newChild !== null;
        
        if(isObject) {
            // ...调用reconcileSingleElement()
        }
        
        // 为文本节点
        if (typeof newChild === 'string' || typeof newChild === 'number') {
            // ...调用reconcileSingleTextNode()
        }
        
        // 为数组
        if (isArray(newChild)) {
            // ...调用reconcileChildrenArray()
        }
        
        // 上述情况都未命中,则说明需要删除掉子节点
        return deleteRemainingChildren(returnFiber, currentFirstChild);
    }

整体思路就是根据children类型分为单节点和数组节点两种情况;最核心的就是根据节点的keytype判断尽可能地复用子节点,数组情况下会复杂一些因为涉及到了同级节点移动的情况。Diff算法的具体步骤源码在调用reconcileSingleElementreconcileChildrenArray函数中,在这里就不再展开了,讲解React Diff算法的相关文章随便搜索下简直不要太多:)。

一张流程图总结下beginWork的执行流程:

completeUnitOfWork

在上文的的performUnitOfWork函数中我们看到了,作为“递”阶段的beginWork每次执行都会返回next变量,作为下一个要处理的fiber节点。如果当前处理完的fiber节点是个叶子节点,其没有子节点了,那么返回的next则为空:

if(next === null) {
	completeUnitOfWork(unitOfWork);	// 内部会调用completeWork方法
}

于是就该执行该fiber节点的“归”阶段了,即completeUnitOfWork函数[源码]

function completeUnitOfWork(unitOfWork: Fiber): void {
    let completedWork = unitOfWork;
    do {
        const current = completedWork.alternate;
        const returnFiber = completedWork.return;

        let next;
        next = completeWork(current, completedWork, subtreeRenderLanes);
		
	if (next !== null) {	// next不为空的特殊情况
            workInProgress = next;
	    return;
        }

	/* effectList相关的操作,放到最后讲 */
	if (returnFiber !== null && (returnFiber.flags & Incomplete) === NoFlags) {
            // 将当前fiber节点的effectList拼接进父节点的effectList中
            if (returnFiber.firstEffect === null) {
              returnFiber.firstEffect = completedWork.firstEffect;
            }
            if (completedWork.lastEffect !== null) {
              if (returnFiber.lastEffect !== null) {
                returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
              }
              returnFiber.lastEffect = completedWork.lastEffect;
            }
            
            // 如果当前fiber节点有副作用,将当前节点拼接进父节点的effectList中
            const flags = completedWork.flags;
            if (flags > PerformedWork) {
              if (returnFiber.lastEffect !== null) {
                returnFiber.lastEffect.nextEffect = completedWork;
              } else {
                returnFiber.firstEffect = completedWork;
              }
              returnFiber.lastEffect = completedWork;
            }
        }
	/* --- */

	const siblingFiber = completedWork.sibling;	// 下一个兄弟节点
        if (siblingFiber !== null) {
          workInProgress = siblingFiber;
          return;
        }
        completedWork = returnFiber;	// 如果没有兄弟节点,则“归”到父节点
        workInProgress = completedWork;
    } while (completedWork !== null);
}

对fiber节点“归”阶段要执行的主要工作是在completeWork方法中实现的。completeUnitOfWork这里只是负责对fiber节点调用completeWork方法并返回next变量。

绝大部分情况下next都为null。因为根据深度优先遍历的规则,当前节点遍历完了说明其子树中的节点一定也都处理完了,下一步应该是开始下一个兄弟节点的“递”阶段:

const siblingFiber = completedWork.sibling;	// 下一个兄弟节点
if (siblingFiber !== null) {
    workInProgress = siblingFiber;
    return;
}

如果没有兄弟节点了,按照规则应该是返回上一层的父节点并执行它的“归”操作。这里将父节点赋值给completedWork变量:completedWork = returnFiber,并在下一轮do while循环中开始这个父节点的“归”操作。

但凡事都有意外。如果返回的next变量不为null

if (next !== null) {	// next不为空的特殊情况
    workInProgress = next;
    return;
}

next变量赋值给workInProgress,开始进入到next表示节点的“递”阶段。这里的特殊情况就是指Suspense组件懒加载时的场景(在下一节的completeWork方法中笔者会标记出其位置)。Suspense组件完成了“归”阶段的工作,但由于组件未加载完成因此需要重渲染Suspense.fallback的内容,所以要重新进入到节点的“递”阶段。

completeWork

让我们进入到completeWork的实现中来[源码]

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
      switch (workInProgress.tag) {
          case LazyComponent:
          case SimpleMemoComponent:
          //...
          case FunctionComponent:
          case MemoComponent:
          case ClassComponent:
            return null;

          case HostComponent: {
              // ...此处先省略
          }

          case SuspenseComponent: {
              if ((workInProgress.flags & DidCapture) !== NoFlags) {	// Suspense下的组件未加载时会抛出异常并被捕获
                  workInProgress.lanes = renderLanes;
                  return workInProgress;
              }
              // ...
              return null
          }
          // ...
      }
  }

对于常见的组件类型FunctionComponent、MemoComponent和ClassComponent等并没有什么特别的操作,都是直接返回null

对于SuspenseComponent类型的组件,如果(workInProgress.flags & DidCapture) !== NoFlags成立,说明懒加载未完成,返回当前的fiber节点;否则返回null。这里便是与上节completeUnitOfWork方法中next变量不为空的特殊情况相照应的地方。

重点关注HostComponent类型(原生DOM对应的fiber节点类型)的处理:

case HostComponent: {
    const rootContainerInstance = getRootHostContainer();
    const type = workInProgress.type;
    
    if (current !== null && workInProgress.stateNode != null) {	// update时的情况
        updateHostComponent(
          current,
          workInProgress,
          type,
          newProps,
          rootContainerInstance,
        );
    } else {
        // mount时的情况
        const currentHostContext = getHostContext();
        // 创建DOM节点
        const instance = createInstance(
            type,
            newProps,
            rootContainerInstance,
            currentHostContext,
            workInProgress,
        );
        // 将子孙DOM节点插入到新的DOM节点下
        appendAllChildren(instance, workInProgress, false, false);

        workInProgress.stateNode = instance;
 
        // 设置DOM节点上的属性
        if (
            finalizeInitialChildren(
              instance,
              type,
              newProps,
              rootContainerInstance,
              currentHostContext,
            )
         ) {
            markUpdate(workInProgress);
        }
    }
}

在这里区分mount还是update时的条件和前面的不一样了,不再只是判断current === null ?了,还多了一条workInProgress.stateNode != null。因为我们当前处理的是HostComponent类型,因此判断是否为更新除了要有fiber节点(current),还得有对应的DOM节点(workInProgress.stateNode)。

对照上述的代码捋下mount和update做的工作。

mount:

  1. 创建fiber节点对应的DOM节点。

  2. 将子孙DOM节点插入到刚刚新创建的DOM节点中。我们知道作为“归”阶段的complete是自底向上完成的,所以在处理当前fiber节点workInProgress时,workInProgress的所有子fiber节点所对应的DOM节点一定是都已经创建好了的。

  3. 创建好的DOM节点赋值给workInProgress.stateNode

  4. 执行finalizeInitialChildren函数,最终进入到setInitialDOMProperties中(路径[finalizeInitialChildren -> setInitialProperties -> setInitialDOMProperties])。该函数将节点上的属性prop设置到DOM上,如style样式属性、children属性描述的内部文本/数值内容、各种自定义属性以及注册事件处理等。

update:

update时的工作相对就简单多了,就是执行updateHostComponent[源码]方法:

updateHostComponent = function(
	current: Fiber,
    workInProgress: Fiber,
    type: Type,
    newProps: Props,
    rootContainerInstance: Container
) {
    const oldProps = current.memoizedProps;
    if (oldProps === newProps) {
      return;
    }
    
    const instance: Instance = workInProgress.stateNode;
    const currentHostContext = getHostContext();
    const updatePayload = prepareUpdate(
      instance,
      type,
      oldProps,
      newProps,
      rootContainerInstance,
      currentHostContext,
    );

    workInProgress.updateQueue = (updatePayload: any);
}

与mount时的第4步类似,也是负责处理DOM节点上的属性。但不同的地方在于,updateHostComponent中只是做了事件监听的注册和属性的预处理:const updatePayload = prepareUpdate(...);然后将需要更新的props内容赋值到fiber节点的updateQueue上:workInProgress.updateQueue = updatePayload。最终在React渲染的commit阶段才会将prop更新真正应用到DOM节点上。

effectList

completeUnitOfWork函数体的中间有一大段关于effectList的操作。此处做的操作就是将当前fiber节点以及子树中有更改的节点(即effectList)“拼接”进父节点的effectList

我们从前文的beginWork执行过程中知道,对整棵fiber树进行完“递”阶段后,所有存在变更的fiber都被标记上flags表明是何种变更。在之后的commit阶段React需要对所有存在变更的fiber节点执行对应的DOM操作的话,如果再完全遍历一次fiber树的话是不太可取的。

因此React做法是将所有存在flags的fiber节点都存放在单向链表结构的effectList中。对照代码,来看下实际的实现:

  • 当前fiber节点有两个变量,firstEffect指向链表的第一个元素,lastEffect指向链表的最后一个元素。这条链表上包含了子树中所有标记了flags的子孙节点。这一条链表就是effectList

  • completeUnitOfWork函数中处理当前fiber节点,通过操作父节点的firstEffectlastEffect变量,将当前fiber节点的effectList拼接进父节点的effectList。示意图如下:

  • 如果当前fiber节点也存在变更,则还需要将当前节点放到父节点effectList的末尾:

  • 如果对整棵fiber树自底向上执行完completeUnitOfWork后,root节点的effectList就是一条拥有整棵fiber树上所有带有变更的fiber节点所组成的链表了。在之后的commit阶段需要将所有fiber节点的变更应用到页面DOM上时,只需要遍历root节点的effectList即可。

在后续的commit阶段有许多操作是需要遍历effectList。我们由上述生成effectList的过程可以知道,遍历effectList中fiber节点的顺序 对应fiber树中的结构是自底向上的,从子孙节点到父节点,再到祖先节点直至root根节点。在后面的解析内容中会有地方再次提到该知识点。

render阶段完成

至此,对于整棵fiber树的递(beginWork)和归(completeUnitOfWork)操作都已完成。现在把目光再拨回到fiber树更新的起点,performSyncWorkOnRoot/performConcurrentWorkOnRoot函数中:在执行完render阶段的入口renderRootSync/renderRootConcurrent后,如果render阶段正常完成,最终它们都会调用commitRoot(root),开启commit阶段。

commit阶段

看进commitRoot的实现[commitRoot -> commitRootImpl]中,函数体非常的长。在React源码的注释中,是将commit阶段划分为以下三个阶段并命名的:

  • before mutation阶段(执行DOM操作之前)
  • mutation阶段(在此阶段执行DOM操作)
  • layout阶段(执行DOM操作之后)

笔者也按这三个阶段来分别讲解,并尽可能地简化代码,只保留比较重要的代码部分,标记出其所在commitRootImpl方法中对应哪些行数。

在before mutation之前

在正式开始commit阶段之前,还有一些额外的工作要做。主要是清理上一次渲染产生的回调任务以及获取完整的effectList[commitRootImpl中1889-1988行]

do {
    flushPassiveEffects();	// 触发useEffect回调和其他同步任务;由于这些任务中可能再触发新的重渲染,因此需要在while循环中执行至没有任务为止
} while (rootWithPendingPassiveEffects !== null);

const finishedWork = root.finishedWork;
const lanes = root.finishedLanes;

// 获取所有存在更改的fiber节点 - effectList
let firstEffect;
if (finishedWork.flags > PerformedWork) {
    if (finishedWork.lastEffect !== null) {
        // effectList的链表操作,将有flags的根节点加入到末尾
        finishedWork.lastEffect.nextEffect = finishedWork;
        firstEffect = finishedWork.firstEffect;
    } else {
        firstEffect = finishedWork;
    }
} else {	// 根节点没有副作用
    firstEffect = finishedWork.firstEffect;
}

if (firstEffect !== null) {
    /*
    	...
    	此处有三个while循环分别对应三个阶段的工作
    	...
    */
}

最开始的代码是在while循环中重复执行flushPassiveEffects方法,直至effect任务队列为空。之所以要这样设计,是因为会有可能两次更新渲染是同步执行的。例如 第一次更新渲染后会产生各个组件中的useEffect回调任务,这些任务将会作为”宏任务“(React内部是通过MessageChannel[MDN]实现的)放在下一轮事件循环中执行。 接着同步执行第二次更新渲染,这个时机是在第一次更新产生的那些“宏任务”执行之前的,所以需要在commit阶段开始前就把上一轮更新产生的副作用先给执行完了。

这个设计对深入理解useEffect的执行时机非常重要,来看下这段代码:

let counter = 0

function App(props: any) {
  const [name, setName] = useState('')

  useEffect(() => {
    console.log(counter)
  })

  const click = () => {
    Promise.resolve().then(() => {
      ++counter
      setName('one')
    })

    Promise.resolve().then(() => {
      ++counter
      setName('two')
    })
  }

  return <div onClick={click}>{name}</div>
}

触发点击事件后,执行的两个微任务中都有更新操作。useEffect的回调方法中会打印counter变量;最终的输出结果是:2, 2

  1. 在第一个微任务中的更新完成后,counter为1,并产生一个useEffect的回调在任务队列中。

  2. 接着开始第二个微任务(useEffect回调是宏任务,所以肯定在它之前),counter为2。在完成第二次更新的render阶段但在开始commit阶段之前,会进入前面说的flushPassiveEffects()循环部分。

  3. flushPassiveEffects方法中提前执行了第1步中产生的useEffect回调,打印counter: 2

  4. 第二次更新产生的useEffect回调放入任务队列,在下一次事件循环的宏任务中执行打印counter: 2

before mutation阶段[commitRootImpl中1990-2033行]

if (firstEffect !== null) {
    // 给执行上下文加上CommitContext,标记当前处于commit阶段
    const prevExecutionContext = executionContext;
    executionContext |= CommitContext;
    
    nextEffect = firstEffect;
    do {
        try {
            commitBeforeMutationEffects();	// before mutation阶段做的工作都在该函数中
        } catch (error) {
            // ...处理异常...
            nextEffect = nextEffect.nextEffect;
        }
    } while (nextEffect !== null);
    
    
    // ...mutation阶段
    
    // ... layout阶段
}

before mutation阶段的代码很短,就是在while循环中执行commitBeforeMutationEffects方法。正常情况下commitBeforeMutationEffects只需要执行一次就可以了,这里将其放在while循环中是为了在执行过程中发生异常时可以在try catch块中捕获到,并执行nextEffect = nextEffect.nextEffect语句,跳过effectList中发生异常的这个节点到下一个,然后再次执行commitBeforeMutationEffects方法。

进入到commitBeforeMutationEffects函数中的实现[源码]

function commitBeforeMutationEffects() {
    while (nextEffect !== null) {
        const current = nextEffect.alternate;
        
        if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
            // 处理DOM的focus、blur相关的操作
        }
        
        const flags = nextEffect.flags;
        // 该fiber节点需要调用getSnapshotBeforeUpdate生命周期钩子
        if ((flags & Snapshot) !== NoFlags) {
          // 该方法内会调用ClassComponent实例的getSnapshotBeforeUpdate方法
          commitBeforeMutationEffectOnFiber(current, nextEffect);
        }
        
        /* 存在“passive effects”,翻译过来就是“被动”的副作用,意指那些通过监听状态(依赖数组)变化产生的作用,如最常见的useEffect */
        if ((flags & Passive) !== NoFlags) {
          if (!rootDoesHavePassiveEffects) {
            // 下面flushPassiveEffects会调用所有的useEffect回调,因此只要执行一次调度即可。用该变量标记是否调度过
            rootDoesHavePassiveEffects = true;
            
            // 调度flushPassiveEffects
            scheduleCallback(NormalSchedulerPriority, () => {
              flushPassiveEffects();
              return null;
            });
          }
        }
        nextEffect = nextEffect.nextEffect;
    }
}

commitBeforeMutationEffects函数会遍历effectList,对每个fiber节点按顺序下来做了这么三件事:

  1. 处理页面DOM节点在更新渲染或删除后的聚焦(focus)/失焦(blur)状态。

  2. React 16版本之后,由于componentWillXXX系列的生命周期钩子会在更新渲染中触发多次,这对于开发来说不安全,因此提供了一个新的生命周期钩子:getSnapshotBeforeUpdate。类组件ClassComponents发生更新后,会在完成render阶段后但在commit阶段执行DOM操作之前调用这个生命周期钩子。

  3. 调度flushPassiveEffects方法,该方法使得在浏览器完成绘制后(layout阶段之后)再调用useEffect回调。

    • flushPassiveEffects[源码]的实现中,会先执行完所有的上一次更新渲染中useEffect返回的销毁函数后,再开始执行所有的本次更新后产生的useEffect回调函数

mutation阶段

对于页面上DOM的实际操作都是发生在mutation阶段。

mutation阶段的外层代码[commitRootImpl中2045-2070行]和before mutation阶段类似:

nextEffect = firstEffect;
do {
    try {
        commitMutationEffects(root, renderPriorityLevel);	// mutation阶段做的工作都在该函数中
    } catch (error) {
        // ...处理异常...
        nextEffect = nextEffect.nextEffect;
    }
} while (nextEffect !== null);

进入到commitMutationEffects函数中的实现[源码]

function commitMutationEffects(root: FiberRoot, renderPriorityLevel: ReactPriorityLevel) {
    while (nextEffect !== null) {
        const flags = nextEffect.flags;
        
        // 重置对应DOM节点的文本内容
        if (flags & ContentReset) {
            commitResetTextContent(nextEffect);
        }
        
        // 处理ref
        if (flags & Ref) {
            const current = nextEffect.alternate;
            if (current !== null) {
                commitDetachRef(current);
            }
        }
        
        // 根据flags类型分别做处理
        const primaryFlags = flags & (Placement | Update | Deletion | Hydrating);
        switch (primaryFlags) {
            case Placement: {
                commitPlacement(nextEffect);
                nextEffect.flags &= ~Placement;
                break;
            }
            case PlacementAndUpdate: {
                commitPlacement(nextEffect);
                nextEffect.flags &= ~Placement;
                
                const current = nextEffect.alternate;
                commitWork(current, nextEffect);
                break;
            }
            /* 服务端渲染相关
            case Hydrating: {...}
            case HydratingAndUpdate: {...}
            */
            case Update: {
                const current = nextEffect.alternate;
                commitWork(current, nextEffect);
                break;
            }
            case Deletion: {
                commitDeletion(root, nextEffect, renderPriorityLevel);
                break;
            }
        }
        
        nextEffect = nextEffect.nextEffect;
    }
}

commitMutationEffects函数遍历effectList,对每个fiber节点按顺序下来做以下三件事情:

  1. fiber节点存在有ContentReset标记的话,需要将对应DOM节点中的文本置空。发生这种情况的判断逻辑在beginWork阶段对HostComponent类型的处理函数updateHostComponent中。

  2. 如果此次更新渲染需要挂载ref且上一次渲染时也挂载了ref,则需要执行commitDetachRef。该函数做的事情就是先将上一次渲染时挂载的ref(current.ref.current)置为null。至于此次渲染需要挂载的ref,是在后续的layout阶段完成的。

  3. 根据flags分类做不同的操作:

    • 插入操作Placement。调用commitPlacement函数[源码]完成DOM的插入。

    • 更新操作Update。调用commitWork函数[源码]。这里有一些需要关注的地方。

      function commitWork(current: Fiber | null, finishedWork: Fiber): void {
          switch(finishedWork.tag) {
              case FunctionComponent:
              case MemoComponent:
              // ...
              {
                  // ...
                  commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork);
                  return;
              }
              case ClassComponent: {
                  return;
              }
              case HostComponent: {
                  const instance: Instance = finishedWork.stateNode;
                  if (instance != null) {
                      const newProps = finishedWork.memoizedProps;
                      const oldProps = current !== null ? current.memoizedProps : newProps;
                      const type = finishedWork.type;
                      const updatePayload: null | UpdatePayload = (finishedWork.updateQueue: any);
                      finishedWork.updateQueue = null;
                      if (updatePayload !== null) {
                          commitUpdate(
                              instance,
                              updatePayload,
                              type,
                              oldProps,
                              newProps,
                              finishedWork,
                          );
                      }
                  }
                  return;
              }
          }
      }
      
      • 对于函数组件FunctionComponent类型的fiber节点,会调用commitHookEffectListUnmount函数。该函数[源码]会遍历fiber节点的updateQueue,执行完所有useLayoutEffect的销毁函数。由effectList中的fiber节点顺序可知,当子孙节点的mutation 阶段完成后才会轮到父节点;因此,在执行函数组件中的useLayoutEffect销毁函数时,该函数组件下对应的DOM的更新操作是已经完成了的。因此,在销毁函数中可以访问到更新后的这部分DOM内容,但不推荐这么做。

      • 对于DOM元素HostComponent类型的fiber节点,取出节点上的updateQueue来执行commitUpdate函数。我们在前文讲解completeWork函数中对HostComponent处理时就说到,对DOM节点的style、文本子节点、自定义属性等的更新内容都保存在了fiber节点的updateQueue字段上。而commitUpdate函数要做的就是将updateQueue中的更新实际应用到DOM节点上面。

    • 删除操作Deletion,需要将fiber节点对应的DOM移除掉。调用commitDeletion函数[源码]

      function commitDeletion(
        finishedRoot: FiberRoot,
        current: Fiber,
        renderPriorityLevel: ReactPriorityLevel
      ): void {
        // Recursively delete all host nodes from the parent.
        // Detach refs and call componentWillUnmount() on the whole subtree.
        unmountHostComponents(finishedRoot, current, renderPriorityLevel);
      
        // 利用detachFiberMutation函数将fiber节点中的属性清空
        const alternate = current.alternate;
        detachFiberMutation(current);
        if (alternate !== null) {
          detachFiberMutation(alternate);
        }
      }
      

      调用unmountHostComponents函数,然后清空fiber节点属性。unmountHostComponents的函数体比较长,就不罗列出来了,其工作就是利用while模拟递归来循环调用fiber节点及其子孙节点 并执行以下操作[源码]

layout阶段

layout阶段的外层代码同上面两个阶段一样[commitRootImpl中2081-2105行]。在该阶段触发的生命周期钩子和hook都可以安全地访问到所有更新后页面上的DOM(在mutation阶段中提到过 在当时执行的useLayoutEffect销毁函数中可以访问到更新后的DOM,但只是其fiber节点对应的那一部分DOM更新后的内容,并不能确保整个页面上的DOM是最新的)。

// 切换current fiber树和workInProgress fiber树
root.current = finishedWork;

nextEffect = firstEffect;
do {
    try {
        commitLayoutEffects(root, lanes);	// layout阶段做的工作都在该函数中
    } catch (error) {
        // ...处理异常...
        nextEffect = nextEffect.nextEffect;
    }
} while (nextEffect !== null);

在文章开头的双缓冲fiber树中讲过,每当完成一次更新渲染后,就会交换current fiber树workInProgress fiber树,执行这项工作的语句正是上面代码的第一行:root.current = finishedWork。时机是mutation阶段之后,layout阶段开始之前。

进入到commitLayoutEffects函数中的实现[源码]

function commitLayoutEffects(root: FiberRoot, committedLanes: Lanes) {
    while (nextEffect !== null) {
        const flags = nextEffect.flags;
        
        // 触发生命周期钩子和hook
        if (flags & (Update | Callback)) {
          const current = nextEffect.alternate;
          commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes);
        }
        
        // 挂载ref
        if (flags & Ref) {
          commitAttachRef(nextEffect);
        }
        
        nextEffect = nextEffect.nextEffect;
    }
}

commitLayoutEffects函数遍历effectList,对每个fiber节点做两件事情:

  1. 调用commitLayoutEffectOnFiber函数
  2. 挂载ref

重点关注commitLayoutEffectOnFiber函数[源码]

function commitLifeCycles(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {
      switch(finishedWork.tag) {
          case FunctionComponent:
          case ForwardRef:
          case SimpleMemoComponent:
          case Block: {
              // 执行useLayoutEffect的回调函数
              commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
              // 分别收集useEffect的销毁函数和回调函数,并再次开启调度
              schedulePassiveEffects(finishedWork);
              return;
          }
          case ClassComponent: {
              // 调用Class组件的生命周期钩子
              const instance = finishedWork.stateNode;
              if (finishedWork.flags & Update) {
                  if (current === null) {
                      instance.componentDidMount();
                  } else {
                      const prevProps =
                            finishedWork.elementType === finishedWork.type
                      ? current.memoizedProps
                      : resolveDefaultProps(finishedWork.type, current.memoizedProps);
                      const prevState = current.memoizedState;

                      instance.componentDidUpdate(
                          prevProps,
                          prevState,
                          instance.__reactInternalSnapshotBeforeUpdate,
                      );
                  }
              }
              // 执行setState中的第二个参数回调函数
              const updateQueue = finishedWork.updateQueue;
              if (updateQueue !== null) {
                  commitUpdateQueue(finishedWork, updateQueue, instance);
              }
          }
      }
  }
  • 对于函数组件FunctionComponent,执行所有的useLayoutEffect的回调函数。
    然后会分别收集useEffect的销毁和回调函数到pendingPassiveHookEffectsUnmountpendingPassiveHookEffectsMount变量中[源码]。并且 还会再次开启useEffect的调度,这里的代码同前面before mutation阶段开启useEffect调度的方式一样:

    if (!rootDoesHavePassiveEffects) {
        rootDoesHavePassiveEffects = true;
        scheduleCallback(NormalSchedulerPriority, () => {
          flushPassiveEffects();
          return null;
        });
    }
    

    都是通过判断rootDoesHavePassiveEffects全局变量来判断是否要开启调度;因此这两个阶段的开启操作是互斥的。

    正是因为useLayoutEffect回调函数是在mutation阶段(DOM操作)之后同步执行而useEffect调度后异步执行的,所以可以用useLayoutEffect这个来解决useEffect回调中操作DOM会有闪屏的问题。

  • 对于类组件ClassComponent,需要区分是mount还是update,然后执行生命周期钩子componentDidMountcomponentDidUpdate
    然后取出fiber节点上的updateQueue,调用其中的回调函数如setState的第二个参数中传入的函数。

  • 此外还有一些其他类型的处理,如对于HostComponent类型如果是mount情况会处理其自动聚焦的状态、对于HostRoot,会执行根节点渲染的回调函数 即ReactDOM.render(<App/>, container, () => { ... })中的第三个参数。

至此,layout阶段完结了,整个React从触发更新至更新渲染完成的流程也结束了~