React源码拆解学习笔记1-虚拟DOM的初次渲染

670 阅读5分钟

前言

相信写过React项目的朋友都不陌生,React渲染DOM的过程是通过项目入口文件index.js中调用了ReactDOM.render方法,将JSX代表的虚拟DOM渲染在了项目的DOM根节点上。我们知道虚拟DOM是为了通过JS对象的方式来表示复杂的真实DOM节点,而在React的渲染更新过程中,又引入了Fiber的数据结构,便于协调过程中进行diff算法,从而以最小化的操作更新DOM,来达到优化性能的目的。下面会实现一个从JSX到创建简单的Fiber数据结构,再到构建真实DOM渲染到页面的过程。

前置准备

以下是一个测试页面,JSX中包含了原生节点,函数组件,类组件以及Fragment节点,便于对实现的功能进行测试。

// index.js
import React, { Component } from "react";
import ReactDOM from "react-dom";
import "./index.css";

class ClassComponent extends Component {
  render() {
    return (
      <div className="border">
        <p>{this.props.title}</p>
      </div>
    );
  }
}

function FunctionComponent(props) {
  return (
    <div className="border">
      <p>{props.title}</p>
    </div>
  );
}

const jsx = (
  <div className="border">
    <h3>Learn React</h3>
    <a href="https://reactjs.org/">react doc</a>
    <FunctionComponent title="FcnCmp" />
    <ClassComponent title="ClsCmp" />
    <ul>
      <>
        <li>1</li>
        <li>2</li>
      </>
    </ul>
  </div>
);

console.log("jsx:", jsx);
ReactDOM.render(jsx, document.getElementById("root"));

从虚拟DOM到Fiber

首先,我们需要对虚拟DOM和Fiber的数据结构进行一个了解。

通过打印页面中JSX我们可以看到,虚拟DOM节点的type属性区分了节点的类型。如果是原生节点,type为标签名,如果是函数/类组件,type分别为函数和类声明(构造函数)。

Screen Shot 2022-03-15 at 1.10.02 PM.png

fiber对象的设计思路和虚拟DOM类似,在其基础上增加了另外一些属性便于对Fiber树进行操作。通过打印实现的简版fiber节点,可以看到该对象有child, sibling, return属性,分别指向了当前节点的第一个子节点,下一个兄弟节点和父节点。另外也开辟了一个stateNode属性,如果是原生节点则是自身真实DOM,反之为null。(以<h3>Learn React</h3>为例)

Screen Shot 2022-03-15 at 1.23.02 PM.png

那么我们就可以基于以上,实现一个从VNode生成Fiber的函数。child, sibling以及stateNode初始化为null, 会在遍历生成Fiber树的过程中进行赋值。

// fiber.js
export function createFiber(vnode, returnFiber) {
  const fiber = {
    key: vnode.key,
    type: vnode.type,
    props: vnode.props,
    child: null,
    sibling: null,
    return: returnFiber,
    stateNode: null,
  };
  return fiber;
}

渲染过程中的2个阶段

从虚拟DOM到渲染/更新真实DOM的过程中,因为考虑到Fiber可以批量有优先级地执行渲染/更新任务,整个过程分为了两个阶段:

  • reconciliation/render(协调阶段)
  • commit(提交阶段) 如果只考虑渲染过程,在协调阶段虚拟DOM树被遍历并生成了Fiber树,而在提交阶段Fiber树被遍历而stateNode属性中的真实DOM节点在此过程中被拿来逐级构建出要渲染的真实DOM。 为了实现对树结构的遍历,我们需要两个变量,分别指向当前正在处理的节点(协调过程中遍历),以及树的根节点(便于提交时遍历)。同时,我们可以把这两个任务分别封装成函数,并进一步封装成完整的渲染任务workLoop. 这样,我们的代码框架就有了雏形。
// ReactFiberReconciler.js

let wipRoot = null; // 根节点
let nextUnitOfWork = null; // 当前节点

function performUnitOfWork(wip) {
  // 协调任务
}

function commitRoot(){
  // 提交任务
}

function workLoop(IdleDeadline) {
  // 封装整个过程:协调+提交
}

协调任务

在协调任务中,虚拟DOM节点树被遍历生成Fiber树。根据虚拟节点的type属性,我们可以区分以下这几种情况,以及他们的协调任务

  • type值为函数:可能是函数组件/类组件
    • 函数组件需要执行函数返回子节点(JSX),对其进行处理
    • 类组件需要实例化类并调用render函数返回子节点(JSX),对其进行处理
  • type值为字符串:是原生标签
    • 需要生成真实DOM节点,更新stateNode属性
    • 将props上的属性映射到DOM节点上,如果是对于props中的children,只添加文本节点
    • 对于其他子节点(JSX)进行处理
  • type值为Symbol(react.fragment):Fragment组件
    • 直接处理子节点(JSX) 对于函数组件和类组件的区分,我们可以定义类组件继承的基类Component,在它的原型对象上声明一个属性,便于我们做区分。

我们可以把上述分情况的处理封装成函数,如下:

export function updateHostComponent(wip) {
  if (!wip.stateNode) {
    wip.stateNode = document.createElement(wip.type);
    updateNode(wip.stateNode, wip.props); // 添加props
  }
  reconcileChildren(wip, wip.props.children);
}

export function updateFunctionComponent(wip) {
  const { type, props } = wip;
  const children = type(props); // JSX
  reconcileChildren(wip, children);
}

export function updateClassComponent(wip) {
  const { type, props } = wip;
  const children = new type(props).render(); // JSX
  reconcileChildren(wip, children);
}

export function updateFragmentComponent(wip) {
  reconcileChildren(wip, wip.props.children);
}

function reconcileChildren(returnFiber, children) {
  if (isStr(children)) {
    // 文本节点
    return;
  }
  const newChildren = Array.isArray(children) ? children : [children];
  let previousNewFiber = null;
  for (let idx = 0; idx < newChildren.length; idx++) {
    const newChild = newChildren[idx];
    const newFiber = createFiber(newChild, returnFiber);

    if (previousNewFiber === null) {
      returnFiber.child = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }

    previousNewFiber = newFiber;
  }
}

把子节点的处理同样封装成函数:在这个过程中createFiber函数中置空的child, 以及sibling属性会被赋值

function reconcileChildren(returnFiber, children) {
  if (isStr(children)) {
    return; // 不考虑文本节点
  }
  const newChildren = Array.isArray(children) ? children : [children];
  let previousNewFiber = null;
  for (let idx = 0; idx < newChildren.length; idx++) {
    const newChild = newChildren[idx];
    const newFiber = createFiber(newChild, returnFiber);

    if (previousNewFiber === null) {
      returnFiber.child = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }

    previousNewFiber = newFiber;
  }
}

整体的协调任务可以封装如下:

function performUnitOfWork(wip) {
  // 更新自身
  const { type } = wip;
  if (isFcn(type)) {
    if (type.prototype.isReactComponent) {
      updateClassComponent(wip);
    } else {
      updateFunctionComponent(wip);
    }
  } else if (isStr(type)) {
    updateHostComponent(wip);
  } else {
    updateFragmentComponent(wip);
  }

  // 返回下一个要更新的fiber
  if (wip.child) {
    return wip.child;
  }

  let next = wip;
  while (next) {
    if (next.sibling) {
      return next.sibling;
    }
    next = next.return;
  }
  return null;
}

提交任务

在提交阶段,我们需要从Fiber树的根节点遍历树,不断地将stateNode中的真实DOM添加在DOM结构中的父级上,从而逐级构建要渲染的真实DOM。所谓DOM结构中的父级,是因为如果发生非原生标签嵌套的情况,直接父级上的stateNode为空。这一过程我们可以通过递归函数实现:

function commitRoot() {
  commitWorker(wipRoot.child);
}

function getParentNode(fiber) {
  let next = fiber.return;
  while (!next.stateNode) {
    next = next.return;
  }
  return next.stateNode;
}

function commitWorker(fiber) {
  // 递归终止条件
  if (!fiber) {
    return;
  }
  const { stateNode } = fiber;
  let parentNode = getParentNode(fiber);

  if (stateNode) {
    parentNode.appendChild(stateNode);
  }

  commitWorker(fiber.child);
  commitWorker(fiber.sibling);
}

实现render函数

在分别实现了协调和提交的任务后,我们将这两个任务再进行一层封装。

// ReactFiberReconciler.js

export function workLoop(IdleDeadline) {
  // reconciliation
  while (nextUnitOfWork && IdleDeadline.timeRemaining() > 0) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  // commit
  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }
}

在render函数中,我们创建根Fiber节点进行初始化,并将前面封装的函数作为回调传入window.requestIdleCallback方法中,在浏览器空闲时执行渲染任务(协调+提交)。至此,渲染函数完成!

// react-dom.js
import { scheduleUpdateOnFiber, workLoop } from "./ReactFiberWorkLoop";

function render(vnode, container) {
  // 创建根节点
  const fiberRoot = {
    type: container.nodeName.toLocaleLowerCase(),
    stateNode: container,
    props: { children: vnode },
  };
  scheduleUpdateOnFiber(fiberRoot);
  window.requestIdleCallback(workLoop);
}

export default { render };

总结与展望

在这次学习笔记中,总体上实现了React从虚拟DOM生成真实DOM进行初次渲染的过程。在这个过程中,通过虚拟DOM的type属性对不同类型的节点进行了区分操作。也对其中的两个阶段,协调和提交,分别进行了实现,构建了基础的Fiber结构,为后面进一步功能的实现做了铺垫。