手把手教你写一个类React框架

1,350 阅读33分钟

本文是以Preact这个类React库作为参考所写,具体的实现代码已经托管到github

1 实现路线

首先需要先了解一下什么是虚拟dom,了解完成之后再学习一下怎么创建虚拟dom,也就是createElement函数的实现

接着,就要把这个虚拟dom映射成真实的dom,那么在映射的过程中,会先简单地了解一下diff算法的基本流程,接着就可以把这个基本的流程转化成具体的代码实现,使用简易版本的diff算法把虚拟dom转换成真实的dom

初次渲染完成后,就要学习一下diff算法**“节点复用”**,那么复用节点其实还可以细分为两部分,一部分是单节点的diff,这个部分其实是较为简单的,不需要涉及到太多的逻辑,最麻烦的其实就是对子节点的复用,有道面试题大家肯定做过,也就是为什么循环列表的时候,要给每个子节点设置一个key

当学习完前面的三节之后,其实类React思想的实现就已经完成了,接下来都是在这个基础上面添加新东西。那么要添加的第一个东西就是函数组件类组件,函数组件的hook和类组件的setState,生命周期的实现在这一节都不讲,这一小节只讲怎么将函数组件和类组件渲染到页面上

接下来就是setState的实现,在面试中经常考到setState的合并更新原理,那么在讲述着一小节的时候,会分别说明React1718版本的区别,再介绍一下如何使用js的任务队列机制来实现合并更新

说完setState之后,就是类组件生命周期的实现了,等生命周期实现完成之后,一个小型的类React框架就已经完成了。

最后学习contexthooks的实现

那么React18concurrent mode会说明一下基本原理,并不会去实现它

2 虚拟dom

2.1 什么是虚拟dom

虚拟dom其实就是一个js对象,我们通过js对象去描述一个dom结构

2.2 为什么需要虚拟dom

为什么需要虚拟dom?我们可以看一下尤雨溪在知乎的一个回答,总结下来借助虚拟dom构建的项目的优点其实就只有一个:"可维护性高"。注意,这里的优点是不包括性能的,也就是说虚拟dom并不会带来性能上的提升。我们可以先看下最古老的前端项目是怎么实现页面交互的

先有一个初始的dom,通过接口获取初始数据,接着通过操作dom把数据渲染到页面上,以此类推

2.png

那么框架的意义在于,通过虚拟dom描述页面上的结构,开发者只需要修改变量数据,接下来的事情交给框架做就行,开发者不需要知道哪里需要发生变化。从以前的获取数据,修改数据,修改视图。变成了获取数据,修改数据。

3.png

需要注意的是,框架的底层依然是操作dom,并且在修改数据和操作dom之前多了一层diffdiff并不是没有代价的,特别是在类React框架中,如果不借助shouldComponentUpdatememo等方法,这个代价会变得非常的巨大,某些时候,可能还需要借助immutableJsimmerJs等库来减少 shouldComponentUpdatememo比较的复杂度 。所以React的灵活也伴随着风险

2.3 虚拟dom属性说明

现在来了解一下虚拟dom的基本结构长什么样子,下面所展示的结构为了好理解为最精简版本,为了方便理解

type ArrayType<T> = T | T[]

type LegalVNode = VNode | string | number | bigint | boolean | null | undefined

interface VNode {
  type: null | string | Function,
  props: Partial<{
    id: string
    style: Partial<CSSStyleDeclaration>,
    className: string,
    onClick: Function,
    onCaptureClick: Function,
    children: ArrayType<LegalVNode>
  }>,
  key: keyof any,
  _dom: HTMLElement,
  _parent: VNode,
  _component: any,
  constructor: null
}

这里面有几个属性特别重要,接下来会分别讲解

2.3.1 type

这个type是用来描述当前虚拟dom的类型,一般情况下,会遇到三类

  • 元素节点,即nodeType为1的dom节点
  • 文本节点,即nodeType为3的dom节点
  • 函数组件/类组件

如果是元素组件的话,比如divpspan等等,就会把这个标签的tagName保存在type上,比如

{ type: 'div' }

如果是文本节点的话,为了和元素节点做区分,type存储一个null即可

{ type: null }

如果是函数组件/类组件,type直接存储方法(类其实也是方法的一种)

{ type: Component }

2.3.2 props

props这里用来存储当前节点绑定的属性,比如classNamestyle和事件

需要注意的是,在虚拟dom中,还存在一个children,用来保存当前节点的子节点。如果当前节点只有一个子节点的话,那么children就是这个唯一子节点的值,如下

{ props: { children: '1' } }

如果该节点存在多个值,那么此属性的值就是一个数组

{ props: { children: [ '1', '2' ] } }

2.3.3 _children

通过上面对props.children的描述可知,这个属性的类型飘忽不定,一会是数组一会又不是,所以Preact会在虚拟dom上挂载一个_children,统一把子节点转化成一个数组。不仅如此,为了方便比较,Preact还会把 stringnumberbigint类型转换成虚拟dom,比如

{ type: 'div', props: { children: '1' } }

转换之后

{ 
  type: 'div', 
  props: { children: '1' },
  _children: [
    { type: null, props: '1' }
  ]
}

2.3.4 constructor

之所以有这个属性是Preact做了一个小优化,它的值永远为null

比如在React中,渲染的节点必须是合法的虚拟dom节点,或者基础数据类型,如果瞎传便会报错

4.png

但是在Preact中不会这样,Preact中如果发现这个节点不是合法的节点,便不会渲染

5.png

Preact是这么做的,因为在js中除了nullundefined之外的值,都是存在 constructor属性的,那么Preact如果发现当前的节点不是null或者undefined,那么只有两种合法可能

  • 函数/类组件
  • 元素/文本节点

那么文本节点在2.3.3说了,为了方便比较也会转换成虚拟dom,也就是说,当前节点的constructor如果能够取到值,那么肯定是开发者传进来了什么不好处理的值,直接break,不处理就行

2.3.5 其余属性

  • key: 保存循环列表的时候给子元素赋值的key
  • _dom: 保存当前虚拟dom对应的真实dom指向
  • _parent: 保存当前虚拟dom的父虚拟dom节点
  • _component: 如果是类组件的话,这个属性保存类组件的实例

2.4 创建虚拟dom

为了防止代码流水账,具体的代码实现已经放在/packages/1,可自行查阅

2.4.1 jsx

平时编写的React项目,基本写的都是jsx,是如下所示

jsx并不是合法的js语法,想要在浏览器上使用的话,需要编译成js,比如借助babel来进行转化

2.4.2 Classic

我们可以打开babel的官网,点开这里的试一试

接着preset选择reactreact runtime修改为Classic,在编辑器的左边输入源码,右边便会显示输出的合法的js代码

可以看到,jsx转化成了 React.createElement 方法,这就是React的一道经典面试题,为什么jsx中没有使用到React对象,却需要引入React

2.4.3 Automatic

React17版本的时候,引入了“全新”的jsx转化方式,算是React在为runtime做了一点点优化,能够提升一点点运行时性能。把React Runtime修改为Automatic,可以看到,合法代码不再使用React.createElement方式创建,而是自动引入jsx函数,这样就不再需要手动引入React这个对象。除此之外,新的转化方式直接把children放置到第二个参数中,这样在运行时就不需再放置了,借此提升了一点性能

9.png

3 初识diff

3.1 了解diff算法

到现在为止,知道了如何创建虚拟dom,现在的目标是把这个虚拟dom树渲染到页面上,但是在具体的学习渲染之前,需要先了解diff算法的基本流程。当开发者调用render函数的时候,一般情况下,需要传递两个参数,第一个参数为初次渲染的虚拟dom,第二个参数为这个虚拟dom存放的容器

code

开发者可以重复调用render重新渲染,render内部会比较两个虚拟dom哪里发生了变化,然后做最小量更新。为了验证重新调用render之后页面上的div是否是重新生成的,可以做下面这个测试,在第一次渲染完成之后,获取页面中唯一的div对象,再次渲染之后,比较现在的div是否是同一个

code-164476517675126.png

具体的代码实现我已经放在git仓库packages/2文件夹下。运行起来后点击button可以看到,控制台打印中,两个div对象确实是同一个

3.2 diff基本流程

那么现在就存在一个问题,就是第一次渲染的时候,并不存在旧的虚拟dom,那该如何比较?在Preact中,会把旧的虚拟dom存储在容器上,比如在上面的例子中,旧的虚拟dom就会存储在 #root 这个dom上,如果取不到,那么会准备一个空对象,与第一次传入的虚拟dom进行diffdiff完成之后,再把传入的虚拟dom赋值到#root上,这样下一次就能在#root上取到旧的虚拟dom

从上面例子中,小伙伴可以看到,虚拟dom其实就是一颗树。那么我们就能通过遍历来处理这颗树。diff算法同理,在遍历的时候,只会对同一层的元素之间进行比较和复用,因为一般业务下,修改dom结构,都是发生在同层之间,跨层比较时间复杂度爆炸并且除了拖拽场景一般用不到

12.png

那么”同层比较“其实可以引导出两个逻辑,一个是在同层中寻找可以复用的节点,寻找完成之后,diff可以复用新旧节点的属性,处理完成之后,接着处理子节点,于是,就可以抽象出两个方法

  • diff: diff可以复用的新旧节点(处理属性,事件绑定等)
  • diffChildren:
    • 比较旧节点的子节点和新节点的子节点,寻找可以复用的节点
    • 找到可以复用的新旧节点之后,传给diff函数
    • 判断子节点是否需要移动顺序(类似于排序)
    • 把没有使用到的旧节点全部从dom树上移除

13.png

4 初次渲染

好了,铺垫已经完成,接下来学习的是怎么把虚拟dom渲染到页面上,为了简明扼要,接下来的都以最精简的实现为主,不考虑边缘情况

4.1 函数定义

如果只是想把虚拟dom渲染到页面上,其实准备三个函数就行,如下

  • render:使用过React的开发者一定非常熟悉这个函数
/**
 * @param vnode      需要渲染到页面上的虚拟dom
 * @param parentDom  需要渲染的容器
 */
declare function render(vnode: VNode, parentDom: HTMLElement): void;
  • diffChildren:在3.2小节说明了这个函数的作用,用来处理可复用的子节点
/**
 * @param parentDom       子节点要挂载到哪个dom下
 * @param newChildren     要处理的子节点
 * @param newParentVNode  新的父虚拟dom节点
 * @param oldParentVNode  旧的父虚拟dom节点
 */
declare function diffChildren(
  parentDom: HTMLElement,
  newChildren: Array<LegalVNode>,
  newParentVNode: VNode,
  oldParentVNode: VNode
): void;
  • diff:对比两个可复用虚拟dom节点,修改属性
/**
 * @param parentDom     当前节点需要挂在到哪个dom下
 * @param newVNode      新虚拟dom节点
 * @param oldVNode      可复用的虚拟dom节点
 */
declare function diff(
  parentDom: HTMLElement,
  newVNode: VNode,
  oldVNode: VNode
): void;

4.1 render

具体代码实现见 /render.js

接下来,准备一个虚拟dom,这个虚拟dom可以说把“大部分”情况全部覆盖了,首先,存在children为数组或单节点的情况,其次props中存在基本属性,style和事件绑定处理

const style = { border: '3px solid #D6D6D6', margin: '5px' }

const element = (
  createElement(
    'div', { className: 'A1', style },
    'A-text',
    createElement(
      'div', { className: 'B1', style },
      'B1-text',
      createElement('div', { className: 'C1', style, onClick: () => alert(1) }, 'C1-text'),
      createElement('div', { className: 'C2', style }, 'C2-text')
    ),
    createElement('div', { className: 'B2', style }, 'B2-text')
  )
)

目前的render函数只需要考虑三件事情

  • 取到旧的虚拟dom节点,取不到用空对象代替
  • 存储新的虚拟dom节点,方便之后使用
  • 调用diffChildren

4.2 递归逻辑

4.2.1 diffChildren

具体代码实现见 /children.js

现在render方法把要处理的子节点传递给了 diffChildren,还记得虚拟dom上的 _children属性吗,用于保存方便处理的子节点,所以需要遍历新节点,处理nullundefinedstringnumberbigint 类型,以下就是初次渲染的逻辑如下

14.png

因为目前是初次渲染,所以把从oldParentVNode中寻找可以复用的旧节点的逻辑给删除了

4.2.2 diff

初次渲染的diff的逻辑相对简单,因为没有旧节点,所以只需要判断当前是元素节点还是文本节点,调用对应的创建domapi,把创建好的元素挂载到虚拟dom_dom 上就行。为什么方便查看是否挂载正确,可以先把className属性挂载到dom

15.png

到现在为止,基本的dom结构已经完整的渲染到页面上,具体的demo放置在 packages/3

17.png

4.3 props处理

具体代码实现见 /props.js

props的处理可以单独提取出来一个方法单独处理,类型定义如下

/**
 * @param dom       当前虚拟dom对应的真实dom节点
 * @param newProps  新虚拟dom节点的props属性
 * @param oldProps  旧虚拟dom节点的props属性
 */
declare function diffProps(
  dom: HTMLElement,
  newProps: Pick<VNode, 'props'>,
  oldProps: Pick<VNode, 'props'>
): void;

diffProps中需要做两件事情

  • oldProps中存在,newProps中不存在的属性给移除掉
  • 分发属性给不同的函数进行单独处理(这里只处理三个类型:style事件绑定其它属性

基本流程图如下

18.png

4.3.1 style

Reactstyle需要写成对象的形式,css属性使用驼峰命名法,如下

{
  backgroundColor: 'red',
  borderBottomColor: 'green',
  ...
}

但是,并不能进行简单的拼接直接赋值到cssText上,因为驼峰命名法在html中是不合法的,我们需要转化成“中划线法”

19.png

这就需要对key进行转化,我们可以借助String原型上的replace方法,replace有一个函数重载,第一个参数可以传递一个正则,第二个参数传递一个方法,接收的参数就是正则调用 exec返回的值,函数的返回值为替换值,使用如下

'borderBottomColor'.replace(/[A-Z]/g, s => `-${s.toLocaleLowerCase()}`)  // 'border-bottom-color'

那么接下来只需要做一个循环,把每个属性和值拼接在一起就可以了

20.png

注意,这里没有处理size相关属性自动加上px的逻辑,感兴趣的小伙伴可以自己想想

4.3.2 事件绑定

Preact的事件绑定与React不同,React自己实现了一套事件委托机制,把所有的事件全部绑定到同一个根节点上,通过自己实现一套事件捕获与冒泡机制实现事件绑定。这里主要学习一下Preact是如何实现事件绑定机制的。在js中,事件绑定分为两种,一种是捕获阶段,一种是冒泡阶段,在虚拟dom中,通过不同的命名方式进行区分捕获与冒泡,命名方式如下

  • onCatureClick: 事件捕获阶段触发
  • onClick: 事件冒泡阶段触发

所以在Preact中事件绑定的时候,需要先判断一下当前绑定的事件是冒泡还是捕获,紧接着,Preact会给每个需要绑定事件的dom对象上添加一个 _listeners空对象,用来保存给这个dom绑定的全部事件。这里是事件名称没有什么要求,只要能有效区分触发各个事件的方法就行

21.png

接着,准备两个代理方法,这两个方法在整个页面运行周期内只存在一份,所有事件触发全部经过这两个代理方法

function eventProxy(e) {
  this._listeners[e.type](e)
}

function eventProxyCapture(e) {
  this._listeners[e.type + 'Capture'](e)
}

最后一步,进行一个逻辑判断。之所以这么做,其实是因为平常的业务中,会频繁的触发domDiff,如果每次diff都需要重新解绑后再重新绑定,明显太浪费性能了,所以所有事件只绑定一次,把需要触发的对象保存在 _listeners 上,当domDiff函数发生变化的时候,只需要替换_listeners中的方法就行,修改一个对象的值的代价永远比解除绑定重新再绑定的代价小的多

22.png

4.3.3 其它属性

剩余的属性可以使用下面的逻辑进行设置

5 节点复用

节点复用整体逻辑见 /packages/5,可以使用 $ npm run 5 启动

接下来就是diff算法的核心,如何从旧节点中寻找可以复用的节点,diff完成之后如何排列dom的位置。接下来就简述一下Preactdiff算法是如何实现的,需要注意的是,为了方便理解,以下的diff算法简略了一些小细节

5.1 key的作用

有这么一道面试题:《为什么要在循环列表中使用key,并不推荐index用作key》,你可以在网上找到长篇大论来解答这个问题,其实只需要一句话就可以解答这个问题。在React中,新旧节点使用typekey是否一致来判断是否可以复用。如果使用index来作为key的话,就会出现错误复用的问题

比如下面的oldChildren中,每个li节点的子孙节点不同,在setState之后,只是把列表的顺序打乱,并没有修改子孙节点,但是在React中,他只知道,li.1typelikey0li2typelikey0,它就会认为这两个是同一个节点,并且子孙节点个数不同。以此类推,就会把简单的顺序调换,变成了,移除子孙节点,创建新节点的复杂操作,浪费了多余的性能

28.png

5.2 寻找可复用的节点

diff算法的逻辑必须从实际情况出发,在项目中,大多数出现的情况并不是移动的dom的位置,而是如下情况

29.png

所以当showboolean类型的时候,子节点会存在以下两种情况

30.png

在4.2.1 节中已经说明了子节点的渲染逻辑,就是遍历子节点,那么遍历自然就能拿到当前数组的索引值,在Preact中,会直接拿当前子节点的索引去旧的子节点数组中取相同位置的。比如a,c节点,新旧数组索引都是0,那么就可以直接命中

31.png

如果没有命中,Preact就会使用傻办法,直接从0开始遍历,时间复杂度为O(n)。如果命中,oldChildren中对应索引的虚拟dom设置为null,这是为了移除没有使用到的节点而准备

正是因为这个优化机制,所以千万不要写成下面这种格式,不然无法正确的复用

32.png

5.3 diff

这节的diff方法是在diffChildren中的for循环中执行的(和初次渲染一致)。在上一小节中已经找到了可以复用的节点了,那么接下来的操作就是把新节点与可复用节点传递到 diff 方法中

既然是复用节点,就不能像4.2.2 小节那样简单粗暴直接创建节点了,具体逻辑如下

33.png

5.4 移动节点位置

5.4.1 理论

循环外变量:

for循环newChildren之前,需要准备一个变量 oldDom,可以把它看成一个指针,指向目前列表中的第一个真实dom对象。比如,当前页面上是[#a,#b,#c,#d],那么oldDom指向的就是#a,如果取不到,赋值一个null即可

循环中变量:

在每次的for循环中,准备如下3个变量

  • childVNode:当前要处理的新子节点
  • oldVNode:在旧的虚拟dom中找到可以复用的旧的虚拟dom节点,取不到,这个值为nullundefined
  • newDom:当前要处理节点的真实dom对象(diff函数执行完成后,挂载在childVNode._dom属性上)

接着,判断childVNode是否是oldDom的虚拟dom,只需要做如下两个判断,如果全是true,说明是同一个节点

  • oldVNode是否存在,如果不存在说明在oldChildren中不存在可复用的节点
  • oldDomnewDom是否是同一个

36.png

如果是同一个节点的话,就不需要处理,继续循环下一个要处理的新节点,把oldDom修改成新节点的弟弟节点

37.png

如果不是同一个节点,判断oldDom是否存在,如果不存在,直接把newDom添加到父dom的尾部,接着进入下一次循环

38.png

如果oldDom存在,判断newDom是否在oldDom的后面,如果在后面,什么都不做,如果在前面,插入到oldDom前面,接着,进入下一个新的子节点的循环

39.png

5.4.2 实践

比如,现在页面上的元素为

34.png

update之后为

35.png

在循环前,取一个 oldDom,现在的oldDom值为li#a

处理e

40.png

页面上的dom就会变成下面这样

[ #e, #a, #b, #c, #d ]

处理d

41.png

页面上的dom就会变成下面这样,oldDom变成了 null

[ #e, #a, #b, #c, #d ]

处理c

42.png

页面上的dom就会变成下面这样

[ #e, #a, #b, #d, #c ]

处理b

42.png

页面上的dom就会变成下面这样

[ #e, #a, #d, #c, #b ]

5.5 移除无效节点

在5.2小节中提到这么一句话: 命中后,把oldChildren中对应索引的虚拟dom设置为null,我们还用上面的例子,现在oldChildren如下

[ 
	{ type: 'li', { key: 'a', id: 'a' }, 'a' }, 
	null, 
	null, 
	null, 
	null
]

我们只需要把不为null的元素从页面中删除,那么整个diff算法就可以结束了,删除后,此时页面中的dom如下

[ #e, #d, #c, #b ]

6 组件渲染

组件渲染的逻辑见 /packages/6,可以使用 $ npm run 6 启动

6.1 Component

想要实现类组件,就需要创建一个Component类,由类组件继承。这个类上有setStateforceUpdate等方法,目前先不实现。因为类其实可以看成方法的语法糖,所以我们没办法在代码中判断是类还是方法,所以需要在类上绑定一个静态属性,用来标识这是一个类组件

43.png 这样,只需要在diff方法中进行如下判断就可以判断当前是什么组件

44.png

6.2 函数组件

Preact中,函数组件的处理方式和类组件一致。比如说,如果是类组件的话,在框架内部肯定需要实例化它,那么函数是如何实例化的?我们可以看下面类组件与函数组件的比较

45.png

可以很明显的看到,函数组件其实就是类组件的render方法,所以Preact中,如果判断当前组件是函数组件,它也会实例化,但是实例化的不是函数组件,而是Component,然后把实例化后的Component对象,添加一个render属性,这个render属性的值自然就是这个这个函数组件

所以,在Preact的类组件的render方法比React多了一个功能,能直接接收props,不需要从context上获取props,因为这是顺便的事情

46.png

6.3 类/函数组件渲染

现在先不考虑套娃情况,所谓的套娃最常见的就是高阶组件,一个类组件或函数组件中return出来的根节点也是一个类组件或函数组件,现在只考虑一种情况,就是return出来的就是一个元素节点或文本节点。那么这种情况下,处理起来是最简单的

首先,先修改diff函数中的分支语句,因为之前只处理过原生节点,现在要加一个分支,直接判断type是不是函数类型

47.png

还记得虚拟dom上存在一个_component的属性吗,用来保存是否已经实例化过该组件了,所以如果之前已经实例化了,就没必要再实例化一次,直接复用之前的对象,反之亦然。这里不要忘记了一件事情,就是函数组件使用Component作为替身,然后重写Componentrender方法

48.png

最后,只需要执行render,获取返回的虚拟dom,与旧的节点的子节点,一起传进diffChildren中,diff函数的任务就结束了

49.png

但是,还没有结束,需要对diffChildren做一点处理

6.4 diffChildren

看下下面这个例子,当左边的虚拟dom update 到右边的虚拟dom后,会发现de没有被移除掉

image-20220201234451686.png

这是因为函数组件并不能转化成真实的dom,真正显示在页面上的是render中的内容,这就导致了函数组件的虚拟dom_dom属性为空。为了解决这问题,可以借助diff算法的特性,就是深度优先的递归策略。这意味着,子节点永远比父节点先完成diff,而且可以在diffChildren中,获取子节点的真实dom的指向,我们直接把它赋值到父虚拟dom_dom属性上

newParentVNode._dom = childDom

这是因为子节点比父节点先完成,如果父节点是一个真实dom的话,_dom会重新赋值,如果不是,其_dom就会指向render中返回的第一个真实的dom,这样在移除dom的时候,就不会出现函数组件无法移除的问题

image-20220202001035713.png

但是这还没有结束,因为在React16中新增了 Fragment组件,这意味着render可能并不会返回唯一的一个根节点

7 Fragment

Fragment相关实现见 /packages/7,可以使用 $ npm run 7 启动

7.1 Fragment实现

Fragment其实很简单,就是一个函数组件,内部把children返回出来

code.png

7.2 diff算法出现的问题

使用Fragment之后,意味着一个类/函数组件并不会返回唯一的一个节点,如果还按照之前的方式渲染,势必会出现问题,比如下面的例子

code-16439010500974.png

在页面中就会渲染成下面这样

image-20220203222202167.png

之所以会这样其实很好理解,可以重新看一下[6.4节](##6.4 diffChildren)的挂载逻辑,函数组件的_dom属性会指向render中返回的第一个真实的dom,那么意味着,当前虚拟dom树如下。因为FragmentFragmentTest都是函数,所以div#1这个节点会被挂载到FragmentFragmentTest 节点上,记住这一点

image-20220203224532124.png

由于FragmentFragmentTest都是函数组件,并不会真实映射到dom树上,所以这会发生什么事情呢?首先,由于递归深度优先的策略,算法中,会先把 div#0 插入到父节点上,接着处理 FragmentTest的子节点Fragment的子节点,就是div#1div#2插入到父节点上,处理顺序如下

image-20220203230232982.png

这时候,内存中排序是正确的

image-20220203225759585.png

但是,好玩的事情就来了,因为所有的叶子节点全部处理完成了,所以接下来就会向上处理,自然就是Fragment节点,因为存在子节点dom挂载到父节点的操作,那么就会发生Fragment_dom也会挂载到dom树下的操作,但是因为dom不能重复挂载到同一个dom树下,所以就会发生dom节点的移动

image-20220203231007351.png

同理,接着往上处理父节点,就会重复处理父节点的操作...

image-20220203231143719.png

7.3 修改diff算法

那么,为了解决这个问题,其实也很简单,还是利用 “递归深度优先的策略” 和“函数组件不会渲染到页面上”这两个规则来解决这个问题

因为不管层级有多深,它都会先处理叶子节点 ,而且,同一层domparentDom都是同一个,那么意味着,diffChildren中处理函数组件的时候,子节点自己就已经排好序了,不需要函数组件自己再排序了

image-20220203232358907.png

也就是说,判断当前是虚拟dom是函数组件的话,就不需要进行 [5.4](##5.4 移动节点位置) 那般操作了,直接return就好了

7.4 列表中的Fragment

Preact中有个小细节需要处理,平时业务编写中,经常出现的就是列表的渲染

code-16443763094961.png

虚拟domprops结构描述如下

image-20220209111332487.png

Preact中,如果当前某一个子节点是数组的话,那么就会包一层Fragment

image-20220209111519161.png

8 初次渲染的生命周期

初次渲染的生命周期的相关实现见 /packages/8,可以使用 $ npm run 8 启动

这里就不实现 UNSAFE 的生命周期和错误处理相关的生命周期了,那么初次渲染会触发的生命周期函数其实只有两个,一个是静态方法 getDerivedStateFromProps,一个是componentDidMount

8.1 static getDerivedStateFromProps

我从网上找了一个张图

x.jpg

可以看到 getDerivedStateFromProps它会在初始化和数据更新的时候执行,那么代码中,就不需要判断执行时机,每次diff都触发,类型定义如下,传入两个参数,nextProps就是新的虚拟dompropsprevState就是当前组件中的state的值

type GetDerivedStateFromProps<P, S> = (nextProps: Readonly<P>, prevState: S) => Partial<S> | null;

具体的代码实现也非常的简单,需要先准备一个 _nextState 的变量,用来存储下一次的state,之所以这么做,是因为在之后的生命周期实现中,需要接收新旧state,所以不能相互覆盖了

code-16439076005888.png

8.2 componentDidMount

componentDidMount需要在所有节点渲染完成之后才会执行,好在render方法是同步的,也就是说可以在diff方法执行之前,存储一个任务队列,这个任务队列在diff全程存在,diif完成之后,把任务队列中的所有待执行方法全部执行,就完成了

code-16439099386909.png

因为一个类组件中,可能存在多个需要处理完成后触发的函数,比如之后要说的setState回调, componentDidUpdate等,所以,我们可以在每个组件的实例上,也保存一个队列,需要执行回调的话,直接这个类的实例存储在commitQueue中,这样的好处是,如果只存储了一个方法的话,那么这个方法的上下文就没法确定了

code-164391062563910.png

commitRoot相关逻辑如下

code-164391068690311.png

9 setState

setState的相关实现见 /packages/9,可以使用 $ npm run 9 启动

9.1 React17的setState

背过React面试题的小伙伴一定知道下面这道题目,点击按钮之后,控制台会打印多少次render,点击完成之后,页面上的count会变成多少

24.png

答案是3次,页面上的count变成了4,因为前两次的setState进行了合并,setTimeout因为已经脱离了目前的执行栈,React合并更新的机制失效了

所以React在17版本中在React-Dom中提供了 unstable_batchedUpdates 方法,能够让我们手动合并更新,使用如下

25.png

现在点击button之后,页面上只会打印两次render

9.2 React18的setState

更新到18版本之后,使用createRoot().render()创建的ReactApp就不需要开发者操心合并更新的事情了

26.png

但是使用render方式创建ReactApp表现与React17一致(觉得React屎山味越来越重了)

9.3 借助任务队列实现合并更新

接下来,介绍Preact是如何实现合并更新的。使用过js的开发者一定都知道JavaScript的一个特点就是单线程。那么单线程就会涉及到一个事件循环机制,说到事件循环,又不得不引出js的宏任务队列与微任务队列概念。下面这类型的面试题各位小伙伴一定都做过

console.log(1)

setTimeout(() => {
  console.log(2)
})

new Promise((resolve) => {
  console.log(3)
  resolve()
}).then(() => {
  console.log(4)
  setTimeout(() => {
    console.log(5)
  })
})

queueMicrotask(() => {
  console.log(6)
})

setTimeout(() => {
  console.log(7)
})

答案是1,3,4,6,2,7,5

那么我们就能借助微/宏事件队列这个模型来实现一个合并更新,以下为核心代码实现,这里的isDirty变量至关重要,用来标注一个组件是否是脏组件,避免重复push到微任务队列中

27.png

9.4 setState

setState的类型定义如下,Sstate的类型,Pprops的类型,可以看到,setState第一个参数可以传递两种类型,第二个参数可以传递一个callback,这很好实现,直接放到 _renderCallbacks任务队列中就行,执行它的任务之后会交给commitRoot完成,setState不需要关心此事

setState<K extends keyof S>(
	state:
		| ((
				prevState: Readonly<S>,
				props: Readonly<P>
			) => Pick<S, K> | Partial<S> | null)
		| (Pick<S, K> | Partial<S> | null),
	callback?: () => void
): void;

具体的实现逻辑如下

  1. Component类上准备一个setState的方法,此方法接收两个参数。还记得在 static getDerivedStateFromProps中准备的 _nextState变量吗,我们只需要把第一个参数处理完成的结果,保存在 _nextState中,那么在diff方法中,就能自动帮助我们处理

image-20220207210255159.png

  1. 判断第二个参数是否是个方法,如果是方法,直接放到 _renderCallbacks

  2. 全局准备一个更新队列 rerenderQueue,因为可能多个类组件需要更新,再准备一个 enqueueRender 方法,用来接收需要更新的类组件的实例,把组件实例存储到 rerenderQueue 中,并在这个方法中借助微任务队列实现合并更新的操作

image-20220208092025802.png

  1. 当上面的工作全部处理完成之后,事件循环机制就会开始处理微任务队列中的任务,rerenderQueue中保存了需要更新的类组件实例,接下来要干的事情其实和 render 方法要干的事情是一样的,有一点区别,就是不再从根节点开始diff,因为React是单向流,直接从需要update的节点向下diff整课树就行

code-16442888088281.png

9.5 forceUpdate

forceUpdate的内部实现与setState一致,只不过没有第一步的处理逻辑。并且调用它之后,不会触发shouldComponentUpdate方法,直接调用render获取新的虚拟dom,紧接着直接进入diff阶段。为了实现这个效果,可以在类组件实例上准备一个变量_force,标识为true,到时候执行 shouldComponentUpdate 前,如果变量是否为true,就不执行shouldComponentUpdate方法

10 update生命周期实现

update的相关实现见 /packages/10,可以使用 $ npm run 10 启动

10.1 shouldComponentUpdate

shouldComponentUpdate类型定义如下

shouldComponentUpdate?(
  // 下一次的nextProps
  nextProps: Readonly<P>,
  // 下一次的nextState
  nextState: Readonly<S>,
  // 先不考虑context
  nextContext: any
): boolean;

因为shouldComponentUpdate不是静态方法,所以在此方法中,可以访问到未更新前的stateprops。所以需要传入下一次的propsstate,这其实也非常好实现,nextProps从新的虚拟dom上取,nextState已经挂载到_nextState上,大概逻辑如下

code-16443002790993.png

10.2 getSnapshotBeforeUpdate/componentDidUpdate

getSnapshotBeforeUpdate需要和componentDidUpdate合起来一起看,首先,先看一下两者的类型定义

getSnapshotBeforeUpdate?(prevProps: Readonly<P>, prevState: Readonly<S>): SS | null;
componentDidUpdate?(prevProps: Readonly<P>, prevState: Readonly<S>, snapshot?: SS): void;

需要注意的是,getSnapshotBeforeUpdate会在update前触发,也就是说可以在这个函数中进行一些dom状态的保存,但是此时,组件实例中的stateprops已经被覆盖,所以此生命周期接收的参数是上一次的propsstate。并且getSnapshotBeforeUpdate的返回值会当作componentDidUpdate的第三个参数传入

了解了需要传递的参数,那么实现也相当容易

code-16443013028104.png

11 组件卸载

unmount的相关实现见 /packages/11,可以使用 $ npm run 11 启动

组件卸载的时候,会触发 componentWillUnmount方法,但是在之前的diff算法讲解中,移除未使用的组件只是简单的把dom元素移除掉。因为浏览器会帮助我们把一个节点下的所有子节点全部移除。那么要实现componentWillUnmount,只需要做一个递归

code-16443017747955.png

12 Context

Context的相关实现见 /packages/12,可以使用 $ npm run 12 启动

12.1 rerenderQueue与_dirty

在讲解Context之前,回顾一下在setState中说过的 rerenderQueueContext中的应用,看下面的例子,现在虚拟dom树结构下。A1向下提供内容,B2C2都使用到了它

image-20220209103510739.png

A1的内容发生改变之后,Preact会在需要更新的组件上添加一个标识 _dirty,并赋值为true。并把A1B2C1全部推到 rerenderQueue 中,从队列中索引为0的位置开始遍历,每个节点向下递归更新,所以需要判断一下当前更新队列中的组件实例_dirty是否为true,因为如果不为true,说明已经处理过了,不需要更新

image-20220209102636513.png

当更新操作完成之后,Preact会把节点的 _dirty赋值为false,所以其实在diff A1的时候,已经顺便把B2C1处理过了,因此队列中的第二和第三并不会处理

image-20220209104408871.png

rerenderQueue如果不设置为一个数组,此时,B1添加一个shouldComponentUpdate生命周期,直接阻止更新,A1Provider发生改变,从A1开始向下递归更新,那么C1的消费行为在整个页面的生命周期中就是无效行为

image-20220209103642687.png

那么存成数组的好处就出现了,直接遍历rerenderQueue处理其中的每一个节点,因为A1向下递归更新,B2顺带处理完成,C1由于父节点阻止更新导致未处理,但是此时C1存在rerenderQueue中,遍历完A1B2,自然就会处理到它

image-20220209103909557.png

12.2 createContext

12.2.1 globalContext和contextId

看下图例子,有没有想过这么一个问题,A1B1分别提供了Provider,为什么B2只能消费ctx1C1却能消费ctx12

image-20220209140320291.png

Preact中是这么实现的,首先,Provider其实就是一个函数,那么意味着,处理方式与Fragment类似,也会借助Component进行实例化

code-16443785174783.png

每次执行createContext的时候,内部都会为每个context生成一个唯一的id,这个id名为contextId,比如现在创建两个context,假设两者的id分别为ctx1,和ctx2

code-16443856776324.png

diff的入口,Preact会准备一个空对象,名为globalContext

image-20220209135759761.png

首先,在diff A1这个大节点(可以看成一个函数组件)的时候,发现其中使用了 ctx1.Provider,那么内部会干两件事情

  • 浅拷贝一份globalContext
  • 给浅拷贝的globalContext对象上添加一个keyctx1.idvaluectx1.Provider的实例的项

接着,把浅拷贝的globalContext代替旧的globalContext,传递给 B1B2

image-20220209140008779.png

那么在diff B1这个大节点的时候,发现其中使用了 ctx2.Provider,那么内部会干两件事情

  • 浅拷贝一份之前浅拷贝过的globalContext
  • 给浅拷贝的globalContext对象上添加一个keyctx2.idvaluectx2.Provider的实例的项

接着,把浅拷贝的globalContext代替旧的globalContext,传递给 C1。那么现在整课树中,globalContext指向如下所示

image-20220209140705147.png

现在,每个子节点都可以从globalContext上获取到对应作用域下的内容

12.2.2 Provider实现

Provider因为是函数,所以内部会被Component代理实例化,所以可以在内部借助类组件的生命周期,自己实现一个发布订阅的机制

code-16443918235066.png

在实例化的时候,只需要判断当前节点上是否存在getChildContext,就可以知道是否需要重写globalContext

code-16443928914908.png

12.3 static contextType

React中有一种消费数据的写法,就是把要消费的context放置在类组件的静态属性contextType上,接着就能从this.context上访问数据

code-16443922637547.png

要实现这个效果,需要借助globalContext这个属性,从globalContext上获取对应Provider的实例

code-16443942336459.png

下一步,在组件初始化的时候,把使用到static ContextType的组件实例放到Provider的订阅表中去

code-164439479873211.png

并把context存储到类的实例上去

code-164439793926413.png

需要注意的是,因为Set散列表是无序的,所以可以给每个虚拟dom添加一个 _depth 属性,层级越深,_depth越大

image-20220209170038617.png

这样在执行update时,就可以先借助_depth排序,把层级最浅的虚拟dom排在前面,从而实现 [12.1](##12.1 rerenderQueue与_dirty)的效果

12.4 Consumer

Consumer的实现非常巧妙,两行代码就能搞定

  • Consumer绑定一个静态属性contextType
  • 因为Consumer是函数组件,并且绑定了一个contextType属性,那么这就意味着可以在this.context中访问到provider提供的数据,直接调用 props.children(this.context) 就能完成渲染

code-164439768608512.png

13 hooks

hooks的相关实现见 /packages/13,可以使用 $ npm run 13 启动

13.1 准备工作

13.1.1 hooks基本原理

hooks为什么不能在循环,判断中使用,原因在于函数组件会像类组件中的render一样,重复执行。而vue3setupsolidJs的函数组件,更像一个constructor,只会触发一次,因此它们不需要太在意hooks的顺序问题

hooks中的useMemouseCallbackuseRef等等还有缓存的作用,那么这些hooks怎么才能知道哪些东西需要使用缓存,哪些需要重新执行?在Preact中,它是这么实现的,首先,函数组件被Component代理,然后在实例化对象上,如果发现此函数组件中使用了hooks,那么就会在类实例上存储一个 __hook变量,具体结构如下

{ 
  __hook: {
    // 存储hook的一些数据,方便下次判断与复用
    _list: [],
    // useEffect中用到,现在先不讲
    _pendingEffects: []
  } 
}

紧接着,定义一个变量 hookIndex,初始值为0

准备工作完成,开始执行函数组件,hooks执行的时候,都会从 __hook._list[hookIndex] 获取上一次的缓存(不同hooks存储的数据不同),然后内部,执行完成一个hooks后,hookIndex都会+1。因此,如果函数组件每次执行的时候,hooks的位置不固定,就会导致缓存利用失败

image-20220211172246405.png

13.1.2 获取当前执行的组件实例

在函数组件的执行过程中,hooks需要从一个地方获取到当前函数组件的虚拟dom对象(从虚拟dom对象上可以获取到Component代理对象),在此对象上存放和写入一个数据。其实这很好实现,你从上面的实现中就可以看到,整个过程其实就是在和diff函数打交道,所以只需要在diff函数执行render获取虚拟dom之前,把当前的newVNode存储到一个地方就行了。因为hooks是可选功能,所以没必要在未使用hooks的时候存储newVNode

Preact是这么做的,它在全局存储了一个options的空对象,如果importhooks的函数,就会在options上注册一个名为 _render 的函数,接着在diff函数中,调用Component.render前,调用options._render

code-16446649349511.png

接着在options._render中保存实例

code-16446653385793.png

13.2 实现useMemo

准备工作都已经完成了,现在可以实现一个最简单的hooks来引出更多的知识点

useMemo的类型定义如下,第二个参数可以为了方便理解可以理解成一个“依赖”(实际上并不是依赖,只是一个标识符)

type Inputs = ReadonlyArray<unknown>;

function useMemo<T>(factory: () => T, inputs: Inputs | undefined): T;

useMemo内部会把接收到的inputs和泛型T存储起来,如果第二次执行的时候,发现inputs中的值和前一次相同,factory就不需要重复调用,直接把上一次的存储起来的T返回

准备一个getHookState,用来获取设置当前函数组件实例上存储的数据

code-16446673385714.png

接着再准备一个用来判断新旧”依赖“是否一致的函数 argsChanged

code-16446674921955.png

最后,useMemo的实现只需要借助这两个函数就能实现

code-164467584722910.png

13.3 useCallback/useRef

Preact中,useCallback/useRef全部都是用useMemo实现的

code-16446745401757.png

13.4 useReducer/useState

useReducer也能使用 getHookStateargsChanged 实现。需要在hookState上存储三个变量

  • _reducer: 存储reducer函数
  • _value: 存储useReducer的返回值,
  • _component: 存储当前useReducer所在的组件实例,方便在触发dispatch后直接调用setState更新

code-16446755016068.png

useState使用 useReducer 就可以实现

code-16446757134769.png

13.5 memo

13.5.1 PureComponent实现

写到这里的时候,突然想起来PureComponent忘记写了,PureComponent只需继承Component,并添加一个shouldComponentUpdate的方法就能实现,如下

code-164468007674511.png

13.5.2 memo实现

memo可以借助PureComponent实现,如下

code-164468023646412.png

13.6 useContext

因为在调用hooks的时候,可以获取到当前的组件实例,也就是说可以获取到当前实例上的globalContext,那只需要在useContext中完成两件事情,第一步是取出Provider组件的实例,第二步是订阅组件

code-164468115954013.png

13.7 useLayoutEffect

13.7.1 执行流程

前面的hooks的实现相对简单,useLayoutEffect会稍微麻烦,因为useLayoutEffect有充当生命周期的作用,它的第一个参数(callback)的返回值是一个函数,它会根据“依赖”的变化,在下一次update前执行,如果没有”依赖"就会在组件卸载时执行

如下图所示,注意”类似“两个字,因为性质和生命周期并不完全相同

image-20220213114402204.png

当页面初始化的时候,控制台打印如下

useLayoutEffect
useLayoutEffect before 1

props.a + 1的时候打印如下

useLayoutEffect after 1
useLayoutEffect before 2

当A组件卸载的时候打印如下

useLayoutEffect unmounted
useLayoutEffect after 2

13.7.2 update实现

function useLayoutEffect(effect: EffectCallback, inputs?: Inputs): void;

第一步,判断 inputs 是否发生变化,如果发生变化在的 hookState 上会存储三个值

_effect    存储第一个回调,这个回调不会立马触发
_inputs    存储第二个依赖
_cleanup   存储调用_effect后的返回值

接着把hookState存储在 currentComponent._renderCallbacks

code-164473694606014.png

因为_renderCallbacks默认存储的是方法,但是hookState是对象。所以在commitRoot中一定会报错,因此需要在commitRoot执行前,把hookState和其它方法筛选出来,可以像获取组件实例一样,在options上注入一个方法

code-164473854391115.png

此方法需要对_renderCallbacks进行过滤,把hookState取出来,剩下的重新写入到 _renderCallbacks 中。取出来的hookState传入invokeEffect函数中,这个函数只干一件事情就是执行_effect函数,并把返回值赋值给_cleanup

code-164473867218916.png

那么这个_cleanup什么时候执行呢?它在invokeEffect之前执行,那么可以再定义一个函数,名为invokeCleanup,这个函数的作用就是执行_cleanup,执行完成之后,清除_cleanup

code-164473874031217.png

那么_commit的完整代码如下

code-164473884223518.png

13.7.3 unmount实现

到到现在为止,useLayoutEffect的大部分功能都已经完成,只缺少了一个组件卸载的时候调用的方法

image-20220213155927113.png

次函数其实在初始化的时候,已经存储到了_clearnUp中了,之所以没有被触发,是因为第二次更新的时候,“依赖”没有发生变化,所以没有把hookState存储到_renderCallbacks中,所以需要在组件卸载前,把存储起来的_clearnUp全部执行。只需要给options注入一个方法,并在unmount函数中执行

image-20220213160743763.png

接着,判断是否存储了_clearnUp,如果存储了,执行便是

code-164473972948619.png

13.8 useEffect

Preact中,useEffect的表现形式与React不同,Preact在组件卸载的时候,useEffect 会比useLayoutEffect 先触发,所以下面的代码不以Preact官方为准,自己实现

useEffect的实现与useLayoutEffect类似,只不过不把hookState存储在_renderCallbacks中,而是_pendingEffects中,这里需要添加一个属性_isUseEffect属性用于和useLayoutEffect区分

code-164474741004920.png

接着,给options上添加一个_diffed方法,用于执行_pendingEffects上的方法,此方法在所有子节点完成后触发

code-164474932295121.png

因为useEffect不会阻塞页面渲染,所以可以做一个类似setState的更新队列,把所有useEffect放置到下一次宏任务中执行所有

code-164474938243122.png

那么卸载同理,准备一个options._unmounted方法,执行所有 clearnUp方法

code-164474955445523.png

到现在为止其实就已经实现大半了,只不过会出现一个问题,useEffect会比useLayoutEffect先执行,并且在useEffect中还能访问到页面上的dom,这是因为options._unmounted方法没有生效,所有的_cleanUp方法在 options._unmount中全部执行完了。为了解决这个问题, 就要运用到 _isUseEffect 这个标识,修改options._unmount方法

code-164475001248724.png

14 concurrent mode

14.1 递归存在的问题

diff中使用递归的方式会出现一个问题,就是在diff的整个过程中,页面是卡死的状态,用户没法操作页面,就会造成不好的用户体验。以60帧的显示器为例,渲染一帧需要16.6ms,如果页面中正在展示动画,如果一帧的渲染时间超过16.6ms,就会出现卡顿,一般这种情况都是在某一时刻,任务执行时间太长,理想状态下,浏览器以下面的方式进行执行任务

image-20220214094805832.png

但是如果js执行时间过长,就会出现下面的情况

image-20220214095054628.png

那么有没有这么一种api,能够告知js,一帧还有多少空闲时间,如果空闲时间不够,直接暂停js程序,等到下一帧再运行,以此反复,直到任务执行完成?requestIdleCallback这个api就进入了React的视野了,这个api能够告知开发者,浏览器是否在空闲状态,由于这个api还在实验阶段,于是React官方自己实现了一个

14.2 concurrent mode基本原理

获取空闲时间的问题解决了,那么一份空闲时间内要执行多少任务?对于这个问题,React官方推出了一种新的用来描述页面结构的数据结构,名为fiber,可以把每个fiber都看成一个最小的执行单元,不可再分割,大致结构如下

{
	// 标识当前是什么节点
  tag: null,
  // 当前fiber对应页面中的真实dom地址
  stateNode: null,
  // 虚拟dom的type值
  type: null,
  // 虚拟dom的props值
  props: null,

  // 当前节点的父节点
  return: null,
  // 当前节点的兄弟节点
  sibling: null,
  // 当前节点的第一个子节点
  child: null,
  
  // 副作用指针
  firstEffect: null,
  nextEffect: null,
  lastEffect: null,
    
}

fiber其实就是虚拟dom的另外一种描述形式,下图的左边是页面中dom结构的树型描述,右边是fiber的基本结构描述

image-20220214092345550.png

fiber并不是凭空产生的,并且也不会在预编译时出现,而是运行时。这意味着fiber还会在运行时浪费一部分的性能,从虚拟dom转化成fiber。但是带来的好处是,这个单元足够的小,因此不会执行一个小单元就把当前帧的空闲时间占满(如果某一个单元执行时间过长,依然会出现掉帧),借助requestIdleCallback这个api就能实现如下效果

image-20220214101004790.png

当处理完成一个fiber之后,React会判断当前的fiber是否需要更新,然后挂载在父节点上,处理父节点的时候判断子节点或自己是否需要更新,如果需要再挂载到父父节点上,以此类推,等所有fiber全部处理完成之后,所有需要更新的fiber都会通过链表串起来,然后做一个while循环,一次性更新

image-20220214101839014.png

14.3 为什么不看好concurrent mode

尤雨溪曾经这么评价React

image-20220214104321354.png

concurrent mode本质上并没有带来性能上的提升,并且可能还会带来性能劣化。而且还会带来新的概念,开发者增加门槛,附带增加了一道面试题。github上有人向尤雨溪提问,为什么vue3要移除时间切片?尤雨溪也进行了答复,大致意思就是说,Fiber架构并不是为了解决过度渲染的问题而出现的,而是为了解决一个大任务渲染时间过长而存在,Preact的开发者对尤雨溪的回答也进行了认同,传送门

解决过渡渲染的问题Vue已经帮助开发者做好了,因此,就算交给js基础非常差的人,写出来的应用性能也不会差到哪里去。而React中的优化交给开发者,而大部分的开发者其实根本就不会去做优化,俗称能跑就行,对于这类开发者而言,fiber反而再次减低了性能,因此我并不是很看好concurrent mode

当然,虽然不看好,但是学还是得学的,Vue3都来了,React18还会远吗