Wenzi

React18 源码解析之虚拟 DOM 转为 fiber 树

蚊子前端博客
发布于 2022/09/18 10:01
主要来了解下把jsx转为fiber节点的流转过程

我们解析的源码是 React18.1.0 版本,请注意版本号。React 源码学习的 GitHub 仓库地址:https://github.com/wenzi0github/react

我们在文章 React18 源码解析之 fiber 等数据结构 中讲解了 jsx, element 和 fiber 的基本结构。这里我们主要讲下如何将 jsx 转为 fiber 节点组成的 fiber 树。

我们为便于理解整个转换的过程,会做一些流程上的精简:

  1. 大部分只考虑初始渲染阶段,因此诸如副作用的收集等暂时就不考虑了,不过偶尔也会涉及到一点两棵 fiber 树的对比;
  2. 忽略各种任务的优先级的调度;
  3. React 中各个节点的类型很多,如函数组件、类组件、html 节点、Suspense 类型的组件、使用 lazy()方法的动态组件等等,不过我们这里主要讲解下函数组件、类组件、html 节点这 3 个;

render()方法是我们整个应用的入口,我们就从这里开始。我们在之前的文章React18 源码解析之 render()入口方法中,只是讲解了 render()方法的挂载方式。这里我们会深入了解到从 jsx 转为 fiber 的整个过程。

1. 起始 #

在开始讲解前,我们先定义下要渲染的 React 组件,方便我们后续的理解:

const FuncComponent = () => {
  return (
    <p>
      <span>this is function component</span>
    </p>
  );
};

class ClassComponent extends React.Component {
  render() {
    return <p>this is class component</p>;
  }
}

function App() {
  return (
    <div className="App">
      <FuncComponent />
      <ClassComponent />
      <div>
        <span>123</span>
      </div>
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

我们编写这个结构主要是为了看下,嵌套的、并列的标签,流程是如何流转的,中间经历了多少个复杂的过程,才将 element 转为 fiber 节点。

2. 初始的 element 如何处理? #

我是强烈建议使用debug-react项目来进行调试的,因为里面的分支和各种变量很多,若只是单纯地硬看,很容易懵,不知道这些变量具体是什么值!那么运行起一个 React 项目后,可以在某些关键节点、变量,打断点输出,就方便很多。

render()传入的 element 和后续的 element 的操作是不一样的。其他的 element 都可以通过执行函数组件或者类组件的实例来得到,而初始的 element 是直接提供的。

我们直接输出下<App />

console.log(<App />);

App组件的element结构

可以看到,element 结构并不是把 React 所有的 jsx 都组织起来,形成巨大的嵌套结构,他只是当前某个节点里的 jsx 结构。若当前是函数组件、类组件等,内部的 jsx 可以通过执行属性 type 对应的函数或类的实例来得到,然后继续递归下去,最终把所有的 jsx 都全部解析出来。

入口函数 render() 传入的参数,就是上面<App />的 element 结构。内部通过属性_internalRoot得到整个应用的根节点 root,然后又调用了 updateContainer():

// children就是我们传入的<App />,即通过jsx编译后的element结构
ReactDOMRoot.prototype.render = function(children: ReactNodeList) {
  const root = this._internalRoot; // FiberRootNode
  updateContainer(children, root, null, null);
};

我们去掉 dev 代码和任务优先级的调度,看下 updateContainer() 主要的流程:

/**
 * 将element结构转为fiber树
 * @param {ReactNodeList} element 虚拟DOM树
 * @param {OpaqueRoot} container FiberRootNode 节点
 * @param {?React$Component<any, any>} parentComponent 在React18传到这里的是null
 * @param {?Function} callback render()里的callback,不过从React18开始就没了,传入的是null
 * @returns {Lane}
 */
export function updateContainer(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  callback: ?Function,
): Lane {
  /**
   * current:
   * const uninitializedFiber = createHostRootFiber(tag, isStrictMode, concurrentUpdatesByDefaultOverride,);
   */
  // FiberRootNode.current 现在指向到当前的fiber树,
  // 若是初次执行时,current树只有hostFiber节点,没有其他的
  const current = container.current;
  const eventTime = requestEventTime();
  const lane = requestUpdateLane(current);

  // 结合 lane(优先级)信息,创建 update 对象,一个 update 对象意味着一个更新
  /**
   * const update: Update<*> = {
   *   eventTime,
   *   lane,
   *   tag: UpdateState,
   *   payload: null,
   *   callback: null,
   *   next: null,
   * };
   * @type {Update<*>}
   */
  const update = createUpdate(eventTime, lane);
  update.payload = { element };

  // 处理 callback,这个 callback 其实就是我们调用 ReactDOM.render 时传入的 callback
  // 不过从React18开始,render不再传入callback了,即这里的if就不会再执行了
  callback = callback === undefined ? null : callback;
  if (callback !== null) {
    update.callback = callback;
  }

  /**
   * 将update添加到current的更新链表中
   * 执行后,得到的是 current.updateQueue.shared.pending = sharedQueue
   * sharedQueue是React中经典的循环链表,
   * 将下面的update节点插入这个shareQueue的循环链表中,pending指针指向到最后插入的那个节点上
   */
  enqueueUpdate(current, update, lane);

  /**
   * 这里调用的链路很深,做了很多事情,如:
   * 流程图: https://docs.qq.com/flowchart/DS0pVdnB0bmlVRkly?u=7314a95fb28d4269b44c0026faa673b7
   * scheduleUpdateOnFiber() -> ensureRootIsScheduled(root) -> performSyncWorkOnRoot(root)
   * -> renderRootSync(root) -> workLoopSync()
   */
  /**
   * 这里传入的current是HostRootFiber的fiber节点了,虽然他的下面没有其他fiber子节点,
   * 但它的updateQueue上有element结构,可以用来构建fiber节点
   * 即 current.updateQueue.shared.pending = sharedQueue,element结构在sharedQueue其中的一个update节点,
   * 其实这里只有一个update节点
   */
  const root = scheduleUpdateOnFiber(current, lane, eventTime);
  if (root !== null) {
    entangleTransitions(root, current, lane);
  }

  return lane;
}

我们再梳理下函数 updateContainer()的流程:

  1. updateContainer(element, container)传入了两个参数,element 就是 jsx 编译后的 element 结构,而 container 表示的是 FiberRootNode,整个应用的根节点,并不是 DOM 元素;
  2. container.current 指向的就是目前唯一的一棵 fiber 树的根节点,并 current 变量存储该节点;
  3. 将 element 结构放到 current 节点的属性中,方便后续的构建:current.updateQueue.shared.pending = [{payload:{element}}];pending 是一个环形链表,element 就放在这个环形链表的节点中,在初始更新阶段,只有这一个 update 节点;
  4. 调用 scheduleUpdateOnFiber(current);该方法内部将 element 取出,构建出下一个 fiber 节点;

3. scheduleUpdateOnFiber #

接下来我们看下 scheduleUpdateOnFiber 函数。

markUpdateLaneFromFiberToRoot(fiber, lane)方法会将当前 fiber 节点往上知道 FiberRootNode 所有节点赋值 lane 这个优先级,同时返回整个应用的根节点 FiberRootNode。

我们忽略中间任务调度的步骤,直接进入到 ensureRootIsScheduled(root) 函数中,这里的 root 参数就是整个应用的根节点 FiberRootNode。这个函数里也是一堆的任务调度,我们快进到 performConcurrentWorkOnRoot.bind(null, root),这里面又快进到了 renderRootSync(root)。

4. renderRootSync #

这里有一个很重要的准备操作,这里的 root 是整个应用的根节点,即 FiberRootNode,会传入到函数 prepareFreshStack() 中,主要是为了接下来的递归,初始化一些数据和属性:

/**
 * 整个应用目前只有 FiberRootNode和current两个节点,current树只有一个根节点,就是current自己;
 * 另一棵树还没有创建,结构是这样: https://mat1.gtimg.com/qqcdn/tupload/1659715740891.png
 * prepareFreshStack() 函数的作用,就是通过current树的根节点创建出另一棵树的根节点,
 * 并将这两棵树通过 alternate 属性,实现互相的指引
 * workInProgressRoot: 是将要构建的树的根节点,初始时为null,经过下面 prepareFreshStack() 后,
 * root.current给到workInProgressRoot,
 * 即使第二次调用了,这里的if逻辑也是不会走的
 * workInProgress初始指向到workInProgressRoot,随着构建的深入,workInProgress一步步往下走
 */
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
  /**
   * 将整个应用的根节点和将要更新的fiber树的根节点赋值到全局变量中
   * root是当前整个应用的根节点
   */
  prepareFreshStack(root, lanes);
}

我们看下函数 prepareFreshStack() 的实现:

/**
 * 准备新堆栈,返回「更新树」的根节点
 * @param root
 * @param lanes
 * @returns {Fiber}
 */
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
  root.finishedWork = null;
  root.finishedLanes = NoLanes;

  workInProgressRoot = root; // 整个React应用的根节点,即 FiberRootNode

  /**
   * prepareFreshStack()个人认为是只在初始化时执行一次,root是整个应用的根节点,而root.current就是默认展示的那棵树,
   * 在初始化时,current 树其实也没内容,只有这棵树的一个根节点;
   * 然后利用current的根节点通过 createWorkInProgress()方法 创建另一棵树的根节点rootWorkInProgress
   * createWorkInProgress()方法内则判断了 current.alternate 是否为空,来决定是否可以复用这个节点,
   * 在render()第一次调用时,root.current.alternate 肯定为空,这里面则会调用createFiber进行创建
   */
  const rootWorkInProgress = createWorkInProgress(root.current, null);
  // 初始执行时,workInProgress指向到更新树的根节点,
  // 在mount阶段,workInProgress是新创建出来的,与current树的根节点workInProgressRoot,肯定是不相等的
  workInProgress = rootWorkInProgress;
  workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes;
  workInProgressRootExitStatus = RootInProgress;
  workInProgressRootFatalError = null;
  workInProgressRootSkippedLanes = NoLanes;
  workInProgressRootInterleavedUpdatedLanes = NoLanes;
  workInProgressRootRenderPhaseUpdatedLanes = NoLanes;
  workInProgressRootPingedLanes = NoLanes;
  workInProgressRootConcurrentErrors = null;
  workInProgressRootRecoverableErrors = null;

  return rootWorkInProgress;
}

总结下 prepareFreshStack() 的作用:

  1. 将整棵树的根节点 root 给到 workInProgressRoot;
  2. createWorkInProgress() 利用 current 节点创建出「更新树」的根节点;整个函数在这里我们就不展开讲了,大致内容就是判断 current.alternate (即 current 互相对应的那个节点)是否为空,若为空则创建出一个新节点;若不为空,则直接复用之前的节点,然后将新特性给到这个节点(不过我们这里传入的是 null);
  3. workInProgress 指针初始指向到「更新树」的根节点,在接下来的递归操作中,该指针一直在变动;

准备好之后,我们再回到函数 renderRootSync(),就可以顺着 workInProgress 指针往下进行了,这里我们就进入到了函数 workLoopSync() 中。

5. workLoopSync #

该函数很简单,就是一个 while 循环,每次循环时,都会执行函数 performUnitOfWork() ,然后操作 workInProgress 指向的那个 fiber 节点,直到 workInProgress 为 null。

我们刚才在上面的 prepareFreshStack() 中,workInProgress 指针指向到了「更新树」的根节点 rootWorkInProgress(即跟 current 树根节点长得一样的那个节点),这个 fiber 节点里的 updateQueue.shared.pending 中的一个 update 里,存放着 element 结构。

function workLoopSync() {
  // Already timed out, so perform work without checking if we need to yield.
  // 已经超时了,所以即使需要让出时,也不再做检查,直到把workInProgress执行完
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

6. performUnitOfWork #

处理 workInProgress 指向的那个 fiber 节点。

function performUnitOfWork(unitOfWork: Fiber): void {
  /**
   * 初始mount节点时,unitOfWork 是上面workLoopConcurrent()中传入的 workInProgress,
   * unitOfWork.alternate 指向的是 current
   */
  const current = unitOfWork.alternate;

  let next;
  /**
   * current为当前树的那个fiber节点
   * unitOfWork为 更新树 的那个fiber节点
   * 在初始mount节点,current和unitOfWork都是fiberRoot节点
   * 在第一次调用beginWork()时,element结构通过其一系列的流程,创建出了第一个fiber节点,即<App />对应的fiber节点(我们假设<App /> 是最外层的元素)
   * next就是第一个fiber节点,然后next给到workInProgress,接着下一个循环
   */
  next = beginWork(current, unitOfWork, subtreeRenderLanes);

  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // unitOfWork已经是最内层的节点了,没有子节点了
    // If this doesn't spawn new work, complete the current work.
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

将当前 current 节点和更新树的节点,都传给 beginWork(current, unitOfWork) 函数。简单来说,beginWork 会根据当前 fiber 节点中的 element 结构,创建出新的 fiber 节点,workInProgress 再指向到这个新 fiber 节点继续操作,直到所有的数据都操作完。具体的操作流程,我们单独开一篇文章进行讲解。

这里我们只需要知道,诸如函数组件、类组件也是 fiber 节点,也是整棵 fiber 树的一部分。其内部的 jsx(element)再继续转为 fiber 节点。

若 beginWork()返回的 next 是 null,说明当前节点 workInProgress 已经是最内层的节点了,就会进入到函数 completeUnitOfWork() 中。可以看到执行的流程是深度优先,即若当前 fiber 还能构造出子节点,即一直向下构造。直到没有子节点后,才会流转到兄弟节点和父级节点。

以我们上面的写的 React 组件为例,workInProgress 指向是的树的根节点,这个根节点没有具体的 jsx 结构。

const FuncComponent = () => {
  return (
    <p>
      <span>this is function component</span>
    </p>
  );
};

class ClassComponent extends React.Component {
  render() {
    return <p>this is class component</p>;
  }
}

function App() {
  return (
    <div className="App">
      <FuncComponent />
      <ClassComponent />
      <div>
        <span>123</span>
      </div>
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
  1. 第一次执行后函数 beginWork() 后,得到 workInProgress 子元素里 element 结构转换出来的 fiber 节点,就是 <App /> 对应的 fiber 节点,然后给到变量 next;workInProgress 再移动这个 fiber 节点上;
  2. 继续执行函数 beginWork(),得到<App />子元素的 fiber 节点,即 div 标签对应的 fiber 节点;
  3. 继续执行函数 beginWork(),得到 div 标签子元素的 fiber 节点,若子元素是一个数组,beginWork() 会把所有的子元素都转为 fiber 节点,并形成单向链表,然后返回这个链表的头指针。这里,div 标签里有 3 个并列的元素,即<FuncComponent />, <ClassComponent>和 div 标签,beginWork()会节点的不同类型,创建出不同的 fiber 节点,然后形成链表,再回到这个链表的第 1 个节点;即 workInProgress 指向了<FuncComponent />生成的 fiber 节点;
  4. 继续执行函数 beginWork(),就会到<FuncComponent />里的 jsx 对应的 fiber 节点,即 p 标签(beginWork()只会一层一层的构建),workInProgress 再指向到 p 标签对应的 fiber 节点,继续构建 span 和文本对应的 fiber 节点;
  5. 文本再往内,就没有节点了,即 next 得到的是 null,这时就会进入到 completeUnitOfWork() 函数,通过该函数的调度,workInProgress 又回到了<FuncComponent />的兄弟节点<ClassComponent />
  6. 继续执行函数 beginWork(),得到 <ClassComponent /> 里的 jsx 对应的 fiber 节点,即 p 标签;

我们接下来再看下函数 completeUnitOfWork() 是如何流转 workInProgress 指针的。

7. completeUnitOfWork #

当前节点和当前所有的子节点都执行完了,就会调用该方法。现在我们只关心整个流程的流转问题。

/**
 * 当前 unitOfWork 已没有子节点了
 * 1. 若还有兄弟节点,将 workInProgress 指向到其兄弟节点,继续beginWork()的执行;
 * 2. 若所有的兄弟节点都处理完了(或者没有兄弟节点),就指向到其父级fiber节点;回到1;
 * 3. 直到整个应用根节点的父级(根应用没有父级节点,所以为null),才结束;
 */
function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return; // 每个节点都有一个return属性,指向到其父级节点

    // 若有兄弟节点,则继续执行兄弟节点
    const siblingFiber = completedWork.sibling; // 该节点下一个兄弟节点
    if (siblingFiber !== null) {
      // If there is more work to do in this returnFiber, do that next.
      workInProgress = siblingFiber;
      return;
    }
    // 当前节点和兄弟节点全部遍历完毕,则返回到其父节点
    // Otherwise, return to the parent
    completedWork = returnFiber;
    // Update the next thing we're working on in case something throws.
    workInProgress = completedWork;
  } while (completedWork !== null); // while的作用就是若该节点没有兄弟节点,能够一直往上找父级节点,
}

通过这张图,我们可以更加直观地理解整个流转流程

fiber构建的流程

图中的数字,就是 workInProgress 指针行进的顺序。

  1. 当有子节点时,就一直往下构建其子节点;若子节点有多个,则一并都构建出来;
  2. 若没有子节点,则优先查询是否有兄弟节点,若有,则流转到兄弟节点(如图中的 5 和 8),回到 1;
  3. 若没有兄弟节点,则回到其父级节点(红色箭头),然后查询父级节点是否有兄弟节点,若有则回到 2,若没有,则继续回到父级节点;

所有的节点都遍历执行完了,workLoopSync()中的 while()循环也就停止了,workInProgress 也指向到了 null,然后就可以进入到 DOM 渲染的 commit 阶段了。

8. 总结 #

到这里,我们把 element 转为 fiber 节点的大致流程过了一遍。主要了解到以下几个知识点:

  1. element 结构从一开始并不是一个巨大的嵌套结构,而是执行组件后,才能得到这个组件里的 element 结构;
  2. 转成 fiber 节点的过程,深度优先的原则,优先执行其第 1 个节点,然后再执行兄弟节点,再回到父级节点;
  3. 每次将 element 结构转为 fiber 节点时,只转当前 fiber 节点里的 element 最直接的子节点,若还有更深的子节点,则等着一会儿 workInProgress 流转到这里的时候,再执行;
  4. 若当前层级的 element 结构是一个数组,即有多个元素时,则会一并全部进行转换;

接下来的文章,我们会再详细介绍下函数 beginWork(),了解不同的 element 结构如何转成 fiber 节点的。

标签:react
阅读(1507)
Simple Empty
No data