『手写React』JSX、Reconciler 架构、首屏渲染流程

158 阅读6分钟

前不久我们才学习了Vue3的源码,并且手写了mini-vue。既然开了学习源码的头,一不做二不休,顺便来看看React吧。

笔者也是一边学习一边记录,这篇博客是为了个人复习而写的,纰漏之处请多包涵。

JSX

在项目中使用的jsx,最终会转换成一个类型为ReactElement的对象。

该对象的接口如下,其中$$typeof是Symbol类型,用于安全,详情:为什么React元素有一个$$typeof属性?(TODO)

export interface ReactElementType {
	$$typeof: symbol | number;
	type: ElementType; // 标签类型
	key: Key;
	props: Props;
	ref: Ref;
}
const supportSymbol = typeof Symbol === 'function' && Symbol.for;

export const REACT_ELEMENT_TYPE = supportSymbol
	? Symbol.for('react.element')
	: 0xeac7;

jsx转为对象由编译时和运行时两步完成。编译是由babel完成的,在playground中可见,jsx会转化成对jsx方法的调用。

jsx方法会区分生产环境和开发环境,而且有时候调用的是React.createElement方法,此处不探讨这些细节。

image.png

于是实现jsx方法,逻辑比较简单,从上面的图也可以看出,它接受type和config,然后从config中获取key、ref、children等信息。最后调用ReactElement方法,生成的children在props中。

export const jsxDEV = (type: ElementType, config: any) => {
  let key: Key = null;
  const props: Props = {};
  let ref: Ref = null;

  for (const prop in config) {
    const val = config[prop];
    if (prop === 'key') {
      if (val !== undefined) {
        key = '' + val;
      }
      continue;
    }
    if (prop === 'ref') {
      if (val !== undefined) {
        ref = val;
      }
      continue;
    }
    if ({}.hasOwnProperty.call(config, prop)) {
      props[prop] = val;
    }
  }

  return ReactElement(type, key, ref, props);
};

function ReactElement(
  type: Type,
  key: Key,
  ref: Ref,
  props: Props
): ReactElementType {
  const element = {
    $$typeof: REACT_ELEMENT_TYPE,
    type,
    key,
    ref,
    props
  };
  return element;
}

Reconciler

Reconciler是“协调器”的意思,其作用和Vue中的Renderer类似,都属于运行时核心模块。它接受编译好的jsx,调用浏览器的API,最终把虚拟DOM转换为真实DOM。

ReactElement如果作为核心模块操作的数据结构,存在的问题:

  • 无法表达结点之间的关系。
  • 字段有限,不好拓展(比如:无法表达状态)。

因此,在ReactElement和DOM之间,需要添加一种新的数据结构,要能够表达结点之间的关系,方便拓展,它就是FiberNodeFiberNode是虚拟DOM在React中的实现

fiber的属性基本分为三个部分:

  1. 基本的属性:因为fiber(一般)是由element创建而来的,所以要保存type、tag、key、ref等属性。props保存在pendingProps中,作为初始的props,与之对应的是更新结束后的memoizedProps。
  2. 作为工作单元的属性:
    • updateQueue属性是更新的核心机制,会从队列中取出Update对象,用baseState进行计算,得到memoizedState,然后此时的memoizedState会作为下一次更新时的baseState。Update中保存了action属性,它可以是值或函数。 最常见的更新操作就是setState或者是useState的dispatch方法,它们的传参就可以是值或函数,和这里对应起来。
    • flags用于标记副作用,比如某个fiber结点需要进行Placement(插入、移动)或是Delete children...就会在commit阶段完成这些副作用。
  3. 构建fiber tree的属性:return指向父亲fiber,child指向孩子fiber,sibling指向兄弟fiber。
export class FiberNode {
    type: any;
    // tag: 结点类型
    tag: WorkTag;
    // pendingProps: 接下来有哪些props需要改变 / 刚开始工作时的props
    pendingProps: Props;
    key: Key;
    ref: Ref;

    stateNode: any;
    return: FiberNode | null;
    sibling: FiberNode | null;
    child: FiberNode | null;
    index: number;

    // 工作完成后 / 确定下来的props
    memoizedProps: Props | null;
    memoizedState: any;
    // 双缓冲技术,current和wip的切换
    alternate: FiberNode | null;
    // 保存标记,副作用
    flags: Flags;
    updateQueue: unknown;

    constructor(tag: WorkTag, pendingProps: Props, key: Key) {
        this.tag = tag;
        this.ref = null;
        this.key = key;
        // HostComponent.stateNode => div DOM
        this.stateNode = null;
        // FunctionComponent.type => Function
        this.type = null;

        this.return = null;
        this.sibling = null;
        this.child = null;
        // 同级的fiberNode有好几个,比如一个ul下有多个li,它们的index分别是0、1、2
        this.index = 0;

        this.pendingProps = pendingProps;
        this.memoizedProps = null;
        this.memoizedState = null;

        this.updateQueue = null;

        this.alternate = null;
        this.flags = NoFlags;
    }
}

更新机制

刚才已经说过,应该在fiber上保存updateQueue,其中保存了Update对象用于更新。

除此之外,需要考虑的事情:

  • 更新可能发生于任意组件,而更新流程是从根节点递归的。
  • 需要一个统一的根节点保存通用信息。

引入FiberRootNode,保存了容器、fiber和finishedWork。

export class FiberRootNode {
    container: Container;
    current: FiberNode;
    // 指向整个更新完成后的HostFiber
    finishedWork: FiberNode | null;

    constructor(container: Container, hostRootFiber: FiberNode) {
        this.container = container;
        this.current = hostRootFiber;
        hostRootFiber.stateNode = this;
        this.finishedWork = null;
    }
}

image.png

之后每次发生更新时,都通过fiber的return指针,向上遍历到hostRootFiber,通过stateNode属性获取fiberRootNode。

mount流程

render阶段

使用双缓存技术,共有两棵fiber tree,每个fiber的alternate指向另一棵树中和它对应的结点。

全局变量workInProgress,顾名思义,指向当前遍历到的fiber结点,初始时指向hostRootFiber.alternate。当然此时wip为null,需要创建fiberNode。

然后,存在一个workLoop函数,来完成“递”和“归”的流程。

beginWork

接受wip作为参数,然后判断wip.tag。

假设是hostComponent类型,则从wip的pendingProps中取出children element,然后根据element生成fiber,设置父亲.child = fiber,将fiber返回。

在reconcileChildren的过程中,还会设置fiber的flags。

总结:生成孩子结点的fiber,设置父fiber的child指针,设置fiber的flags,不断向下。

completeWork

开始completeWork时,wip指向fiber tree中的叶子结点,因为是“归”的步骤,completeWork是按从下往上的顺序执行的。

complete完成的工作,一是构建dom(并且把dom赋值给wip.stateNode),二是将DOM插入到dom树中,三是处理flags。

  • 构建dom很简单,document.createElement就可以。
  • 插入dom结点时,要找到wip下最近的HostComponent或是HostText结点(要做到这一点,又要向下递归),最后把符合条件结点的stateNode插入父dom。
  • flags分布在不同的fiberWork中,为了快速找到它们,可以利用归的流程,把子fiber的flags冒泡到父。父结点保存subtreeFlags,即子树中的flags。

另外,在首屏渲染时,只有根fiber上会有“Placement”的flags,前面的所有dom插入操作都是离屏的,最后把整棵dom树插入浏览器。

总结:从最底下出发,设置子fiber的return指针,sibling的return指针,依次生成dom和完成dom插入操作,并且设置flags,不断向上。

commit阶段

commit阶段有三个子阶段:beforeMutation、mutation、layout。其中Placement等操作是在mutation阶段完成的。

在render阶段结束后,会更新fiberRootNode上的finishedWork。而commit阶段,就是拿到finishedWork,然后根据flags,完成相应的操作,过程是自上而下的。

用MutationMask来判断是否需要进行mutation阶段,假如需要进行Placement:

  • 获取hostParent:往上找,直到找到HostComponent或HostRoot类型的结点,得到其stateNode。
  • 获取要插入的结点的dom:往下找,直到找到HostComponent或HostText类型的结点,得到其stateNode。

这样的操作在前面completeWork的时候也出现了一次,可以想一下函数组件或是类组件,它们是不对应任何dom的,它们的孩子才是HostComponent或HostText。

image.png

实现了首屏渲染,取得阶段性胜利。