React 源码揭秘 | commit流程

前面文章所描述的都发生在render过程中。React包含两个过程,即render和commit过程,其中render过程是可以打断的,而commit阶段是不可打断的。

commit阶段可以理解是真正的操作DOM的阶段,其消费render阶段打到Fiber节点上的Flag,并且根据Flag对真实DOM元素进行更新,删除,插入等操作。

先来复习一下Flag,我们知道,commit包含两个子阶段,即:

Mutation阶段: 此阶段主要进行DOM的更新操作,包含DOM的增删改,以及Ref的更新阶段。次阶段也包涵Effect的收集。

Passive阶段: 此阶段是在Mutation阶段完成之后,进行一些副作用的处理阶段,一般在浏览器进行完一个循环的渲染之后执行。但是由于Effect是交给scheduler执行的,如果浏览器在完成Mutation阶段之后还有空闲时间,也有可能先执行useEffect。

对于Muation阶段,React主要处理以下Flags

TypeScript 复制代码
export const Placement = 0b0000001;
export const Update = 0b0000010;
export const ChildDeletion = 0b0000100;
export const Ref = 0b0010000;

export const MutationMask =
	Placement | Update | ChildDeletion | Ref

可以看到,Mutation阶段主要处理 dom元素插入 删除 更新 以及Ref的更新,MutationMask就是这四个Flag的合集。

Passive阶段,主要处理PassiveEffect和ChildDeletion

TypeScript 复制代码
export const PassiveEffect = 0b0001000;
export const PassiveMask = PassiveEffect | ChildDeletion;

因为useEffect不仅仅是在监听值变动时执行,在组件卸载时,也会执行其返回的函数

PassiveMask为这两个Flags的合集

commit准备工作

在render阶段结束之后,此时已经有一棵完整的待更新Fiber树了。

此时需要为commitRoot做一些初始化工作

  1. root.finishedWork用来记录已经生成的待Commit的Fiber树,由于render阶段结束之后,workInProgress已经为空了,这个值需要用root.current.alternate获得。

  2. root.finishedLane 用来记录当前处理的lane,需要用wipRootRenderLane赋予

在给这两个属性赋值后,需要吧wipRenderLane置为NoLane

最后调用commitRoot函数,开始commit阶段

TypeScript 复制代码
// performWorkOnRoot
//任务完成 收尾 commit
// 设置root.finishedWork
root.finishedWork = root.current.alternate;
root.finishedLane = lane;
// 设置wipRootRenderLane = NoLane;
wipRootRenderLane = NoLane;
// 进入commit阶段
commitRoot(root);

commitRoot - 开启commit流程

commitRoot函数用来开启commit阶段,其要做的事情为:

  1. 拿到root.finishedWork & root.finishedLane 并且把这两个值置为空, 并且调用markRootFinished把当前处理的lane从root.pendingLanes去除,表示已经完成本lane的处理。

  2. 检测finishedWork根节点是否包含PassiveEffect或者其子节点subtreeFlasg是否包含PassiveMask, 如果包含,则开启Passive阶段,并且将Passive阶段处理函数推入scheduler,在Mutation阶段结束之后调度运行。

  3. 检测finishedWork的flags和subTreeFlags是否包含 (PassiveMask | MutationMask) 因为Mutation阶段除了要处理阶段的插入删除更新Ref,还需要收集Effect。

  4. 如果包含Flag,说明需要commit 开启commitMuationEffect

  5. 把root.current赋finishedWork

  6. 开启Layout阶段,调用commitLayoutEffect 用来挂载Ref

  7. 重新开启调度流程,更新结束

实现如下:

TypeScript 复制代码
/** commit阶段 */
export function commitRoot(root: FiberRootNode) {
  const finishedWork = root.finishedWork;

  if (finishedWork === null) return;

  const lane = root.finishedLane;
  root.finishedWork = null;
  root.finishedLane = NoLane;

  // 从root.pendingLanes去掉当前的lane
  markRootFinished(root, lane);

  /** 设置调度 执行passiveEffect */
  /** 真正执行会在commit之后 不影响渲染 */
  /** commit阶段会收集effect到root.pendingPassiveEffect */
  // 有删除 或者收集到Passive 都运行
  if (
    (finishedWork.flags & PassiveMask) !== NoFlags ||
    (finishedWork.subTreeFlags & PassiveMask) !== NoFlags
  ) {
    // 调度副作用
    scheduler.scheduleCallback(
      PriorityLevel.NORMAL_PRIORITY,
      flushPassiveEffect.bind(null, root.pendingPassiveEffects)
    );
  }

  /** hostRootFiber是否有effect  */
  const hostRootFiberHasEffect =
    (finishedWork.flags & (MutationMask | PassiveMask)) !== NoFlags;

  /** hostRootFiber的子树是否有effect  */
  const subtreeHasEffect =
    (finishedWork.subTreeFlags & (MutationMask | PassiveMask)) !== NoFlags;

  /** 有Effect才处理 */
  if (hostRootFiberHasEffect || subtreeHasEffect) {
    commitMutationEffects(finishedWork, root);
  }
  // commit完成 修改current指向新的树
  root.current = finishedWork;
  // commitLayout阶段 处理Attach Ref
  commitLayoutEffects(finishedWork, root);
  // 确保可以继续调度
  ensureRootIsScheduled(root);
}

commitMutationEffect - Mutation阶段

commitMutationEffect是高阶函数commitEffect返回的函数,其调用如下:

TypeScript 复制代码
export const commitMutationEffects = commitEffect(
  "mutation",
  MutationMask | PassiveMask,
  commitMutationEffectsOnFiber
);

其中commitEffect是个高阶函数,需要传入

  1. 当前commit的阶段,mutation|layout,

  2. 需要检测的Mask

  3. 处理函数callback

这个函数的作用就是返回一个处理函数,这个处理函数会深度优先遍历传入的finsihedWork 树,在向下递的阶段,每一次都查看当前节点的子节点是否还有对应需要检测的Mask,如果有就继续往下找,如果没有就停止,开始归的过程,这样能减少不必要的遍历。在归的过程中,调用callback函数完成对Flags的处理。

TypeScript 复制代码
/** 高阶函数 用来处理Effect */
function commitEffect(
  phrase: "mutation" | "layout",
  mask: Flags,
  callback: CommitCallback
): CommitCallback {
  /** 递归,DFS 找到最深的无subflags的节点 下面的不需要commit了 因为没有副作用 */
  return (finishedWork, root) => {
    // DFS
    let nextFinishedWork = finishedWork;
    while (nextFinishedWork !== null) {
      if (
        (nextFinishedWork.subTreeFlags & mask) !== NoFlags &&
        nextFinishedWork.child
      ) {
        // 递
        nextFinishedWork = nextFinishedWork.child;
      } else {
        while (nextFinishedWork !== null) {
          // 归
          callback(nextFinishedWork, root);
          if (nextFinishedWork.sibling !== null) {
            nextFinishedWork = nextFinishedWork.sibling;
            break;
          }
          nextFinishedWork = nextFinishedWork.return;
        }
      }
    }
  };
}
commitMutationEffectOnFIber

这个函数是传递进commitEffect的callback回调,commitEffect在每次归的过程中,都会调用这个函数处理每个节点。

这个函数的作用是,检查当前Fiber节点上的flag值,根据不同的flag,调用不同的Flag处理函数

TypeScript 复制代码
/** 用来处理 Mutation副作用 [Placement | Update | ChildDeletion // TODO PassiveEffect] */
const commitMutationEffectsOnFiber: CommitCallback = (finishedWork, root) => {
  // 处理每个节点的Effect
  // 获取节点的flags
  const flags = finishedWork.flags;
    // 处理placement
  if ((flags & Placement) !== NoFlags) {
    // 存在Placement
    commitPlacement(finishedWork);
    // 去掉副作用flag
    // 去掉某个flag: 0b0111&(~0b0100) => 0b0111&0b1011=> 0b0011 去掉了 0b0100
    finishedWork.flags &= ~Placement;
  }
    // 处理Update
  if ((flags & Update) !== NoFlags) {
    commitUpdate(finishedWork);
    finishedWork.flags &= ~Update;
  }

    // 处理ChildDeletion
  if ((flags & ChildDeletion) !== NoFlags) {
    const deletion = finishedWork.delections;
    deletion.forEach((deleteOldFiber) => {
      commitDeletion(deleteOldFiber, root);
    });
    finishedWork.flags &= ~ChildDeletion;
  }

    // 处理收集passiveEffect
  if ((flags & PassiveEffect) !== NoFlags) {
    // 存在被动副作用
    commitPassiveEffect(finishedWork, root, "update");
  }

  // 卸载Ref 只有hostComponent需要卸载
  if (finishedWork.tag === HostComponent && (flags & Ref) !== NoFlags) {
    const current = finishedWork.alternate;
    if (current) {
      // 需要卸载current的ref 其实本质上current和finishedWork的ref都是一个
      saftyDetachRef(current);
    }

    // 卸载之后由于可能还会加载ref 所以这里的flag不能~Ref
  }
};
commitPlacement 插入节点

commitPlacement函数用来把新创建的Fiber节点插入到当前DOM树上,插入节点需要找到要插入的父节点,以及要插入到父节点的哪个位置,即找到兄弟节点。

需要注意的点是:

  1. 只有HostComponent节点才能作为parent节点被插入

  2. 只有HostComponent节点或HostText节点才可以作为被插入节点的兄弟

由于函数节点 Fragment节点 Memo节点等仅在Fiber中体现,不存在于真实DOM中, 所以在寻找父节点和兄弟节点时需要注意。

TypeScript 复制代码
/** 处理Placement */
function commitPlacement(finishedWork: FiberNode) {
  /** 获取finishedWork的hostparent 用来挂载finishedWork对应的DOM (finishedWork可能也不是Host 后面有处理) */
  const hostParent = getHostParent(finishedWork) as Container;
  /** 获取finishedWork的Host sibling节点  */
  const hostSibling = getHostSibling(finishedWork) as Element;
  // 拿到parent和sibling了,就可以插入dom了
  // hostsibling不存在就是append 存在就是插入
  if (hostParent !== null) {
    insertOrAppendPlacementNodeIntoConatiner(
      finishedWork,
      hostParent,
      hostSibling
    );
  }
}
getHostparent

这个函数用来找到待插入节点最近的的父HostComponent节点,其实现如下,就是顺着return一直找,指导找到tag为HostComponent或者return为null(即HostRoot节点)

如果找到HostRoot节点,需要返回hostRoot.stateNode.container节点

TypeScript 复制代码
/** 获取HostParent
 *  获取当前节点的HostComponent/HostRoot parent
 */
function getHostParent(fiber: FiberNode): Element {
  let node = fiber.return;
  while (node !== null) {
    if (node.tag === HostComponent) {
      // host component 返回其stateNode
      return node.stateNode;
    }

    if (node.tag === HostRoot) {
      // hostRoot 其stateNode -> FiberRootNode 需要通过FiberRootNode.container获取
      return node.stateNode.container;
    }
    // 向上找
    node = node.return;
  }
  return null;
}
getHostSibling

这个函数用来找到和当前插入Fiber节点紧邻的兄弟节点,因为需要使用parent.apppendBefore(sibling)完成节点插入,所以需要找到其兄弟给插入位置定位。

其实现思路是,从当前节点先向上找一个存在sibling的父节点,任意类型都可以,如果在寻找的过程中,找到HostComponent HostRoot 或者 null 的时候,还没有sibling节点,说明当前节点没有兄弟节点,直接返回null (注意 HostComponent代表已经找到了其parent节点了,往上找没意义了)

如图:

实现如下,当while循环因为node.sibling不为null退出时,说明已经找到了存在sibling的父节点

TypeScript 复制代码
    while (node.sibling === null) {
      const parent = node.return;

      if (
        parent === null ||
        parent.tag === HostComponent ||
        parent.tag === HostRoot
      ) {
        /** 回溯的过程中 如果遇到了 hostComponent / hostRoot 说明找到了parent节点 不能再往上找了 */
        return null;
      }

      /** 继续往上查找 */
      node = parent;
    }

找到存在sibling的父节点后,就需要继续寻找sibling节点,从其sibling节点向下找child,如果child是HostComponent或者是HostText,则找到兄弟节点了,直接返回。

需要注意,这里找的节点一定要是非Placement的,因为Placement的节点还没有挂载在真实dom树上,无法作为兄弟节点,所以在向下找的过程中,不论是什么节点 是不是Host节点,只要其存在Placment Flag,都要结束这条路的查找,重新循环找父节兄弟的下一个兄弟,(如果一个节点为Placment,其下所有节点一定还没有挂载,可以不用再找了)如图

ul节点还没有被挂载,无法被使用

看一个能找到sibling的例子,如下:

其中Div为更新阶段挂载上去的,那么此时 div.sibling 已经存在,但是因为其不是Host元素,继续往下找到ul,符合要求,返回作为sibling

getHostSibling函数实现如下:

TypeScript 复制代码
/**
 * 查找fiber的sibling host节点 (难点)
 *  这里注意,sibling节点可能是不同级的
 *  同时 对于Placement的节点,由于其和其child节点都还没放置 不能作为sibling节点
 * 查找方向
 * 1. 查看当前节点有没有sibling,如果有从sibling往下找(child) 如果child为hostComponent/HostTag 并且flag不为placement 则返回
 *    如果查找的节点为placement 不论什么类型 查找和以下的节点都不能用 开始回溯
 * 2. 回溯,找查找节点的parent,如果有sibling 则回到 (1) 查找其sibling 直到找到一个不为placement的hostCom/hostText为止
 *    如果回溯的过程中,遇到了hostcomponent/hostroot 或者 null的节点 则直接返回null (因为回溯的过程中 一定走的都是非host节点 因为如果是host节点就肯定已经返回了)
 *    如果回溯到过程中遇到host 那么一定是parent节点 或者已经找到hostRoot了 表示没找到
 * @param fiber
 */
function getHostSibling(fiber: FiberNode): Element {
  let node = fiber;
  // 找sibling节点,没有找parent,如果遇到hostComponent / hostRoot 直接返回null
  findSibling: while (true) {
    while (node.sibling === null) {
      const parent = node.return;

      if (
        parent === null ||
        parent.tag === HostComponent ||
        parent.tag === HostRoot
      ) {
        /** 回溯的过程中 如果遇到了 hostComponent / hostRoot 说明找到了parent节点 不能再往上找了 */
        return null;
      }

      /** 继续往上查找 */
      node = parent;
    }

    // 执行到这里,说明存在sibling,移动node节点 -> sibling
    node.sibling.return = node.return;
    node = node.sibling;

    // 找到sibling了 此时开始向下查找,这里要注意,寻找的节点必须满足
    // 1. 是hostComponent / hostText
    // 2. 不能是placement节点 如果不满足,返回到回溯阶段
    while (node.tag !== HostComponent && node.tag !== HostText) {
      // 都不是,如果此时为Placement 下面的不用看了 因为当前节点下的DOM还没挂载,直接回溯
      if ((node.flags & Placement) !== NoFlags || node.child === null) {
        continue findSibling; // 直接跳到最外层循环,回溯
      }
      // 向下寻找
      node.child.return = node;
      node = node.child;
    }
    // 运行到此处 找到hostCompoent/hostText了 看是不是placement
    if ((node.flags & Placement) === NoFlags) {
      return node.stateNode;
    }
  }
}

找到parent和sibling节点之后,就要执行插入操作,其调用的函数是 insertOrAppendPlacementNodeIntoContainer 这个名字一看就知道什么意思,就是完成插入操作的.

insertOrAppendPlacementNodeIntoContainer

这个函数就是把当前的Placement节点插入DOM树,但是需要考虑多种情况

  1. 如果带插入节点是个Host节点,那么直接调用parent.appendBefore() 或者在没有sibling的情况下调用parent.appendChild() 即可

  2. 如果不是Host节点,可能是Fragment或者FunctionComponent这样的虚拟节点,那么就需要遍历其所有子节点,如果把如果是Host的节点都插入,当然了如果是非Host节点需要递归的寻找,也是一个深度优先的过程。 如图,插入节点为FunctionComponent时,其插入路径如下:

实现如下: 其中insert函数被递归调用,保证每个host节点都被挂载

TypeScript 复制代码
/**
 * 插入或者追加finishwork节点到hostParent(container)中
 * @param finishedWork
 * @param hostParent
 * @param hostSibling
 */
function insertOrAppendPlacementNodeIntoConatiner(
  finishedWork: FiberNode,
  hostParent: Container,
  hostSibling?: Element
) {
  // 这里需要注意 finishedWork 可能也不是HostComponet
  if (finishedWork.tag === HostComponent || finishedWork.tag == HostText) {
    if (hostSibling) {
      hostParent.insertBefore(finishedWork.stateNode, hostSibling);
    } else {
      hostParent.append(finishedWork.stateNode);
    }
  } else {
    // 如果finishwork不是host 比如是Fragment或者Function
    // 需要遍历其子节点 并且添加
    let child = finishedWork.child;
    while (child !== null) {
      insertOrAppendPlacementNodeIntoConatiner(child, hostParent, hostSibling);
      child = child.sibling;
    }
  }
}
commitUpdate更新节点

更新节点逻辑相对节点, 对于文本节点,修改其nodeValue

对于host节点,修改其dom节点上的 __props属性 (具体在合成事件中讲)

TypeScript 复制代码
/** 处理update副作用 */
function commitUpdate(fiber: FiberNode) {
  if (fiber.tag === HostText) {
    fiber.stateNode.nodeValue = fiber.memorizedProps.content;
  } else {
    updateFiberProps(fiber.stateNode, fiber.memorizedProps);
  }
}
commitDeletion 删除节点

删除节点其实和inserOrAppendPlacementNodeIntoConatiner 一样,先找到其parent的DOM节点,如果当前待删除的节点为hostComponent或HostText 直接删除,如果是其他节点,就需要递归的去寻找其第一层子节点,并且删除

TypeScript 复制代码
function commitDeletion(fiber: FiberNode, root: FiberRootNode) {
  const container = getHostParent(fiber);
  if (container) {
    deleteNodeFromContainer(container, fiber, root);
  }
}

/** 递归的方式删除节点 */
function deleteNodeFromContainer(
  container: Container,
  childToDelete: FiberNode,
  root: FiberRootNode
) {
  if (!container || !childToDelete) return;
  if ((childToDelete.tag === HostComponent || childToDelete.tag === HostText)&&childToDelete.stateNode!==null) {
    /** 如果是host节点,直接删除即可 */
    if(container.contains(childToDelete.stateNode)){
      container.removeChild(childToDelete.stateNode);
    }
  
    // 删除时,卸载Ref
    if (childToDelete.tag === HostComponent) {
      // HostComponent删除的时候 需要卸载Ref
      saftyDetachRef(childToDelete);
    }
  } else {
    /** 非host节点,递归删除 */
    if (childToDelete.tag === FunctionComponent) {
      /** 函数组件的情况下,需要收集Effect */
      commitPassiveEffect(childToDelete, root, "unmount");
    }
    let deleteNodeChild = childToDelete.child;
    while (deleteNodeChild !== null) {
      deleteNodeFromContainer(container, deleteNodeChild, root);
      deleteNodeChild = deleteNodeChild.sibling;
    }
  }
}

对于Ref和PassiveEffect 我们在后面说

执行完commitMutationEffects 会继续执行commitLayoutEffect, 其本质和MutationEffect一样,只不过mask换成了LayoutEffect = Ref 后面会细说Ref挂载更新卸载的流程

最后再次调度ensureRootIsScheduled

这样,我们就完成了简易的Commit流程!

相关推荐
腾讯TNTWeb前端团队3 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰6 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪6 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪6 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy7 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom7 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom8 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom8 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom8 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom8 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试