【React源码笔记11】- commit

React | 2020-08-12 20:36:25 133次 1次

经过了 scheduleWorkrender 的阶段处理,生成了一条 effectList 链表,和一些更新链表,接下来进入 commit 阶段的处理这个阶段是不可以被打断的。回顾进入这里的流程:

performSyncWorkOnRoot
        |
        |
   workLoopSync ------ performUnitOfWork
                           |
                           |
                       beginWork
                           |
                           |
        --------------completeUnitOfWork
        |
        |
  finishSyncRender
        |
    commitRoot

commit 的过程分为三个步骤:

before mutation阶段(执行DOM操作前):commitBeforeMutationEffects

mutation阶段(执行DOM操作)               :commitMutationEffects

layout阶段(执行DOM操作后)                :commitLayoutEffects


一、commitRoot概览

function commitRootImpl(root, renderPriorityLevel) {
    if (firstEffect !== null) {
      nextEffect = firstEffect;
      do {
        try{
            commitBeforeMutationEffects();
        }catch(e){
          captureCommitPhaseError(nextEffect, error);
          nextEffect = nextEffect.nextEffect;
        }
      } while (nextEffect !== null);
      
      nextEffect = firstEffect;
      
      do {
        commitMutationEffects(root, renderPriorityLevel);
      } while (nextEffect !== null);
      
      nextEffect = firstEffect;
      
      do {
        commitLayoutEffects(root, expirationTime);
      } while (nextEffect !== null);
    }
    // 在离开commitRoot函数前调用,确保任何附加的任务被调度
    ensureRootIsScheduled(root);
    // 在commit阶段有同步任务被调度,在这里率先执行他们,不需要等到浏览器下一个macroTask
    // 比如在 componentDidMount 中执行 setState 创建的更新会在这里被同步执行
    flushSyncCallbackQueue();
    return null;
  }

这里面通过三个大的遍历分别执行上述的三个过程,nextEffect 是一个全局变量,每次循环前都会重新取第一个 effectList 值,从头开始。其实是每个子过程中都会有一个遍历的过程(这里代码中没有体现),为什么这部分还有循环呢?因为这里的循环是为了出错时通过 nextEffect(这个不是全局变量,是上一篇文章中最后画的那张图中的指针) 继续进入对应的过程遍历。 


二、before mutation阶段

function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    const effectTag = nextEffect.effectTag;
    if ((effectTag & Snapshot) !== NoEffect) {
      const current = nextEffect.alternate;
      commitBeforeMutationEffectOnFiber(current, nextEffect);
    }
    if ((effectTag & Passive) !== NoEffect) {
      // useEffect 相关 暂时忽略
      if (!rootDoesHavePassiveEffects) {...}
    }
    nextEffect = nextEffect.nextEffect;
  }
}

这里面针对 class 组件执行的是 getSnapshotBeforeUpdate 钩子,针对 function 组件执行的是 useEffect,这里暂时忽略,后面 hooks 再分析。

在 beginWork 中 updateClassComponent 时判断如果有 gSBU 钩子就会打上 workInProgress.effectTag |= Snapshot 标记,到这里才会调用,因为 commit 阶段(不会打断)和 render 阶段(会打断)执行不同,只会执行一次,所以 componentWillXXX 等不安全的方法通过 gSBU 代替。


三、mutation阶段

此阶段执行 commitMutationEffects 方法:

function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
  // 遍历effectList
  while (nextEffect !== null) {
    ...
    // 更新ref
    if (effectTag & Ref) {
      ...
    }
        //选中其中一种类型
    const primaryEffectTag = effectTag & (Placement | Update | Deletion | Hydrating); 
    switch (primaryEffectTag) {
      // 插入DOM
      case Placement: {
        commitPlacement(nextEffect);
        nextEffect.effectTag &= ~Placement;
        break;
      }
      // 插入DOM 并 更新DOM
      case PlacementAndUpdate: {
        // 插入
        commitPlacement(nextEffect);
        nextEffect.effectTag &= ~Placement;
        // 更新
        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      ...
      // 更新DOM
      case Update: {
        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      // 删除DOM
      case Deletion: {
        commitDeletion(root, nextEffect, renderPriorityLevel);
        break;
      }
    }

    nextEffect = nextEffect.nextEffect;
  }}


四、mutation阶段--插入 commitPlacement

看这个方法之前回顾下第三篇笔记中提到的节点关系 关系图 其中 FiberRoot 是整个应用的起点,记录整个应用更新过程中各种信息,只能有一个。RootFiber 则是具体的 Fiber 节点的根,可以有多个。

function commitPlacement(finishedWork: Fiber): void {
  // 找到父级 
  const parentFiber = getHostParentFiber(finishedWork);
  let parent;
  let isContainer;
  // 拿到真实节点 rootFiber.stateNode === fiberRoot
  const parentStateNode = parentFiber.stateNode;
  switch (parentFiber.tag) {
    case HostComponent:
      // 普通节点直接拿到真实dom
      parent = parentStateNode;
      isContainer = false;
      break;
    case HostRoot:
      // 对于 fiberRoot 要取containerInfo,即 ReactDom.render中第二个参数
      parent = parentStateNode.containerInfo;
      isContainer = true;
      break;
    ...
    
  }
  // 没有找到对应场景,但是这里是判断如果存在标记则执行,通过 ~清除标记
  if (parentFiber.effectTag & ContentReset) {
    // 清除标记  按位取反
    parentFiber.effectTag &= ~ContentReset;
  }
  
  // 获取Fiber节点的DOM兄弟节点
  const before = getHostSibling(finishedWork);
  //根据DOM兄弟节点是否存在决定insertBefore 或者 appendChild 执行DOM插入操作。
  // 下面这两个 方法做的事情是一样的,只不过一个是挂载到 container 另一个是父节点
  if (isContainer) {
    insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
  } else {
    insertOrAppendPlacementNode(finishedWork, before, parent);
  }
}

上述过程为:找到父级节点并拿到真实 DOM---获取兄弟节点---根据是否有兄弟 before 执行不同方法。


getHostSibling

这个方法的代码暂时没看懂为什么如此设计,它的目的就是找到需要插入的位置,如果有在它之前插入 insertBefore,如果没有直接 appendChild。现列举一些案例辅助理解过程:

// 在 p111 之前插入

// 这种操作最好加上key  不加key这里不会全部复用旧节点,加上key就是复用
// 如果 不加入Com节点,加不加key都无所谓,都会复用 因为 null==null
// 这里的effectlist为:p11 -> Com -> p111 -> p222  
// 因为旧节点 p11 并没有直接移除 而是打上了删除标记 deleteChild方法中
// 旧节点p22被新节点p111复用(残留的statenode所以为p22)
return !flag ? (
        <div id="A1">
          <p>11</p>
          <p>22</p>
        </div>
      ): (
        <div id="A1">
          <Com />
          <p>111</p>
          <p>222</p>
        </div>
      )
// 当这种情况给 Com 加上key 时和不加key getHostSibling的算法是不一样的
// 不加key进入第二个while中第一个判断就继续跳出到最外层 sibling tag
// 加上key进入第二个while第二个判断继续取child
return !flag ? (
        <div id="A1">
          <Com />
        </div>
      ): (
        <div id="A1">
          <p>111</p>
          <Com />
        </div>
      )
function getHostSibling(fiber: Fiber): ?Instance {
  let node: Fiber = fiber;
  siblings: while (true) {
    // If we didn't find anything, let's try the next sibling.
    while (node.sibling === null) {
      if (node.return === null || isHostParent(node.return)) {
        return null;
      }
      node = node.return;
    }
    node.sibling.return = node.return;
    node = node.sibling;
    while (
      node.tag !== HostComponent &&
      node.tag !== HostText &&
      node.tag !== DehydratedFragment
    ) {
      // 按照上述示例,如果为一个 class 组件,有插入标识,直接跳出本轮 继续大循环
      if (node.effectTag & Placement) {
        continue siblings;
      }
      // 如果被复用的类组件 继续找子节点
      if (node.child === null || node.tag === HostPortal) {
        continue siblings;
      } else {
        node.child.return = node;
        node = node.child;
      }
    }
    // 返回最终的需要被插入的真实dom
    if (!(node.effectTag & Placement)) {
      // Found it!
      return node.stateNode;
    }
  }
}

因为 fiber 树和真实的 dom 树是不一致的,比如组件 Com,在 fiber 上是占一个位置的,它的 child 才是真的需要渲染的节点,真实 dom 树上是不存在 Com 这个东西的。

如果待插入节点存在兄弟节点,并且兄弟节点不是特殊组件,比如(classfunction)类型,并且兄弟节点并不是新插入的,则找到的插入位置就是兄弟节点之前。

如果待插入节点存在兄弟节点,兄弟节点是特殊组件,比如(classfunction)类型,并且兄弟节点并不是新插入的,那么需要继续找组件的第一个子节点真实 dom,在这之前插入。

其他情况返回 null,意味着后续操作是直接 append 操作。


insertOrAppendPlacementNode

这个方法会判断如果是组件类型,则递归调用插入或者追加节点,因为要获取它的 child,普通 dom 节点直接根据 getHostSibling 的结果执行插入或者追加节点。

如果组件类型不可以复用,那么它下面的节点是全部重新插入,effectList 上只有组件,不会有它的孩子节点。如果组件类型可以复用,那么产生的 effectList 会包含它的子节点对比后的结果,而不会全部重新插入,所以在做一些节点的移动业务时,对于多个子节点的情景最好的情况就是别忘了给组件加上 key,因为这种插入的情况不加 key 会导致 fiber 不可复用。

const isHost = tag === HostComponent || tag === HostText;


五、mutation阶段 --更新commitWork

这个方法做的事情就很简单了,就是更新节点属性和节点内容,最终会调用这个方法,其中对于 hooks 的内容暂时忽略:

function updateDOMProperties(
  domElement: Element,
  updatePayload: Array<any>,
  wasCustomComponentTag: boolean,
  isCustomComponentTag: boolean,
): void {
  // 这个是在completeWork中生成的 updateQueue
  // 数组形式,奇数索引存储属性,偶数索引存储对应的值
  for (let i = 0; i < updatePayload.length; i += 2) {
    // 属性
    const propKey = updatePayload[i];
    // 值
    const propValue = updatePayload[i + 1];
    if (propKey === STYLE) {
      setValueForStyles(domElement, propValue);
    } else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
      setInnerHTML(domElement, propValue);
    } else if (propKey === CHILDREN) {
      setTextContent(domElement, propValue);
    } else {
      setValueForProperty(domElement, propKey, propValue, isCustomComponentTag);
    }
  }
}


六、mutation阶段 --删除commitDeletion

删除 dom 节点,并且还会执行对应的组件中的对应的卸载钩子,重置 current fiber 节点,因为卸载了不需要复用了。

调用 unmountHostComponents ,看下主要逻辑:

function unmountHostComponents(...) {
    ...
    // 大循环 最后看下作用
    while (true) {
      // 内部这个循环为了找到  currentParent 父节点
      if (!currentParentIsValid) {
        let parent = node.return;
        findParent: while (true) {
          const parentStateNode = parent.stateNode;
          switch (parent.tag) {
            case HostComponent:
              currentParent = parentStateNode;
              currentParentIsContainer = false;
              break findParent;
            ...
          }
          parent = parent.return;
        }
        currentParentIsValid = true;
      }
      
      // dom节点
      if (node.tag === HostComponent || node.tag === HostText) {
        commitNestedUnmounts(...);
        // 就是单纯的删除
        removeChild(...);
      } else {
        // 组件 执行卸载钩子 和 ref卸载
        commitUnmount(finishedRoot, node, renderPriorityLevel);
        // 组件内部的dom节点继续遍历
        if (node.child !== null) {
          node.child.return = node;
          node = node.child;
          continue;
        }
      }
      
      // 子树遍历结束
      if (node === current) {
        return;
      }
      
      // 没有兄弟
      while (node.sibling === null) {
        // 回到顶点或者子节点也遍历完成
        if (node.return === null || node.return === current) {
          return;
        }
        node = node.return;
        if (node.tag === HostPortal) {
          // When we go out of the portal, we need to restore the parent.
          // Since we don't keep a stack of them, we will search for it.
          currentParentIsValid = false;
        }
      }
      
      // 比如组件 return [1,2,3] 需要继续遍历兄弟,所以外部是一个大循环
      node.sibling.return = node.return;
      node = node.sibling;
    }
}

这里面主要做的事情是先找到父节点,然后根据节点类型执行相关逻辑,下面就是不停的遍历进行处理。

对于 dom 节点执行 commitNestedUnmounts 和 removeChild 移除,

对于组件执行 commitUnmount 执行一些卸载钩子,类组件就是 componentWillUnmounthooks 组件就是 useEffect 的返回回调。

 commitNestedUnmounts 方法做的事情和上面的逻辑差不多,深度优先遍历,因为有可能 dom 中包含了组件,所以需要深度执行所有的组件卸载钩子 commitUnmountunmountHostComponents 的遍历逻辑因为判单进入 dom 节点不会再深度执行,所以需要这个方法辅助继续深度执行,逻辑其实是一样的,只不过这里不再用做节点类型判断:

function commitNestedUnmounts(...){
  let node: Fiber = root;
  while (true) {
    // 卸载钩子执行
    commitUnmount(finishedRoot, node, renderPriorityLevel);
    if (
      node.child !== null &&
      (!supportsMutation || node.tag !== HostPortal)
    ) {
      node.child.return = node;
      node = node.child;
      continue;
    }
    if (node === root) {
      return;
    }
    while (node.sibling === null) {
      if (node.return === null || node.return === root) {
        return;
      }
      node = node.return;
    }
    node.sibling.return = node.return;
    node = node.sibling;
  }
}


七、layout阶段

进入 commit 的最后一个阶段--layout,在上个阶段 dom 已经渲染完成,那么这个阶段执行的一些事情就是可以让开发者访问真实 dom,比如一些钩子调用、setState 回调(所以回调里能立即取到最新值)、render 回调、ref 的挂载等。

function commitLayoutEffects(...) {
  while (nextEffect !== null) {
    const effectTag = nextEffect.effectTag;
    if (effectTag & (Update | Callback)) {
      // 只看下类组件
      commitLayoutEffectOnFiber(...);
    }
    if (effectTag & Ref) {
      // ref相关 后面分析
      commitAttachRef(nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}
function commitLayoutEffectOnFiber(){
    ...
    const instance = finishedWork.stateNode;
    // 钩子执行
    instance.componentDidUpdate(...); 
    // 更新渲染则执行的是 componentDidUpdate,初次执行 didmount
    const updateQueue = finishedWork.updateQueue;
    // setState回调执行
    commitUpdateQueue(finishedWork, updateQueue, instance);
    ...
}

最后还要执行实现树的切换,将 wip 树转为 current 树,之前笔记中提到了双缓冲机制

root.current = finishedWork;

到此,一个 class 组件的更新渲染流程大致就完成了,接下来进行 hookscontextref、事件系统等源码学习!

1人赞

分享到: