一文理解React是怎样通过Fiber渲染DOM

786 阅读23分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

前言

React18已经大家应该已经使用一段时间了,本文试着从最新的源码(18.2)出发,来讲解react和react fiber在初次渲染阶段做了哪些事情。下面会在main.jsx中输入示例结构(文中后续的示例结构指的都是此处),结合源码进行讲解。

// main.jsx
import { createRoot } from 'react-dom/client';

const element = (
  <h1>
    hello<span style={{ color: 'red' }}>world</span>
  </h1>
);

const root = createRoot(document.getElementById('root'));
root.render(element);

为什么要用fiber

在fiber出现之前,react渲染的过程是 虚拟DOM => 真实DOM,但是在这个过程进行时,我们是无法打断的。当我们的react项目过于臃肿,不断的递归会导致这个时间过于漫长,而对于浏览器来说,一般的刷新频率为60Hz,即大概是16.6ms渲染一次,称为一帧。浏览器的JS线程和渲染线程是互斥的,当JS线程占据太长时间,会导致渲染线程无法进行,从而造成掉帧,页面就会显得十分卡顿,对于用户来说这显然不是一个友好的体验,故而react引入了fiber的概念。

在fiber出现之后,react渲染的过程是虚拟DOM => fiber => 真实DOM,我们通过使用fiber统一去协调和调度真实DOM的生成,把一个耗时的任务切分成一个个小任务,分布在每一帧内分别去执行,保证每一帧能够有足够的时间去渲染。

react在一个帧空闲时间进行JS任务运行(约定为5ms),每次完成一个任务都要看看时间是否耗尽,如果还有剩余时间则会继续下一个任务,否则react则会让出控制权,让浏览器继续进行渲染工作,最大程度上保证不卡顿。

什么是fiber

广义上的fiber是一种架构,官方是这么解释的。

React Fiber是对核心算法的一次重新实现。

React Fiber的目标是增强它在动画、渲染和执行上的性能。它首要的特性就是逐渐增强的渲染能力:它可以将渲染工作切割成不同的块并且散布在多个帧上。

其他的关键特性包括更新时的暂停、废弃、以及重用;对不同类型的更新赋予权重;并发函数。

github.com/acdlite/rea…

fiber结构

对于官方的描述可能你有一些模糊,下面我们从结构切入看看它到底长什么样。我们知道,react每一个节点都会对于一个fiber节点,为了能够方便自由暂停,继续工作,把fiber节点设计的尽可能的独立。每个fiber独立,通过指针使其相互连接,这样就形成了一个树,实际上就是一个基于链表的树

A = { child: B }
B = { return: A, sibling: C }
C = { return: A, sibling: D, child: E }
D = { return: A, return: A }
E = { return: C }

fiber树具有如下特点

  • 每个节点return指向其父节点;
  • 父节点的child指向第一个子节点;
  • 每个节点的sibling指向第一个兄弟节点。

这样的结构可能看起来更改复杂,但是他有的优点是,普通的树结构无法代替的。比如,他的复用很简单,只要你过改变指针方向就可以实现服用;结构比较离散,单一节点的进度不影响其他节点;可以随时中断,开始遍历,这个结构允许我们从某一个节点开始遍历整棵树。

以上种种优点都符合fiber结构的期望,切片化任务,随时中断、开始任务。下面看看源码中定义的fiber,这里我们文中引入的源码都是经过一些删减的,我们只关注过程,删掉了一些不重要的兼容。

// react-reconciler/src/ReactFiber.js

import { NoFlags } from './ReactFiberFlags';

/**
 * 创建Fiber节点
 * @param {*} tag fiber的类型 函数组件0 类组件1 根元素3 原生组件5
 * @param {*} pendingProps 新属性,等待处理或者说生效的属性
 * @param {*} key 唯一标识
 */
export function FiberNode(tag, pendingProps, key) {
  this.tag = tag;
  this.key = key;
  this.type = null; // fiber对应虚拟dom的类型,来自于 虚拟DOM节点的type(span div p)
  // 每个虚拟DOM=>Fiber节点=>真实DOM
  this.stateNode = null; // 此fiber对应的真实DOM节点

  this.return = null; // 指向父节点
  this.child = null; // 指向第一个子节点
  this.sibling = null; // 指向弟弟

  // fiber哪里来的?通过虚拟DOM节点创建,虚拟DOM会提供pendingProps用来创建fiber节点的属性
  this.pendingProps = pendingProps; // 等待生效的属性
  this.memoizedProps = null; // 已经生效的属性

  // 每个fiber还会有自己的状态,每一种fiber 状态存的类型是不一样的
  // 类组件对应的fiber 存的就是类的实例状态,HostRoot存的就是要渲染的元素
  this.memoizedState = null;
  // 每个fiber身上可能还有更新队列
  this.updateQueue = null;
  // 副作用的标识,表示要针对此fiber节点进行何种操作
  this.flags = NoFlags; // 自己的副作用
  // 子节点对用的副使用标识
  this.subtreeFlags = NoFlags;
  // 替身,轮替 DOM-DIFF时会用到
  this.alternate = null;
}

// src/react-reconciler/src/ReactFiberFlags.js

export const NoFlags = 0b00000000000000000000000000;

FiberNode是一个工厂函数,属性在代码中已经给了基本注释,下面重点介绍下面几个属性:

  • tag

指的是fiber的类型,如函数组件、根节点、原生节点等等,下面介绍一些常见类型

// src/react-reconciler/src/ReactWorkTags.js

export const FunctionComponent = 0; //函数组件
export const ClassComponent = 1; //类组件
export const IndeterminateComponent = 2; // 未确定的组件
export const HostRoot = 3; //容器根节点
export const HostComponent = 5; //原生节点 span div h1
export const HostText = 6; //纯文件节点

我们都知道在react中,每一个节点都会对应一个fiber节点。当然这里也有特别的一点,就是对于单一的文本节点,是不会再为其创建单独的fiber节点的,比如<h1>hello<span>world</span></h1>这个结构中'h1'有'hello'和'span'两个子节点,那么react就会为'hello'创建一个对应的fiber,而'world'是span节点的唯一一个子节点,那么react就不会为'world'创建fiber,而是把world作为属性挂载到span的属性上,这里算是fiber的一个小优化吧。

HostRoot是对应根节点(最外层的容器节点,document.getElementById('root'))的fiber,react中称之为HostRootFiber,就是我们的根fiber,react中对于fiber冒泡操作都是到此为止。根fiber通过stateNode指向根节点,根节点通过current指向根fiber。

  • stateNode

指向fiber对应的真实节点。

  • flags

指的是此fiber要进行的副作用,挂载一些关于增删改的DOM操作。

// react-reconciler/src/ReactFiberFlags.js

// Don't change these two values. They're used by React Dev Tools.
export const NoFlags = /*                      */ 0b00000000000000000000000000;
export const PerformedWork = /*                */ 0b00000000000000000000000001;

// You can change the rest (and add more).
export const Placement = /*                    */ 0b00000000000000000000000010;
export const Update = /*                       */ 0b00000000000000000000000100;
export const ChildDeletion = /*                */ 0b00000000000000000000001000;
export const ContentReset = /*                 */ 0b00000000000000000000010000;
export const Callback = /*                     */ 0b00000000000000000000100000;
export const DidCapture = /*                   */ 0b00000000000000000001000000;
export const ForceClientRender = /*            */ 0b00000000000000000010000000;
export const Ref = /*                          */ 0b00000000000000000100000000;
export const Snapshot = /*                     */ 0b00000000000000001000000000;
export const Passive = /*                      */ 0b00000000000000010000000000;
export const Hydrating = /*                    */ 0b00000000000000100000000000;
export const Visibility = /*                   */ 0b00000000000001000000000000;
export const StoreConsistency = /*             */ 0b00000000000010000000000000;

这里列举出了react中部分flag,这里想说的是关于位计算,这样代码的可读性虽然比较差,但是优势是在快。

给出下面示例作为参考,也可以参考这篇文章

const Placement = 0b001;
const Update = 0b010;

let flags = 0b000;

// 增加操作
flags |= Placement;
flags |= Update;
console.log(flags.toString(2)); // 11

// 删除操作
flags = flags & ~Placement;

console.log(flags.toString(2)); // 10

//判断是否包含

// 0b010 & 0b001 = 0b000
console.log((flags & Placement) === Placement); // false
// 0b010 & 0b010 = 0b010
console.log((flags & Update) === Update); // true
//判断不包含
console.log((flags & Placement) === 0); // true
console.log((flags & Update) === 0); // true
  • subtreeFlags

用来记录子节点的副作用,React放弃了之前Effects List的方案,引入了subtreeFlags这个属性用来记录和跟踪子节点的副作用。子节点的副作用flags会向上冒泡标记父节点的subtreeFlags

遍历自上而下进行,当遍历到D节点时,它的flags为Update,向上冒泡到B,那么B的subtreeFlags就是Update,然后继续冒泡加上C的flags那么A的subtreeFlags就是Update | Placement

  • alternate

fiber的替身,React在渲染时使用了双缓冲技术,在React中最多会存在两棵Fiber树。当前屏幕正在显示的成为current Fiber,也就是正在渲染的fiber,也是老fiber;而正在内存中构建的FIber树成为workInProgress Fiber,将要渲染的fiber,也是新fiber。在我们显示一帧的同时可以在内存中进行下一帧的构建,这样就避免了因为只有一帧而出现白屏的现象,这种技术就是被称为双缓冲技术current FiberworkInProgress Fiber通过alternate指针相互指向,这样方便我们在新老fiber上的属性复用。

fiber树结构

现在我们通过示例结构渲染fiber树,最终会生成这样的fiber结构。

图中有currentworkInProgress两个HostRootFiber,两个根fiber引导了两棵fiber树,通过双缓冲技术对我们的页面进行渲染。当我们第一次渲染的时候current即当前fiber,并无节点挂在,而我们workInProgress正在内存中进行进行示例结构fiber链表的生成。当构建完成时,FiberRootNode即我们的根节点将current指向构建完成的fiber树,该fiber的stateNode指向根节点,实现current FiberworkInProgress Fiber位置的互换,两棵树便是如此进行轮替。

fiber树的遍历

fiber树的渲染是分为协调阶段和提交阶段的。

协调阶段

workLoopSync

看看工作循环阶段做了哪些事情,文中删去了一些无关代码,在代码中增加了一些注释来帮助我们理解。

// src/react-reconciler/src/ReactFiberWorkLoop.js
function workLoopSync() {
  // 只要是workInProgress存在就会一直处理这个fiber,处理完一个fiber workInProgress就会变成它的子fiber或者是sibling
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

/**
 * 执行工作单元
 * @param {*} unitOfWork 将要处理的fiber
 */
function performUnitOfWork(unitOfWork) {
  // 获取新的fiber对应的老fiber,这是值是可能为null的。
  const current = unitOfWork.alternate;

  // beginWork的作用其实就是分发函数,通过fiber不同的tag来分发给不同的函数来处理,返回值是下一个要处理的fiber,多数为子fiber,对fiber树进行深度遍历
  const next = beginWork(current, unitOfWork);
  // 将要生效的属性就变成了生效的属性
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // 如果没有子节点表示当前fiber已经完成工作
    completeUnitOfWork(unitOfWork);
  } else {
    // 如果有子节点,就让子节点成为下一个工作单元
    workInProgress = next;
  }
}

workInProgress指的是我们现在正在处理的fiber,它的作用其实就是一个指针,只要它不为null,就会不断执行performUnitOfWork来执行一个工作单元,通过beginWork来获取到下一个需要处理fiber,如果是null,则表示完成当前fiber,执行完成函数completeUnitOfWork,否则进行下一个需要进行的fiber。下面看看beginWorkcompleteUnitOfWork做了哪些事情。

beginWork

// src/react-reconciler/src/ReactFiberBeginWork.js

/**
 * 目标是根据虚拟dom构建新的fiber链表,其实就是分发函数,通过fiber不同的tag来分发给不同的函数来处理
 * @param {*} current 老fiber
 * @param {*} workInProgress 新fiber
 * @returns 子节点fiber
 */
export function beginWork(current, workInProgress) {
  switch (workInProgress.tag) {
    // 这里引入未决定的概念是因为,react中的组件是分为类组件和函数组件两种的,但是它们本质都是函数,需要进一步的确认。
    case IndeterminateComponent:
      return mountIndeterminateComponent(
        current,
        workInProgress,
        workInProgress.type,
      );
    case HostRoot:
      return updateHostRoot(current, workInProgress);
    case HostComponent:
      return updateHostComponent(current, workInProgress);
    case HostText:
      return null;
    default:
      return null;
  }
}

我们看到beginWork这里并没有做太多的事情,主要还是起到一个分发函数的作用,根据不同的tag调用不同的函数,我们以根节点为例,重点关注函数的返回

// src/react-reconciler/src/ReactFiberBeginWork.js
/**
 * 更新根节点,把child子fiber加到根节点上面
 * @param {*} current 当前节点
 * @param {*} workInProgress 正在处理的节点
 * @returns
 */
function updateHostRoot(current, workInProgress) {
  // 需要知道它的子虚拟DOM,知道它的儿子的虚拟DOM信息
  processUpdateQueue(workInProgress);
  // 经过processUpdateQueue计算之后,memoizedState就会挂载最新的更新
  const nextState = workInProgress.memoizedState; // workInProcess.memoizedState = { element }

  // nextChildren就是新的子虚拟DOM
  const nextChildren = nextState.element; // h1
  // 根据新的虚拟DOM生成子fiber链表
  reconcileChildren(current, workInProgress, nextChildren);

  return workInProgress.child; // { tag: 5, type: 'h1'}
}

这里可以看到updateHostRoot的过程主要是分成三步:

  • 通过processUpdateQueue计算更新,把获取虚拟DOM放在workInProgress.memoizedState上;
  • 通过reconcileChildren根据虚拟DOM生成fiber链表;
  • 返回workInProgress的第一个儿子。

我们的最终目的就是返回当前工作节点的第一个字节点,能够使我们的遍历能够往更深处进行,进行深度优先的遍历。

processUpdateQueue

这里我们简单的说一下processUpdateQueue过程,这里不影响主流程,主要是计算更新队列的过程

// src/react-reconciler/src/ReactFiberClassUpdateQueue.js
/**
 * 根据老状态和更新队列中的更新计算最新的状态
 * @param {*} workInProcess 要计算的fiber
 */
export function processUpdateQueue(workInProcess) {
  // 取出更新队列
  const queue = workInProcess.updateQueue;
  // 这里要知道更新队列的结构,pending是一个循环列表,shared.pending指向的是最后一个挂载的更新
  const pendingQueue = queue.shared.pending;

  // 如果有更新,或者说更新队列有内容
  if (pendingQueue !== null) {
    // 清除更新队列,防止之后重复更新
    queue.shared.pending = null;
    // 获取更新队列中的最后一个更新 update = { payload: { element: 'h1'}}
    const lastPendingUpdate = pendingQueue;
    const firstPendingUpdate = lastPendingUpdate.next;
    // 断开更新链表,变成一个单链表 方便更新
    lastPendingUpdate.next = null;

    // 读取老的状态 null
    let newState = workInProcess.memoizedState;
    let update = firstPendingUpdate;

    while (update) {
      // 根据老状态和更新计算新的状态
      newState = getStateFromUpdate(update, newState);

      update = update.next;
    }

    // 把最终的计算的状态赋值给memoizedState
    workInProcess.memoizedState = newState;
  }
}

/**
 * 合并更新,这里要合并的是更新队列和memoizedState上面的更新
 * @param {*} update 更新队列
 * @param {*} prevState memoizedState
 * @returns
 */
function getStateFromUpdate(update, prevState) {
  switch (update.tag) {
    case UpdateState: {
      const { payload } = update;
      return assign({}, prevState, payload);
    }
  }
}

我们如果想要生成对应的fiber就必须要有对应的虚拟DOM,而这里的更新队列是一个收尾相连的循环链表,我们这里需要整合更新队列上面的所有更新,然后把最终计算结果赋值给memoizedState,而这个结果就是我们需要虚拟DOM。

这里可以看一下,react中处理更新队列的过程:

首先清除之前队列,避免重复更新,拿到首位更新firstPendingUpdate作为更新的开始,找到lastPendingUpdate断开循环链表,否则后面的while循环没有结束条件,之后就是进行while循环挂载所有的更新,然后返回最终计算的值。其实在react中更新逻辑跟此处的逻辑都相差不大,大量的递归遍历也导致react的性能比较差(相较于其他框架)。

reconcileChildren
// src/react-reconciler/src/ReactFiberBeginWork.js
/**
 * 根据新的虚拟Dom生成新的fiber链表
 * @param {*} current 老的父fiber
 * @param {*} workInProgress 新的父fiber
 * @param {*} nextChildren 新的子虚拟DOM
 */
function reconcileChildren(current, workInProgress, nextChildren) {
  // 如果此新fiber没有老fiber,说明此新fiber是新创建的
  if (current == null) {
    workInProgress.child = mountChildFibers(workInProgress, null, nextChildren);
  } else {
    // 如果说有老fiber的话,做DOM-DIFF 拿老的子fiber链表和新的虚拟DOM进行比较,进行最小话的更新
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
    );
  }
}

//src/react-reconciler/src/ReactChildFiber.js
// 有老的父fiber更新的时候就用这个
export const reconcileChildFibers = createChildReconciler(true);
// 如果没有老父fiber,重新挂载逻辑
export const mountChildFibers = createChildReconciler(false);

/**
 *
 * @param {*} shouldTrackSideEffects 是否跟踪副作用
 */
function createChildReconciler(shouldTrackSideEffects) {
  ...
  
  /**
   * 比较子fiber dom-diff就是用老的子fiber链表和新的虚拟dom进行比较的过程,这里的究极目的就是两个,dom-diff和创建fiber
   * @param {*} returnFiber 新的父fiber,这里知识保证return的指向
   * @param {*} currentFirstFiber 老fiber的第一个子fiber,用来和新的虚拟dom进行比较
   * @param {*} newChild 新的子虚拟dom,这里可能是一个单节点(对象),多节点(数组)
   */
  function reconcileChildFibers(returnFiber, currentFirstFiber, newChild) {
    // 现在暂时值考虑新节点只有一个的情况
    if (typeof newChild === 'object' && newChild !== null) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          return placeSingleChild(
            reconcileSingleElement(returnFiber, currentFirstFiber, newChild),
          );
        default:
          break;
      }
    }

    // newChild存在多节点情况,此时为一个数组
    if (isArray(newChild)) {
      return reconcileChildrenArray(returnFiber, currentFirstFiber, newChild);
    }

    // 兜底返回null,这里指的是单独的文本节点情况,他们不需要fiber
    return null;
  }

  return reconcileChildFibers;
}

我们通过reconcileChildren根据是否拥有老fiber来决定重新挂载还是协调,即是否跟踪副作用。其实这里这么设计的原因是,添加不同的副作用,如果存在老fiber就加上Placement的副作用,否则不需要。到最后我们调用的是reconcileChildFibers其实就是根据不同的类型来创造不同类型的fiber,并把它们插在准确的位置。

completeUnitOfWork

我们通过beginWork不断的向深处去寻找我们的子节点,即完成递归中“递”的过程,而在这里我们的completeUnitOfWork就完成的了“归”的过程,下面我们结合源码看看是如何实现的。

书接上文,当我们的next === null的时候,这意味着我们的beginWork没有返回下个需要操作的子节点,那我们现在应该做的就有两个事情:

  • 检查是否存在兄弟节点,如果有则继续递归;
  • 如果没有就返回当前的父亲节点,让他判断是否存在兄弟节点,如果有则继续递归;
  • 如果父亲节点依旧没有兄弟节点,则继续返回,继续检查.....

如此以往,我们便可以遍历到所有节点。下面具体看看是如何工作的。

// src/react-reconciler/src/ReactFiberWorkLoop.js

/**
 * 完成单个fiber,这里要做主要是创建真实节点和让workInProgress指向sibling或者return,对fiber树进行广度的遍历
 * @param {*} unitOfWork
 */
function completeUnitOfWork(unitOfWork) {
  let completedWork = unitOfWork;

  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;
    // 执行此fiber的玩成工作,如果是原生组件的就是创建真实节点
    completeWork(current, completedWork);
    // 如果有弟弟,就对弟弟进行fiber链表的构建
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      workInProgress = siblingFiber;

      return;
    }
    //如果没有弟弟,需要做两件事:
    // 1. 这个节点的父节点已经全部完成,标记其完成
    // 2. 将workInProgress指向这个节点的父节点,看看他是否还有sibling节点需要遍历
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
}

如果没有子节点,就标志着当前节点的完成,调用我们completeWork,这主要是创建真实DOM节点,冒泡flags,这个我们放在下一小节讲解。这里我们需要注意到两个指针,分别是workInProgresscompletedWork,其中前者是开启/结束performUnitOfWork循环的条件,

而后者是开启/结束自身循环的条件while (completedWork !== null),正是这种内外两层循环能够对fiber分别进行深度和广度的遍历。

completeWork
// src/react-reconciler/src/ReactFiberCompleteWork.js
/**
 * 完成一个节点,这里需要做的就是根据fiber创建真实dom节点,冒泡节点属性
 * @param {*} current
 * @param {*} workInProgress
 */
export function completeWork(current, workInProgress) {
  const newProps = workInProgress.pendingProps;
  switch (workInProgress.tag) {
    case HostRoot:
      bubbleProperties(workInProgress);
      break;
    case HostComponent:
      // 如果是原生节点
      // 创建真实的dom节点
      const { type } = workInProgress;
      const instance = createInstance(type, newProps, workInProgress);
      // 把所有儿子都添加在自己身上
      appendAllChildren(instance, workInProgress);
      workInProgress.stateNode = instance;
      // 挂载初始属性
      finalizeInitialChildren(instance, type, newProps);
      // 冒泡属性
      bubbleProperties(workInProgress);
      break;
    case HostText:
      // 如果是文本节点,那就创建真实的文本节点
      const newText = newProps;
      // 创建真实节点并传入stateNode
      workInProgress.stateNode = createTextInstance(newText);
      // 向上冒泡属性
      bubbleProperties(workInProgress);
      break;

    default:
      break;
  }
}

/**
 * 将当前fiber的所有子节点,以及子节点的儿子的副作用都挂在fiber上面
 * @param {*} completedWork
 */
function bubbleProperties(completedWork) {
  let subtreeFlags = NoFlags;
  let child = completedWork.child;

  while (child !== null) {
    subtreeFlags |= child.subtreeFlags;
    subtreeFlags |= child.flags;
    child = child.sibling;
  }

  completedWork.subtreeFlags = subtreeFlags;
}

// src/react-dom-bindings/src/client/ReactDOMHostConfig.js
export function createInstance(type) {
  return document.createElement(type);
}

这里可以看到completeWork做的事情就是根据不同的类型创造不同的DOM实例,之后我们可以将对应真实DOM插在对应位置,bubbleProperties就是收集subtreeFlags子节点的副作用,这里在之后的dom-diff会用到,会在后面的篇章介绍到。这里我们可以看一下finalizeInitialChildren是怎么挂载属性。

finalizeInitialChildren
// src/react-dom-bindings/src/client/ReactDOMHostConfig.js
/**
 * 计划初始化子节点属性
 * @param {*} domElement 父节点
 * @param {*} type 父节点类型
 * @param {*} props 父节点属性
 */
export function finalizeInitialChildren(domElement, type, props) {
  setInitialProperties(type, domElement, props);
}

// src/react-dom-bindings/src/client/ReactDOMComponent.js
export function setInitialProperties(tag, domElement, props) {
  // 设置初始dom属性
  setInitialDOMProperties(tag, domElement, props);
}

// src/react-dom-bindings/src/client/ReactDOMComponent.js
const STYLE = 'style';
const CHILDREN = 'children';

function setInitialDOMProperties(tag, domElement, nextProps) {
  for (const propKey in nextProps) {
    if (nextProps.hasOwnProperty(propKey)) {
      const nextProp = nextProps[propKey];

      // 添加style属性
      if (propKey === STYLE) {
        setValueForStyles(domElement, nextProp);
      } else if (propKey === CHILDREN) {
        // 向文本节点中加入文本
        if (typeof nextProp === 'string') {
          setTextContent(domElement, nextProp);
        } else if (typeof nextProp === 'number') {
          setTextContent(domElement, `${nextProp}`);
        }
      } else if (nextProp !== null) {
        // 添加其他dom属性
        setValueForProperty(domElement, propKey, nextProp);
      }
    }
  }
}

// src/react-dom-bindings/src/client/CSSPropertyOperations.js
export function setValueForStyles(node, styles) {
  const { style } = node;

  for (const styleName in styles) {
    if (styles.hasOwnProperty(styleName)) {
      const styleValue = styles[styleName];
      style[styleName] = styleValue;
    }
  }
}

// src/react-dom-bindings/src/client/DOMPropertyOperations.js
export function setValueForProperty(node, name, value) {
  if (value === null) {
    node.removeAttribute(name);
  } else {
    node.setAttribute(name, value);
  }
}

React中相互调用还是挺多的,这化简了一些过程,只关注我们我们想看到的,我们通过setValueForStyles方法把我们style属性里面的样式加在真实的style属性里面,实现虚拟DOM和真实DOM的映射。

其他属性的添加,也是类似于style属性的添加,通过removeAttributesetAttribute方法,为我们的真实DOM移除或者添加属性,

总结

看到这里的读者,可能是依旧对于整个协调过程不太理解,其实对于源码的学习,大家只有真正的去debugger几遍才能具体了解过程,也能加深自己的理解。这里借用一张图来说明整个遍历过程,

workLoopSync开始,会根据workInProgress是否存在而不断的调用performUnitOfWork去执行每一个需要完成的fiber,根据beginWork来对不同的fiber进行不同的操作并返回下一个需要完成的子fiber,不不断的完成深度遍历,实现递归中的,当没有返回子fiber时,标志着该fiber的完成,之后继续调用completeUnitOfWork来完成这一个工作,去检测此fiber是否还有兄弟fiber,和是否返回父节点的方式,来不断进行广度和递归中的操作。

提交阶段

前面的协调阶段我们已经根据我们的fiber生成了我们的真实DOM,现在我们在提交阶段就可以执行副作用,修改真实DOM。下面我们看看源码做了哪些事情。

// src/react-reconciler/src/ReactFiberWorkLoop.js
/**
 * 提交节点
 * @param {*} root
 */
function commitRoot(root) {
  const { finishedWork } = root;
  // 判断子节点和自己身上有没有副作用
  const subtreeHasEffects =
    (finishedWork.subtreeFlags & MutationMask) !== NoFlags;
  const rootHasEffect = (finishedWork.flags & MutationMask) !== NoFlags;
  // 如果自己的副作用或者子节点有副作用就进行DOM操作
  if (subtreeHasEffects || rootHasEffect) {
    commitMutationEffectsOnFiber(finishedWork, root);
  }
  // 等DOM变更之后,更改root中current的指向
  root.current = finishedWork;
}

// src/react-reconciler/src/ReactFiberFlags.js
export const MutationMask = Placement | Update;

这里的finishedWork是经过协调阶段的根fiber,就是完成的workInProgress Fiber,提交阶段关注的就是副作用的处理,我们通过判断子节点和自身是否有副作用来决定是否进行进入我们的commitMutationEffectsOnFiber,其中MutationMask是一个关于副作用的集合,它包括更新、插入、删除等副作用。

遍历副作用

// src/react-reconciler/src/ReactFiberCommitWork.js
/**
 * 提交在fiber上面的副作用
 * @param {*} finishedWork 完成的工作,HostRootFiber
 * @param {*} root FiberRootNode
 */
export function commitMutationEffectsOnFiber(finishedWork, root) {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case HostRoot:
    case HostComponent:
    case HostText: {
      // 先遍历他们的子节点,处理他们身上的副作用
      recursivelyTraverseMutationEffects(root, finishedWork);
      // 再处理自己身上的副作用
      commitReconciliationEffects(finishedWork);
      break;
    }
    default:
      break;
  }
}

/**
 * 递归遍历有变动的副作用节点
 * @param {*} root
 * @param {*} finishedWork
 */
function recursivelyTraverseMutationEffects(root, parentFiber) {
  if (parentFiber.subtreeFlags & MutationMask) {
    // 这里其实什么都没有做,就是遍历,处理操作之后会补出来
    // 如果传入的节点子节点有副作用
    let { child } = parentFiber;
    while (child !== null) {
      commitMutationEffectsOnFiber(child, root);
      child = child.sibling;
    }
  }
}

react里面的遍历基本上都是深度优先的遍历,我们看看这里是怎么实现的,首先通过commitMutationEffectsOnFiber通过判断不同的类型调用不同的函数,这里只关注了HostRoot,之后进入recursivelyTraverseMutationEffects,这里的subtreeFlags就完全发挥了作用,因为subtreeFlags是通过子节点不断冒泡到父节点的,这里我们通过判断这个字段就决定是否继续向下进行,这也算是性能优化的一种方式。当条件满足时,根据其子节点是否存在,继续调用commitMutationEffectsOnFiber然后根据其子节点种类继续往深处遍历,而在while循环中,会通过sibling的存在继续进行循环来进行广度的遍历。

处理本身的副作用
// src/react-reconciler/src/ReactFiberCommitWork.js
/**
 * 处理自己身上的副作用
 * @param {*} finishedWork
 */
function commitReconciliationEffects(finishedWork) {
  const { flags } = finishedWork;

  if (flags & Placement) {
    // 如果此fiber要执行插入操作
    // 把此fiber对应的真实dom节点添加到父真实dom上面
    commitPlacement(finishedWork);
    // 删除flags里的Plcement
    finishedWork.flags & ~Placement;
  }
}

这里只是列出了插入的情况,条件满足时,执行commitPlacement,之后删除fiber上的Placement的flags。

commitPlacement
/**
 * 把此fiber的真实dom插入到父dom中去
 * @param {*} finishedWork 完成的工作
 */
function commitPlacement(finishedWork) {
  // 获取最近的,可以插入dom的 父fiber
  const parentFiber = getHostParentFiber(finishedWork);

  switch (parentFiber.tag) {
    case HostRoot: {
      const parent = parentFiber.stateNode.containerInfo;
      // 获取最近的弟弟真实dom节点
      const before = getHostSibling(finishedWork);

      insertOrAppendPlacementNode(finishedWork, before, parent);
      break;
    }
    case HostComponent: {
      const parent = parentFiber.stateNode;
      const before = getHostSibling(finishedWork);

      insertOrAppendPlacementNode(finishedWork, before, parent);
      break;
    }
    default:
      break;
  }
}

commitPlacement把此fiber的真是DOM插入到父DOM中,这里我们怎么才能查到正确的位置,需要考虑的有两点

  • 如果此DOM是唯一一个子节点,那我们需要其父节点,执行appendChild方法插入即可;
  • 如果此DOM不是唯一一个子节点,那我们就需要其兄弟节点,执行insertBefore方法找到其正确的位置。

另外这里还需要的注意的是,我们要找到能使用的真是DOM才能执行此操作,因为我们取到的真实DOM都是通过fiber上的stateNode或者根fiber上面的containerInfo,但是并不是所有的fiber都对应着真实DOM(比如说是FunctionComponent对应的fiber)所以,我们必须通过正确的方法才能找正确的节点,特别是对兄弟节点的寻找,更是难点,下面一一解释。

getHostParentFiber
// src/react-reconciler/src/ReactFiberCommitWork.js
/**
 * 找到最近的可以插入的fiber
 * @param {*} fiber
 * @returns
 */
function getHostParentFiber(fiber) {
  let parent = fiber.return;

  while (parent !== null) {
    if (isHostParent(parent)) {
      return parent;
    }

    parent = parent.return;
  }
}

// src/react-reconciler/src/ReactFiberCommitWork.js
/**
 * 判断该fiber是否是可以插入的fiber,根fiber或者原生节点的fiber
 * @param {*} fiber
 * @returns
 */
function isHostParent(fiber) {
  return fiber.tag === HostComponent || fiber.tag === HostRoot;
}

getHostParentFiber 的实现比较简单就是一直向上找,知道满足HostComponent原生节点和HostRoot根节点的条件。

getHostSibling
// src/react-reconciler/src/ReactFiberCommitWork.js
/**
 * 找到想要的锚点,根据弟弟dom来插入节点
 * @param {*} fiber
 */
function getHostSibling(fiber) {
  let node = fiber;

  siblings: while (true) {
    while (node.sibling === null) {
      // 如果没有弟弟节点
      if (node.return === null || isHostParent(node.return)) {
        // 如果没有父节点,或者是可以
        return null;
      }
      node = node.return;
    }

    // 如果弟弟节点存在存在
    node = node.sibling;
    while (node.tag !== HostComponent && node.tag !== HostText) {
      // 如果不是原生节点,或者是文本节点,这些真实节点就继续遍历
      if (node.flags & Placement) {
        // 如果也是插入的fiber就不用继续遍历了 直接返回第一层找下一个弟弟
        continue siblings;
      } else {
        node = node.child;
      }
    }

    // 如果不是插入 就直接返回stateNode
    if (!(node.flags & Placement)) {
      return node.stateNode;
    }
  }
}

这里的遍历比较复杂因为我们fiber结构是比较复杂的,最主要的原因是因为并不是一个fiber都会对应一个真实DOM,并且还要保证找到的弟弟节点不是新插入的节点,因为新插入的节点用来判断是没有意义的,下面借助图片的形式来帮助大家来理解这个过程。

我们的遍历是从最低层开始的,即我们从1节点开始,进入siblings循环,进入第一个子循环,判断其是否有兄弟节点,没有就返回父节点,图中2节点,之后继续返回3号节点。该节点存在兄弟节点,进入第二个子循环,因为4节点的类型不符合(要求HostComponent或者是HostText,而4节点是FunctionComponent),则继续向下遍历到5节点,之后到6节点。如果6节点flags是Placement则继续siblings循环,继续以6节点开始重新循环,直到找的正确的节点,返回其真实DOM,如果找不到正确的节点就返回null。

insertOrAppendPlacementNode
// src/react-reconciler/src/ReactFiberCommitWork.js
/**
 * 把对应的子节点真实dom插入到父节点中
 * @param {*} node 将要被处理的fiber节点
 * @param {*} before 真实的弟弟真实dom
 * @param {*} parent 真实的父dom
 *
 * 这里单单通过parent是无法正确的添加子节点,还需要一个弟弟dom来确定位置,比如parent有两个子节点,我需要在两人之间插入新的节点appendChild显然是做不到的
 */
function insertOrAppendPlacementNode(node, before, parent) {
  const { tag } = node;
  // 判断此fiber对应的节点是不是真实dom节点
  const isHost = tag === HostComponent || tag === HostText;

  // 如果是的话就直接插入
  if (isHost) {
    const { stateNode } = node;
    if (before) {
      insertBefore(parent, stateNode, before);
    } else {
      appendChild(parent, stateNode);
    }
  } else {
    // 如果node不是真实DOM节点,获取它的大儿子
    const { child } = node;

    if (child !== null) {
      // 把大儿子添加到父节点DOM中
      insertOrAppendPlacementNode(child, before, parent);

      let { sibling } = child;
      while (sibling !== null) {
        insertOrAppendPlacementNode(sibling, before, parent);
        sibling = sibling.sibling;
      }
    }
  }
}

我们拿到beforeparentDOM之后就可以进行我们的插入操作了,这里同上文说的就是执行insertBeforeappendChild两个方法插入正确的节点,完成提交过程。

总结

提交阶段就是进行副作用操作,来执行以一些插入,删除等操作的真实DOM操作。其中难点就是getHostSibling中关于其最近兄弟节点的寻找,多重循环找到正确节点。

最后

源码是真的好难读啊,不仅要求我们理解原理,还要用代码体现出来,而对于React这种成熟的框架,里面公共的方法和函数比较多,经常就能看见函数里面调过来调过去,很容易就搞晕了😷,这里还是建议大家可以用源码多走几次dubgger,观察👀一下函数的输入和输出到底是什么,这样才可以加深自己的理解。如果感觉源码太重,可以考虑作者用到例子,会有一些简化,但不是特别简化,还在不定期更新中~,地址链接

参考