React 源码揭秘 | CompleteWork “归“的过程

news/2025/2/22 2:24:55

上篇说了BeginWork的流程,我们继续看workLoop.ts/performUnitOfWork函数

/**
 * 处理单个fiber单元 包含 递,归 2个过程
 * @param fiber
 */
function performUnitOfWork(fiber: FiberNode) {
  // beginWork 递的过程
  const next = beginWork(fiber, wipRootRenderLane);
  // 递的过程结束,保存pendingProps
  fiber.memorizedProps = fiber.pendingProps;
  // 这里不能直接给workInProgress赋值,如果提前赋workInProgress为null 会导致递归提前结束
  // 如果next为 null 则表示已经递到叶子节点,需要开启归到过程
  if (next === null) {
    /** 开始归的过程 */
    completeUnitOfWork(fiber);
  } else {
    // 继续递
    workInProgress = next;
  }
  // 递的过程可打断,每执行完一个beginWork 切分成一个任务
  // complete归的过程不可打断,需要执行到下一个有sibling的节点/根节点 (return === null)
}

当一个Fiber节点的beginwork流程结束后,当前节点的pendingProps以及使用完成,将其保存在memorizedProps 

继续往下找,看next(wip.child)是不是叶子节点,如果不是叶子节点则修改workInProgress为next,即下一个待处理的节点,结束函数运行,workLoop函数将performUnitOfWork包在一个while循环内,只要wip不为null就循环运行。

如果到达叶子节点,即next === null 此时已经深度优先构建到叶子节点了,需要往回递,也就是CompleteWork的过程。

completeUnitOfWork 向上“归”

归的过程由CompleteUnitOfWork实现,其代码如下:

function completeUnitOfWork(fiber: FiberNode) {
  // 归
  while (fiber !== null) {
    completeWork(fiber);

    if (fiber.sibling !== null) {
      // 有子节点 修改wip 退出继续递的过程
      workInProgress = fiber.sibling;
      return;
    }

    /** 向上归 修改workInProgress */
    fiber = fiber.return;
    workInProgress = fiber;
  }
}

其功能就是从叶子节点向上归,每遍历到一个节点都调用completeWork函数处理,直到找到一个由sibling兄弟的节点,就修改wip = sibling 结束函数运行,就又回到了beginWork的流程. 如图所示: 虚线为递归流程

 

completeWork -“归”的过程实现

completeUnitOfWork在遍历每个节点的时候,都会调用completeWork函数来对该节点完成"归"的处理。

completeWork函数定义在react-reconciler/completeWork.ts下,其主要完成三件事:

  1. 对于首次挂载的Fiber节点,创建真实Dom实例,并且赋给stateNode属性
  2. 完成当前stateNode节点和其子Fiber节点的stateNode节点的连接
  3. 副作用冒泡 包含flag和lane

其实现逻辑如下,也是根据不同的Fiber.tag分类处理

/** 归的过程 主要逻辑有
 * 1. 不能复用的DOM创建 赋给stateNode
 * 2. 连接父子节点
 * 3. flags冒泡
 */
export function completeWork(wip: FiberNode) {
  /** 真正需要操作的 只有HostComponent和HostText */
  const pendingProps = wip.pendingProps; //需要处理props
  const currentFiber = wip.alternate; // 当前生效的fiber

  switch (wip.tag) {
    case HostComponent:
      /** 处理HostComponent的情况 */
      if (currentFiber && currentFiber.stateNode) {
        // update
        if (currentFiber.ref !== wip.ref) {
          wip.flags |= Ref;
		}
        // 检查pendingProps和memroizedProps 如何不同则打上Update更新标签
        // if (pendingProps !== wip.memorizedProps) {
        //   wip.flags |= Update;
        // }  
        // 不要这样写 会导致事件处理函数的闭包陷阱 我们需要在每次更新的时候 update新的event
        wip.flags |= Update;
      } else {
        // mount
        // 挂载阶段,直接创建DOM,保存到stateNode
        const domInstance = document.createElement(wip.type as string);
        // 在DOM上更新属性
        updateFiberProps(domInstance, pendingProps);
        // stateNode保存instance
        wip.stateNode = domInstance;
        // 把所有的children dom元素加入到instance中
        // completeWork时 其所有子节点已经完成了递归
        appendAllChildren(domInstance, wip); // 这个操作只有HostComponent处理 HostText由于已经没有子节点 不需要这样操作
      }
      // 冒泡处理属性
      bubbleProperties(wip);
      return null;
    case HostText:
      if (currentFiber && currentFiber.stateNode) {
        // update
        if (currentFiber.memorizedProps?.content !== pendingProps?.content) {
          wip.flags |= Update;
        }
      } else {
        // mount
        const textInstance = document.createTextNode(pendingProps?.content);
        wip.stateNode = textInstance;
      }
      bubbleProperties(wip);
      return null;
    case HostRoot:
    case FunctionComponent:
    case Fragment:
    case MemoComponent:
      bubbleProperties(wip);
      return null;
    default:
      console.warn("未处理的completeWork类型!");
  }
}

这里着重看HostComponent和HostText的情况,只有这两种节点需要创建真实DOM节点 即stateNode存在。

对于存在alternate的节点,即currentFiber存在的节点,说明当前新的Fiber节点是通过currentFiber复用过来的,能复用说明key和type满足相同的要求,所以其stateNode也一定是相同的,所以次类节点直接标记一下Update和Ref即可(每次更新的时候都需要标记Update,把绑定的属性,方法都重新更新,避免闭包问题出现)

对于首次创建的节点,即没有alternate(currentFiber) 这种节点一定是新创建的,首次挂载的,此时就需要创建DOM元素,但是需要注意,这个DOM元素只是创建出对象,但是还没有挂载到DOM树上,即离屏的。在commit阶段会完成挂载。

创建过程如下,HostText同理

 // mount
// 挂载阶段,直接创建DOM,保存到stateNode
const domInstance = document.createElement(wip.type as string);
// 在DOM上更新属性
updateFiberProps(domInstance, pendingProps);
// stateNode保存instance
wip.stateNode = domInstance;
// 把所有的children dom元素加入到instance中
// completeWork时 其所有子节点已经完成了递归
appendAllChildren(domInstance, wip); // 这个操作只有HostComponent处理 HostText由于已经没有子节点 不需要这样操作

根据wip的type(此时为字符串)创建一个真实DOM元素,并且通过updateFiberProps设置属性

const elementPropsKey = "__props";

export function updateFiberProps(node: Element, props: ReactElementProps) {
  updateAttributes(node, props);
  node[elementPropsKey] = props;
}

由于React中采用合成事件(SyntheticEvent)来代理处理事件,所以将Fiber上的属性都保存到了DOM元素的 __props属性上了,合成事件后面具体说,这里知道更新了属性和绑定的事件即可。

 把创建的DOM对象放到stateNode下,然后处理父子之间的节点关系

appendAllChildren - 连接当前节点dom实例的所有子节点

 创建完一个新节点后,需要连接其所有的子dom节点,为什么要这么设计? 为什么不能放到commit阶段再添加,是因为这样设计能减少挂载次数,减少挂载时间。

当创建完一个还未挂载的dom节点,就寻找并且添加其子节点,这样在整个completeWork结束之后,就可以获得一个离屏的DOM树,这样只需要在HostRoot节点执行一次挂载即可。

对于Update的情况,对于新增的DOM树杈,也只是在子树的根节点执行一次挂载即可,因为挂载比较耗时,所以这样操作能提升性能!

你可能会有疑惑,我前面说render阶段是可以打断的,这也是为什么Update Fiber时props修改放到commit阶段,completeWork只是挂一个Update的flag,为了避免render多次执行带来的多次副作用更新。

但是对于新创建的dom实例则不同,因为当前创建的DOM实例还没有挂载到DOM树,如果render被打断重新执行,也只是重新再创建一次dom实例而已,能保证render阶段是个纯函数,没有副作用!

appendAllChildren的实现如下:

/**
 * 注意 这个函数只找一层HostComponent / HostText,并且完成连接
 * 如果中间遇到Function Fragment等 会跳过去连接 直到空或者遇到的第一个Host元素为止
 *  因为挂载DOM只会挂载DOM 需要跳过Function Fragment等
 */
function appendAllChildren(instance: Element, wip: FiberNode) {
  let node = wip.child;
  while (node !== null && node !== wip) {
    if (node.tag === HostComponent || node.tag === HostText) {
      instance.appendChild(node.stateNode);
    } else if (node.child) {
      node.child.return = node; // 冗余操作
      /** 查找其子元素 */
      node = node.child;
      continue; // 返回继续检查是否为Host
    }

    /** 走到这里,说明完成这条路线的一个Host元素的append */

    /** 开始向上归 此时路径上 除了wip 和当前节点(如果不为空的情况下)其余的应该都是非Host节点 比如Function或Fragment */
    /** 如果归的时候 某个节点有sibling 那么这个sibling下的第一个Host元素 也是挂在instance下的 需要处理 */
    while (node.sibling === null) {
      // 由于wip的sibling也可能为null node.sibling === null不能判断是否到了wip 这样会一直循环下去 需要在每个循环单独判断一下,当前节点的return是不是wip
      if (node.return === wip || node.return === null) {
        return;
      }
      // 如果没有兄弟 就向上归
      node = node.return;
    }

    /** 如果有兄弟,node指向兄弟 继续循环处理 */
    node.sibling.return = node.return; // 冗余操作
    node = node.sibling;
  }
}

 其本质就是,找当前新建DOM节点的下一层Host节点,遇到不是Host节点的就跳过,遇到Host节点就完成连接,并且回溯(注意 这里找一层就回来,因为下面的Host元素肯定已经在之前归的过程中完成连接了),如果有sibling节点,就找兄弟节点,但是每一条路,都只找一层Host,找到Host就回溯,直到回溯到当前新建节点对应的Fiber即可 (注意这里不是回到根节点,是当前新建节点对应Fiber节点为根的子树)

我们用以下例子画图理解这个过程:

<div>
  <div>texteNode<div>
  <ul>
    <li key={'item-1'}>item1</li>
    <li key={'item-2'}>item2</li>
    <li key={'item-3'}>item3</li>
  </ul>
</div>

这里只展示 归的过程,不展示递的过程

第一次递结束,此时Fiber树的状态如图所示,wip为textNode,从这个文本节点开始归

 

 第一次completeWork 创建TextNode Fiber对应的DOM Instance TextElement 绿色所示

此时TextNode没有子节点,appendAllChildren不做任何处理

第二次completeWork归到div,创建 div Element 绿色所示,此时appendAllChild把TexteElement加入到Div Element的第一个Child

第二次递结束,开始归的过程,此时从item1的位置开始completeWork 如下

此时创建TextElement 绿色所示,appendAllChildren没有任何效果

第二次completeWork,此时创建li的Element 并且appendAllChildren把item1加入到li的dom子节点中。如图所示

 第三次第四次递归的过程类似 省略

第五次归的过程,执行到ul 创建ul的dom元素,如图所示 

 此时appendAllChildren把三个li的dom节点都加入到ul的子节点

继续归到Div 创建DIv的Element 如图,appendAllChildren把div和ui的dom节点加入到div的子节点

继续往上归到根节点,结束!

此时就获得了一棵离屏dom树,在Commit阶段,在HostRoot节点完成一次挂载Placement即可挂载整棵DOM树! 

bubbleProperties - 副作用冒泡

bubbleProperties是个优化策略,对所有类型的Fiber节点,在completeWork的过程中,都需要进行冒泡操作,其目的是,把当前处理到的节点的副作用向上冒泡上去。

需要冒泡的副作用包含两个 一个是优先级lane 一个是标记flag

对应的,每个fiber节点上还包含childLanes和subTreeFlags两个属性,代表子节点的优先级车道和标记。

在commit阶段,同样是深度优先遍历Fiber树,找到flags并且进行副作用处理,其优化点在于,只用深度遍历存在subTreeFlags的节点即可,对于没有subTreeFlags的节点,下面的节点已经没有副作用了,不需要继续遍历,可以减少不必要的遍历操作,其实现也很简单,就是一个找return的过程 如下:

/** 把当前节点所有子节点的属性都merge到当前节点
 * 需要处理
 * 1. subtreeFlags
 * 2. childLanes
 */
function bubbleProperties(wip: FiberNode) {
  let subtreeFlags = NoFlags;
  let childLanes = NoLane;
  let node = wip.child || null;
  while (node !== null) {
    // merge subtreeFlags
    subtreeFlags |= node.subTreeFlags;
    subtreeFlags |= node.flags;
    // merge childLanes
    childLanes |= node.lanes;
    childLanes |= node.childLanes;

    // 寻找下一个子节点
    node.return = wip; // 冗余操作
    node = node.sibling; // 找下一个node
  }

  wip.subTreeFlags = subtreeFlags;
  wip.childLanes = childLanes;
}


http://www.niftyadmin.cn/n/5861528.html

相关文章

【蓝桥杯集训·每日一题2025】 AcWing 6134. 哞叫时间II python

6134. 哞叫时间II Week 1 2月20日 农夫约翰正在试图向埃尔茜描述他最喜欢的 USACO 竞赛&#xff0c;但她很难理解为什么他这么喜欢它。 他说「竞赛中我最喜欢的部分是贝茜说『现在是哞哞时间』并在整个竞赛中一直哞哞叫」。 埃尔茜仍然不理解&#xff0c;所以农夫约翰将竞赛…

蓝桥杯备考:贪心算法之矩阵消除游戏

这道题是牛客上的一道题&#xff0c;它呢和我们之前的排座位游戏非常之相似&#xff0c;但是&#xff0c;排座位问题选择行和列是不会改变元素的值的&#xff0c;这道题呢每每选一行都会把这行或者这列清零&#xff0c;所以我们的策略就是先用二进制把选择所有行的情况全部枚举…

性格测评小程序10生成报告

目录 1 修改数据源2 创建云函数2.1 安装依赖文件2.2 编写主方法 3 启用大模型4 搭建前端逻辑5 最终效果总结 这是我们测评小程序的最后一篇内容&#xff0c;当用户提交了测评&#xff0c;就需要依据测评的结果生成报告。如果按照传统开发思路&#xff0c;需要建表然后录入不同性…

LC-单词搜索、分割回文串、N皇后、搜索插入位置、搜索二维矩阵

单词搜索 使用 回溯法 来解决。回溯法适合用于这种路径搜索问题&#xff0c;我们需要在网格中寻找单词&#xff0c;并且每个字符都只能使用一次。 思路&#xff1a; 递归搜索&#xff1a;我们可以从网格中的每个单元格开始&#xff0c;进行深度优先搜索&#xff08;DFS&#x…

分布式 IO 模块:水力发电设备高效控制的关键

在能源领域不断追求高效与可持续发展的今天&#xff0c;水力发电作为一种清洁、可再生的能源形式&#xff0c;备受关注。而要实现水力发电设备的高效运行&#xff0c;精准的控制技术至关重要。分布式 IO 模块&#xff0c;正悄然成为水力发电设备高效控制的核心力量。 传统挑战 …

B+树作为数据库索引结构的优势对比

MySQL作为数据库&#xff0c;它的功能就是做数据存储和数据查找&#xff1b;使用B树作为索引结构是为了实现高效的查找、插入和删除操作。 B树的查找、插入、删除的复杂度都为 O(log n)&#xff0c;它是一个多叉树的结构&#xff0c;能兼顾各种操作的效率的数据结构。如果使用…

企业内部真题

文章目录 前端面试题:一个是铺平的数组改成树的结构问题一解析一问题一解析二前端面试题:for循环100个接口,每次只调3个方法一:使用 `async/await` 和 `Promise`代码解释(1):代码解释(2):1. `fetchApi` 函数2. `concurrentFetch` 函数3. 生成 100 个接口地址4. 每次并…

thread---基本使用和常见错误

多线程基础 一、C多线程基础 线程概念&#xff1a;线程是操作系统能够调度的最小执行单元&#xff0c;同一进程内的多个线程共享内存空间头文件&#xff1a;#include <thread>线程生命周期&#xff1a;创建->执行->销毁&#xff08;需显式管理&#xff09;注意事…