React源码分析 - 详解 Commit 阶段

2,648 阅读13分钟

作者:来对鸡翅谢谢

文章作者授权本账号发布,未经允许请勿转载

在上篇文章中,我们初步探索了 Hooks 系统的实现,对其底层的运行机制有了一定的了解,并遗留了一个TODO,useEffect 的刷新时机。本篇文章主要介绍 React 处理组件的流程,并详细介绍在 commit 阶段中,React每一步的处理逻辑,中间也会具体描述 useEffect 的刷新时机。

React16 引入 Fiber 架构后,将整个调度分为了两个阶段 render & commit,在 render 阶段,React 会计算 DOM 的更新,并将所有需要更新的 fiber 整理成一个 effect list,在 commit 阶段中, React 会遍历 effect list 执行所有的副作用,期间会执行更新相关的生命周期、挂载 DOM 等等。

commit 前置工作

以如下代码为例

const Child = () => {
  const [childCount, setChildCount] = useState(0);

  useLayoutEffect(() => {
    setChildCount(1);
  }, [])

  useEffect(() => {
    setChildCount(2);
    setChildCount(3);
  }, []);

  console.count('childRender');

  return (
    <span>{childCount}</span>
  )
}


const App = () => {
  const [count, setCount] = useState(0);

  useLayoutEffect(() => {
    setCount(1);
  }, [])

  useEffect(() => {
    setCount(2);
    setCount(3);
  }, []);

  console.count('render');

  return (
    <>
      <span>{ count }</span>
      <Child />
    </>
  );
}

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

在进入 commit 阶段前,我们得到的 effect list 如下。

image-20201123193841203

首先 React 会创建一个 FiberRoot 作为整个组件树的顶层节点 , finishedWork 属性指向的是完成的第一个 work,其实就是传递给 ReactDOM.render 的组件,在示例代码中就是 App 组件,每一个 fiber 都具有几个和 effect 相关的属性,指向具有 side effectsfiber

  • firstEffect:指向 effect list 的第一个 fiber
  • nextEffecteffect list 的指针,指向下一个 effect fiber

当进入 commit 阶段时, React 首先会根据根节点拿到 finisedWork,需要处理的 effect 链表已经在 render 阶段挂载到 firstEffect 属性上,那么遍历的代码就能想象得到。

const effect = fiberRoot.finishedWork.firstEffect;
while (effect) {
  // ...
  effect = effect.nextEffect
}

和上方思路类似, React 将整个 commit 阶段拆分成了多个小阶段,在每一个小阶段上, React 会执行上述的遍历流程 ,按照官方注释来说,这样可以保证所有的突变 effect (即 useEffect)可以在 布局 effect(即 useLayoutEffect) 之前被处理。 commit 流程的主要代码在 ReactFiberWorkLoop.js 文件中

function commitRootImpl() {
  // 刷新所有的 PassiveEffect
  do {
    flushPassiveEffects();
  } while (rootWithPendingPassiveEffects !== null);
  
  // Get the list of effects.
  // effectList 的第一个节点
  let firstEffect;
  // ...
  // 省略 if 判断,如果 root 有副作用的话,其副作用将会放置在 effectList 的末尾,root 无副作用的话,那么 firstEffect 就是根组件的 firstEffect
  firstEffect = finishedWork.firstEffect;
  if (firseEffect !== null) {
    nextEffect = firstEffect;
    // 每一阶段的详细代码后续会进行说明
    // 第一阶段,before mutation
    do {
      commitBeforeMutationEffects();
    } while(nextEffect !== null)
    // ...
    // 将游标重置,指向 effect list 头
    nextEffect = firstEffect;
    // 第二阶段 mutation
    do {
      commitMutationEffects(root, renderPriorityLevel);
    } while(nextEffect !== null)
    	
    // 将当前的 workInProgress树 作为 current 树
    root.current = finishedWork;
    
    // ...
  	// 第三阶段 layout 
    do {
      commitLayoutEffects(root, expirationTime);
    } while(nextEffect)
            
    // 让调度器在 帧 的末尾暂停,给浏览器机会执行一次 重绘
    requestPaint();
    
    // rootDoesHavePassiveEffects 标志位判断,该标志位是在 commit 第一阶段进行设置,标记当前 commit 是否具有 passiveEffect
    if (rootDoesHavePassiveEffects) {
      rootDoesHavePassiveEffects = false;
    	rootWithPendingPassiveEffects = root;
    	pendingPassiveEffectsExpirationTime = expirationTime;
    	pendingPassiveEffectsRenderPriority = renderPriorityLevel;
    } else {
      // 遍历 effect list 逐个设置为 null 以便 GC
    }
    
    // 确保 root 上所有的 work 都被调度完
    ensureRootIsScheduled(root);
    
    // 检测在 useLayoutEffect 中是否做了布局修改等,刷新布局,如果在 layoutEffect 中调用了 setState 也会在该函数中检测中并开启新的一轮调度
    // 原版注释: If layout work was scheduled, flush it now.
    flushSyncCallbackQueue();
  } else { ... }
}

从上方代码我们可以了解到,一个完整的 commit 流程被分为了三个子阶段,在三个子阶段中还会穿插一些额外的检测,下面我们依次分析这几个阶段和一些比较重要的标志位。

第一阶段 before Mutation

function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    const effectTag = nextEffect.effectTag;
    // 对于使用 getSnapShowBeforeUpdate 的组件 fiber.effectTag |= SnapShot
    if ((effectTag & Snapshot) !== NoEffect) {
      // ...
      const current = nextEffect.alternate;
      // 执行 getSnapShotBeforeUpdate 生命周期
      commitBeforeMutationEffectOnFiber(current, nextEffect);
      // ...
    }
    // 对于使用 useEffect 的组件,其 Fiber.effectTag = UpdateEffect | PassiveEffect
    if ((effectTag & Passive) !== NoEffect) {
      // If there are passive effects, schedule a callback to flush at
      // the earliest opportunity.
      if (!rootDoesHavePassiveEffects) {
        rootDoesHavePassiveEffects = true;
        // 安排一个回调
        scheduleCallback(NormalPriority, () => {
          flushPassiveEffects();
          return null;
        });
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}

上述代码代码分为了两个部分,通过遍历 effect list,判断每一个 effect 的副作用类型。

  • Class 组件且使用了 getSnapShotBeforeUpdate
    • 进入第一个判断,在内部会执行 getSnaoShotBeforeUpdate 生命周期,也只有 Class 组件会进入这个判断
  • 函数式组件且使用了 useEffect
    • 标记 rootDoesHavePassiveEffects 标志位,在 commit 阶段的末尾会判断当前 commit 是否具有被动的副作用,有的话会设置一些额外的标志位用于下一轮的调度。
    • 安排一个回调,回调内部会触发 flushPassiveEffects

首先这边可以看到 React 会安排一个回调,这个回调会被异步触发,这边所说的异步并不是指 setState 那样的批处理,而是 eventLoop 中的同步任务、异步任务。我们简单研究一下这个创建回调的函数。

// Scheduler.js
 function unstable_scheduleCallback(priorityLevel, callback, options) {
   // ...
   var newTask = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  // ...
   if (startTime > currentTime) {
     //... 超时调用
   } else {
     // 正常调用
     push(taskQueue, newTask);
     if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      // 发送一个 postMessage
      requestHostCallback(flushWork);
    }
   }
 }

// ScheduleHostConfig.default.js
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;

requestHostCallback = function(callback) {
	scheduledHostCallback = callback;
	if (!isMessageLoopRunning) {
		isMessageLoopRunning = true;
		port.postMessage(null);
	}
};

在上述代码中, React 会创建一个 task,这个对象上保存着我们传入的回调,并且 task 会被压入到一个任务队列 taskQueue 中,在还有剩余时间时, React 会调用 postMessageperformWorkUntilDeadline 函数压入异步任务队列中,等待所有的同步任务都执行完之后执行该函数。

在这里 React 借助浏览器原生的 MessageChannel 来实现异步刷新, MessageChannel 实例化会得到两个端口,一种一个端口用来绑定 onmessage 事件,另一个端口则通过调用 postMessage 异步触发 onmessage

上述代码中还有一个比较关键的函数, flushPassiveEffects,从命名上看,这是用来刷新被动副作用的,也就是刷新 useEffect ,这个方法在很多的地方被调用,比如调度入口、 commit 阶段的入口、 postMessage 异步回调中等。该函数的解析我们放在下面叙述。

从第一阶段来看,我们了解到 useEffect 的其中一个刷新时机,就是异步任务队列中,并且是宏任务队列。

第二阶段 Mutation

function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
  while (nextEffct) {
    // ...
    const effectTag = nextEffect.effectTag;
    let primaryEffectTag = effectTag & (Placement | Update | Deletion | Hydrating);
    // ...
    switch(primaryEffectTag) {
      // 单纯的挂载 DOM
      case Placement: {
        // 挂载 DOM
        commitPlacement(nextEffect);
        nextEffect.effectTag &= ~Placement;
        return; 
      }
      // 更新组件及DOM
      case PlacementAndUpdate: {
        // 挂载 DOM
        commitPlacement(nextEffect);
        nextEffect.effectTag &= ~Placement;
        // 刷新 layoutEffect.desotry
        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      // 更新组件
      case Update: {
        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      // 卸载
      case Deletion: {
        commitDeletion(root, nextEffect, renderPriorityLevel);
        break;
      }
      // ...
    }
    nextEffect = nextEffect.nextEffect;
  }
}

上述为第二阶段的大致代码, React 会根据当前组件的 effectTag 与上可能的标志位,得到最终的标志位,这一点和 Hooks 中判断某一个 effect 是否执行是一样的。 React 将产生副作用的原因做了分类,在第二阶段主要用到了如下分类:

  • Placement:放置 DOM, 适用于只有 DOM 变化的场景
// 单纯的渲染DOM,没有任何组件更新
const App = () => {
	return <span>Placement Effect</span>
}
  • PlacementAndUpdate:放置 DOM,并更新组件

    // 当 App 挂载时,或者 count 状态更新时,不仅仅是组件更新,DOM也会发生变化
    const App = () => {
      const [count, setCount] = useState(0);
      
      return <span>{ count } PlacementAndUpdate</span>
    }
    
  • Update :组件更新,但是 DOM 无变化

    // 当 count 变化时,仅仅是 App 组件发生了更新,但是 DOM 没有变化
    const App = () => {
      const [count, setCount] = useState(0);
      
      return <span>Update</span>
    }
    
  • Deletion:组件卸载

以上不同的标志位,内部逻辑都比较相似,主要函数为 commitPlacementcommitWork

// 挂载 DOM
function commitPlacement(finishedWork: Fiber): void {
  // ...
  // 当前 Fiber 对应的 stateNode 的父节点
  const parentFiber = getHostParentFiber(finishedWork);
  let parent;
  let isContainer;
  // ...省略 对 parentFiber.tag 的Switch 判断,根据不同类型的 fiber 对 parent isContainer 赋值
  const before = getHostSibling(finishedWork);
  if (isContainer) {
    insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
  } else {
    insertOrAppendPlacementNode(finishedWork, before, parent);
  }
}

commitPlacement 函数做的事就是将变化的DOM都挂载到页面上,主要操作分为 insertBeforeappendChild ,并且 fiber.stateNode 都保留了之前已经挂载好的DOM的引用。因此对于在同一个DOM下交互子节点的顺序,并不是直接卸载之前的DOM,而是直接使用 insertBefore

Node.insertBefore() 方法在参考节点之前插入一个拥有指定父节点的子节点。如果给定的子节点是对文档中现有节点的引用,insertBefore() 会将其从当前位置移动到新位置(在将节点附加到其他节点之前,不需要从其父节点删除该节点)。

而另一个主要函数 commitWork 的调用顺序永远在 commitPlacement 之后,其语义是等待布局更新后,需要开启新的一轮 layoutEffect,但是在此之前需要卸载上一轮的 layoutEffect,而 commitWork 的作用就是清除上一轮 layoutEffect 的副作用,清除操作也就是调用 useLayoutEffect 第一个参数的返回值, React 使用 destory 来命名。对于函数式组件而言,内部主要函数为 commitHookEffectListUnmount

// tag = HookLayout | HookHasEffect
function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
  const updateQueue = finishedWork.updateQueue;
  // 获取该 Fiber 下的 effect list
  let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & tag) === tag) {
        // Unmount
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          destroy();
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

在上篇文章中,我们提到组件内调用的 Hooks API 会生成 hook 对象,并且对于具有副作用的 hook 还会额外生成 effect 对象,组件内所有的 effect 相连形成一个 effect 链表,挂载到 fiber.updateQueue.lastEffect 中。

那么这里的逻辑就是从 updateQueue.lastEffect 中取出副作用链表依次遍历调用 effect.desotry 方法。

第三阶段 Layout

在第二阶段中,需要更新的DOM都已挂载到页面上,从生命周期上讲,这个时候需要调用组件的 componentDidMount 生命周期,调用过程就是第三阶段做的事。

function commitLayoutEffects(root, committedExpirationTime) {
 while (nextEffect !== null) {
   // ...
   const effectTag = nextEffect.effectTag;
   if (effectTag & (Update | Callback)) {
      recordEffect();
      const current = nextEffect.alternate;
     // didmount
      commitLayoutEffectOnFiber(
        root,
        current,
        nextEffect,
        committedExpirationTime,
      );
    }
   // ...
 } 
}

function commitLayoutEffectOnFiber(finishedRoot, current, finishedWork, committedExpirationTime) {
  switch(finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent:
    case Block: {
      // 刷新 layoutEffect
      commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork); 
      // ...
      return;
    }
		case ClassComponent: {
      // didMount
    }  
  }
}

从上述代码我们可以了解到,对于函数式组件而言,主要调用 commitHookEffectListMount,和上方 commitHookEffectListUnmount 逻辑类似,都是从 updateQueue.lastEffect 中取出副作用链表,区别在于本次是调用 effect.create(), 并且将返回值赋值给 effect.desotry,等待下一轮调度前刷新。

commit 末尾

在上述三个子阶段都完成之后, React 已经将所有的DOM都挂载到屏幕上,并且也执行了 didMount,但是在上述三个子阶段的执行过程中,可能会发生新的副作用,比如说在 layoutEffect 中调用了 setState ,因此在 commit 的末尾会做一次检测,入口函数就是 flushSyncCallbackQueue

function commitRootImpl(root, renderPriorityLevel) {
  // ...
  // first stage: before Mutation
  // second stage: Mutation
  // 将 workInProgress树变为 current 树
  root.current = finisedWork;
  // third stage: Layout
  // 在第一阶段中被赋值,赋值依据有需要更新的 useEffect
  if (rootDoesHavePassiveEffects) {
    rootDoesHavePassiveEffects = false;
    rootWithPendingPassiveEffects = root;
    pendingPassiveEffectsExpirationTime = expirationTime;
    pendingPassiveEffectsRenderPriority = renderPriorityLevel;
  } else {
    // while 清空 effect list 便于垃圾回收
  }
 	// ...
  flushSyncCallbackQueue();
  return null;
}

这边需要提到的一点就是 workInProgress Tree 在第二阶段执行完之后,会被当成 current 树。接下来我们来研究一下 flushSyncCallbackQueue 函数。

function flushSyncCallbackQueue() {
  // 在 setState 时被赋值,取消当前 node 的回调,因为会在 flushSyncCallbackQueueImpl 被刷新
  if (immediateQueueCallbackNode !== null) {
    const node = immediateQueueCallbackNode;
    immediateQueueCallbackNode = null;
    Scheduler_cancelCallback(node);
  }
  flushSyncCallbackQueueImpl();
}

function flushSyncCallbackQueueImpl() {
  if (!isFlushingSyncQueue && syncQueue !== null) {
    isFlushingSyncQueue = true;
    let i = 0;
    try {
      const isSync = true;
      const queue = syncQueue;
      // 循环 syncQueue 执行同步队列中的每一个回调
      runWithPriority(ImmediatePriority, () => {
        for (; i < queue.length; i++) {
          let callback = queue[i];
          do {
            callback = callback(isSync);
          } while (callback !== null);
        }
      });
      syncQueue = null;
    } catch (error) {
      // ...
    } finally {
      // ...
    }
  }
}

上述代码中出现了一个新的队列 syncQueue,回想一下,大家都知道 setState 在大多数情况下是批处理的,每次 setState 并不是立即 render,而是做一个回调,在调度的末尾执行回调,等回调执行时会带上新的状态值并开启新的一轮调度。其实这边的回调就会压入 syncQueue 中,而压入的回调函数则是调度入口函数 performSyncWorkOnRoot

function performSyncWorkOnRoot(root) {
	// ...
	flushPassiveEffects();
  // ...
  // 开启新的一轮调度
}

在调度入口函数开始,React 首先会执行一次 flushPassiveEffects,这边可以看到,刷新被动副作用的函数在调度入口也会执行,这个函数已经在整个流程中多次出现了,我们来研究下这个函数。

function flushPassiveEffects() {
  // commit 阶段末尾被赋值,依据是 rootDoesHavePassiveEffects
  if (pendingPassiveEffectsRenderPriority !== NoPriority) {
    // ...
    return runWithPriority(priorityLevel, flushPassiveEffectsImpl);
  }
}

// 代码有删减
function flushPassiveEffectsImpl() {
    // ...
    while (effect !== null) {
      // ...
      commitPassiveHookEffects(effect);
      const nextNextEffect = effect.nextEffect;
      // Remove nextEffect pointer to assist GC
      effect.nextEffect = null;
      effect = nextNextEffect;
    }
}

从上述代码中,我们可以了解到, flushPassiveEffects 函数调用入口就会做一个判断,而标志位 pendingPassiveEffectsRenderPrioritycommit 阶段的末尾根据当前 effect list 中是否具有副作用的 fiber 来判断, 实质的刷新函数是 flushPassiveEffectsImpl 内部会提交 commitPassiveHookEffectscommitLayoutEffect 类似,都是从 updateQueue.lastEffect 中拿到副作用列表,依次遍历先执行所有的 destory,之后在进行一次遍历执行所有的 create 并根据返回值设置新的 destory

而在这里,我们可以发现,上一轮的 useEffect 会在下一轮 render 前刷新,而不是等到浏览器执行异步队列时刷新。这也正是 ReactuseEffect 执行时机的描述:

Does useEffect run after every render? Yes! By default, it runs both after the first render and after every update. (We will later talk about how to customize this.) Instead of thinking in terms of “mounting” and “updating”, you might find it easier to think that effects happen “after render”. React guarantees the DOM has been updated by the time it runs the effects.

总结

默认情况下, useEffect 会在浏览器宏任务队列中刷新,但是如果 commit 阶段中产生了额外的副作用,那么将会在 commit 末尾进行检测并开启新的一轮调度,在调度的开头会被刷新。

我们以一段代码为例,主要通过查看组件 render 的次数,在实际编码过程中,我们应当尽量减少组件 render 的次数,并且知道组件会在什么时候被 render

const App = () => {
  const [count, setCount] = useState(0);
  
  useLayoutEffect(() => {
      setCount(1);
  }, []);
  
  useEffect(() => {
      setCount(2);
      setCount(3);
  }, []);
  
  consle.count('render');
  
  return (<span>{ count }</span>);
}

上述组件一种会打印两次 render

  • 第一次是在初始挂载时, App 组件被调用,第一次打印,并且创建了一个 hook 链表和一个 effect 链表
  • 当第一次进入 commit 阶段时, commit 的开始会执行一次 flushPassiveEffect 但是因为当前没有设置标志位 pendingPassiveEffectsRenderPriority,所以很快的退出了该函数的执行
  • 接下来进入 commit 的第一阶段, React 检测到 App 组件使用了 useEffect,标记标志位 rootDoesHavePassiveEffects,并将刷新副作用的函数推入宏任务队列,
  • 进入第二阶段, React 会挂载DOM,并刷新上一轮的 layoutEffect.destory
  • 进入第三阶段, React 刷新当前的 layEffect.create ,上述代码中,因为在 useLayoutEffect 中调用了 setState,产生了额外的副作用, React 将调度函数当成回调压入 syncQueue,并且 setState 的值被缓存下来
  • commit 阶段末尾, React 依据标志位 rootDoesHavePassiveEffects 设置标志位 pendingPassiveEffectsRenderPriority,并执行 flushSyncQueue,刷新 syncQueue 队列,依次执行回调,这里的回调就是调度入口函数
  • 执行 performWorkOnRoot ,开始新的一轮调度,调度入口,执行 flushPassiveEffect ,此时因为标志位 pendingPassiveEffectsRenderPriority 上一步已经设置过,开始刷新所有的 passiveEffect,上述代码中, useEffect 中调用了两次 setState,新的 state 值会立即计算,得到一个新的状态值挂在 fiber.updateQueue 上挂载两个 update 对象。
  • 当第二次调用 App 组件时, React 遍历 updateQueue,计算出最新的状态值,第二次打印
  • 第二次进入 commit 阶段, React 执行三个小阶段,在三个阶段中因为 hooks.deps 都没有发生变化,所以不执行任何的 effectcommit 的末尾也会检测到新的副作用,调度结束。
  • 同步代码执行完成后,执行宏任务队列,执行 performWorkUntilDeadline 回调,内部会刷新 flushPassiveEffects,因为标志位 pendingPassiveEffectsRenderPriority 未设置,所以退出。

至此, App 组件调度完成,一共会打印两次。

总结

  • 一个完整的 commit 会被拆分为三个子阶段来完成,在 commit 末尾会刷新 commit 阶段产生的同步回调及 setState

  • 第一阶段,对于 Class 组件而言,是执行 getSnapShotBeforeUpdate 生命周期,对于函数式组件则是安排异步回调

  • 第二阶段 React 会挂载或更新 DOM,并清理上一轮的 useLayoutEffect

  • 第三阶段,对于 Class 组件而言是执行 componentDidMount,对于函数式组件则是执行 useLayoutEffect

  • useEffect 的执行时机大多数情况下会在浏览器异步任务队列中被刷新,但如果在 commit 阶段调用了 setState,则会在下一轮 render 开始前被刷新,在 ClassHooks 混用的场景尤其要注意,已下方的 bad case 为例

    const Child = ({ getFormIns }) => {
      // 生成一个 form 实例
      const form = Form.useForm();
      
      // 试图向外层传递 form 实例
      useEffect(() => {
        if(getFormIns) {
          getFormIns(form);
        }
      }, []);
      
      return <span></span>
    }
    
    class App extends React.Component {
      constructor(props) {
        super(props);
    		this.formIns = null;
      }
      
      componentDidMount() {
        // 拿到 form 实例 做一些操作
        formIns.updateModel({});
      }
      
      return <Child getFormIns={(form) => this.formIns = form} />
    }
    

以上代码中试图在父组件中调用 Form 实例相关的方法, 但是实际上 useEffect 的执行时机并不是真正的 didMount,导致父组件在 DidMount 的时候出错,将 useEffect 换成 useLayoutEffect 就可以解决上述问题,但不管是使用 useLayoutEffect 还是使用 useEffect 都不符合 Hooks 的语义,在示例代码中场景是父组件期望可以调用子组件的变量或者方法,这种场景更适合使用 useImperativeHandle 来代替。