Preact(React)核心原理详解

avatar
前端工程师 @字节跳动

豆皮粉儿们,又见面了,今天这一期,由字节跳动数据平台的“winge(宝丁)”,带大家见识见识前端“轮子”之一Preact框架。

图片

提到Preact,你肯定会先想到React吧。React的出现给我们带来了全新的Web开发体验,其中也带来了许多新的概念:JSX、virtual-dom、组件化、合成事件等。当我们想从源码层面去研究它的原理时,庞大又晦涩难懂的源码就大大地提高了困难度。然而Preact与React有一样的API的同时,又相对代码简练,更容易学习和研究原理,我们可以通过学习Preact和研究它的原理,而增长新的姿势!

本文作者:winge(宝丁)

本文将分为以下几个重点内容介绍Preact,宝宝们,准备好了没:

  • Preact 是什么?

  • Preact 和 React的区别有哪些?

  • Preact是怎么工作的

  • JSX

  • VirtualDom

  • Preact 的 VirtualDOM diff算法

  • PreactHooks 的实现

  • 一个组件的生命周期

一、Preact 是什么

简单而言, PreactReact 的3KB轻量级替代方案,它拥有着和 React 一样的API。有同学或许会问, Preact 中的 P 的含义是什么,根据 Preact 的作者表述的是 performance 的含义,这也是 Preact框架的目标之一。

我们先来看用 Preact编写的几个例子:

图片

图1

图片

图2

大家第一眼看上去,和 React的写法基本上一致的,如果仔细的看,大家可能会几个疑问:

  1. h 进行了变量的声明,但是没有使用,这个有什么意义?可以去掉么?

  2. 表单里面使用的是 onInput方法,而不是在 React中写的 onChange方法,这是为什么?

在这里我先不直接告诉大家答案,这些疑问会在下面的内容中一一为大家解答。

二、Preact 和 React 的区别有哪些?

Preact号称打包后的体积只有3KB,自然相比 React而言,在某些方面进行了精简,并且它本身的定位也不是准备重新实现一个 React,所以两者之间肯定是存在一些区别。

我们在这里仅介绍两者最主要的区别:

  • 事件系统

  • 更符合 Dom规范的描述

2.1 事件系统

通过一个例子,大家就能知道两者的区别

图片

图3

React内部,其自身实现了一套事件合成系统,所以我们一般在 React的表单组件中使用的都是 onChange 方法来进行组件值的更新,而在 Preact内部,没有事件合成系统,它直接使用的是由浏览器原生提供的事件系统,这也是为什么 Preact在表单里面使用的是 onInput方法,而不是在 React中写的 onChange方法。这也是它体积更小的直接原因之一。

2.2 更符合Dom规范的描述

React中我们想描述一个 DOM的类名,必须要使用 className, 而在 Preact中,不仅可以使用 className来描述,也可以直接使用 class来描述 DOM的类名,这也使得 Preact更接近原生 DOM规范的描述。

当然除了这些, PreactReact之间还有一些差别,由于它不是本文的重点,在这里我们就不一一展开介绍,大家可以直接通过Preact官网来进一步了解。

三、Preact是怎么工作的

在本节,我们将开始介绍 Preact的内部工作流程,希望阅读本节过后,大家对 Preact会有进一步的认识。

3.1 JSX

在介绍 JSX之前,我们先想一下如何在 JS中来描述 DOM结构,很多同学可能会想,可以通过浏览器的操作 DOM的API来完成,或者封装成一个工厂函数来进行接收一定的输入,输出就是相应的 DOM

图片

图4

但是如果每次都需要通过这么复杂的方式来进行 DOM结构的描述,想必 Preact的性能再优秀,也不能进一步的进行推广。

这个时候,如果换一种图5这样的的方式,是不是大家就很熟悉

图片

图5

没错,左侧其实就是我们平时写的 JSX语法,经过 babel或者其他的插件转换之后变成我们上面所说的函数式的描述,然后再经过一系列的处理,变成我们所熟悉的原生 DOM的结构,这也是 JSX产生的本质原因。

综合来看,其实 JSX的本质就是 JS的扩展,它允许你用类似 HTML/XML的结构,进而编译成类似图6的一个函数调用。

图片

图6

这个时候,我们就不得不提 babel的强大之处了,原来从 JSX转化到函数调用这个阶段是由 React团队提供的, 后面因为 babel做的更好,更强大,就逐渐演变成了 @babel/plugin-transform-react-jsx这个核心插件了, 那么这个时候我们也可以揭开上文中提到的 h函数的神秘面纱,正是因为在 Preact中, JSX的语法会通过 babel这个插件转换成一个名称为 h的工厂函数,类似于在 React中的 React.createElement的作用, 所以我们才需要去声明 h函数,虽然我们在实际开发环境上用不到,但是它的作用是体现在 babel转换后的代码中的, 大家也可以通过这个链接来体验 babel的强大所在。

3.2 Virtual Dom

在本节当中,我们将会介绍 Preact中的 VirtualDom是什么?那么它和我们前面说的 JSX之间有什么关联呢?

我们前面提到了 h函数是一个工厂函数,输入我们知道了,是一些描述 DOM结构的基本信息,那么它的输出是什么呢?我们可以通过下图来揭晓谜底。

图片

图7

从图7我们可以看出,其实 h函数的输出是一个特殊类型的数据结构,而 VirtualDOM本质上就是一种用来描述 DOM结构的数据结构,所以 h函数的输出其实就是我们常说的 VirtualDOM

不管在 React中还是在 Preact中,最核心的都是 VirtualDomdiff算法,怎么把最新的数据所驱动的 DOM结构表现在页面当中,这个也是大家最关心的环节。

3.3 Preact 的 Virtual DOM 的 diff 算法

Preact中, VirtualDOMdiff算法可以拆解为三大块。

  • Diff children

  • Diff 这里的 type 指的是组件的类型,主要分成 component、 Fragment和 dom node三种。

  • Diff props

接下来我们会分别仔细的介绍这三块

3.3.1 Diff children

图片

图8

对比 children差异主要有两个流程,首先我们先看左侧的流程图,在这个 diff阶段,我们会先对新的 children进行遍历,如果发现新的 child可以在老的 children中找到相同的 key,那么会执行 diff<type>这个阶段,如果没找到相同的 key,会去看是不是相同的类型,比如是不是相同的 dom node的类型,或者是相同的构造函数等,找到了的话 也会执行 diff<type>这个阶段,如果没有找到,会把这个老的 child放到一个数组当中。

新的 children遍历完毕之后,我们会执行下一个流程,也就是右侧的流程图,会进行遍历没有使用的 old child数组,将它们一一 unmount掉,这个时候也会执行相应的生命周期。当这个 child是一个父组件的话,会对它的 children重复这个流程,直到全部 unmount

在这个阶段,我们也可以得到“写 key是一个非常小但是却非常有用的性能优化手段”的结论,因为在一定的程度上它会有效的减少 diff过程中所带来的性能损耗。

3.3.2 Diff

图片

图9

Diff<type> 环节可以说是在整个 diff算法中最重要的一个环节,也是最复杂的一个环节。首先我们会进行新的 vnode判断它所属于的类型,目前来看,主要包括: Fragmentcomponentdom node,其中当判断 vnode的组件是一个空函数的时候表示的就是 Fragment,而为非空函数的就是 component类型。然后根据当前的 vnode所属的类型进行下一步的处理。

typeFragment的时候,就直接会将 Fragment内部的 children进入到上文中提到的 diff children阶段。

typecomponent时,我们会先判断当前的 vnode所代表的组件是否已经存在过,如果没有存在则执行 create操作,同时也会执行相对应的生命周期,如果已经存在对应的组件,那么则会执行 update操作,并且执行相对应的生命周期函数,在这里我们可以强调一下 shouldComponentUpdate生命周期函数,当它返回 false的时候,那么我们就不会再去执行下一步要执行的 render函数,只有当该生命周期函数不存在或者返回非 false的时候,我们会继续执行 render函数,然后继续走该 Diff<type>阶段。

typedom node时,我们首先会判断新老 vnode是否为同一 node type,如果不同,则会创建新的 dom并且代替,如果相同,则会进行更新操作。

回过头来看 Diff<type>环节,并且结合我们平时写组件的习惯,可以发现,最后我们写的组件都是原生的 dom结构,所以最后都会进入到 diff dom node这一流程中,也是在这一流程中,真正的去创建和更新 dom

3.3.3 Diff props

图片

图10

我相信,大家可能会有点奇怪这一个阶段是做什么的?在上文中我们提到了当两个 dom node节点类型相同的时候,会执行更新操作,那么该环节主要是为这个更新操作而服务。

它的原理很简单:先循环老的 domprops,如果它不在新的 dom上,那么就会将它设为空,然后循环新的 props,然后和老的 props中相同的 prop去做比较,然后设置最新的 prop的值。

到这里,一个完整的 virtualdom diff过程也就完成了,今天要介绍的 Preact内部的工作原理部分也结束了。但是大家可能还比较难和一个真实的组件来相关联,接下来我们通过一个真实的组件,来将上面的过程进行串联,加深大家对它的理解。

四、结合实际组件了解整体渲染流程

首先,我们先编写一个如下图的 Clock组件:

图片

图11

接下来我们会通过两个阶段来介绍:

  1. 初次渲染

  2. 执行 setState

为了方便介绍,我在画了一个流程图,大家可以搭配图12的流程图和文字来看,方便大家更容易理解。

图片

图12

4.1 初次渲染

  1. 入口函数为 render(<Clock/>,document.body)

  2. 将 JSX语法转化成 h函数的形式之后,也就是 createElement函数来创建一个用来描述子组件为 Clock组件的 vitrual node(下文简称为 vnode),类似于这种结构 { type: Fragment, children: [Clock], props: null }

  3. 将该 vnode,用数组包裹起来,然后送入到 diff children阶段

  4. 当 diff children阶段结束之后,会执行 commitRoot方法来执行挂载组件的 componentDidMount方法,内部主要是通过 promise或者 setTimeout来做有异步的处理。

  5. 接下来我们主要来进行描述 diff children的流程

  6. 因为是第一次渲染,所以我们都没有老的 vnode也就没有所谓的是否具有相同 key或者相同 type的新老 vnode

  7. 直接进入到 diff(newChild,oldChild)这一阶段

  8. 判断我们的 vnode的 type是一个 component,并且是一个新的组件,这个时候我们创建新组件,并且执行对应的生命周期,然后调用我们的 render函数

  9. 因为 render函数的返回值其实依然是一个 vnode,所以会继续流转到 diff(newChild,oldChild)这一个阶段,直到判断 type是 dom node时,会执行 dom的操作变化。

4.2 执行setState

  1. 我们可以从流程图中看到,其实 setState本质上的操作,会将它所在的 vnode送入到 diff(newChild,oldChild)中,而 newChild和 oldChild的主要区别其实就是 state的变化

  2. 因为 Clock组件是一个 component类型的 vnode,所以我们会继续判断它是不是新组件,很显然已经不是了,于是会执行对应的生命周期,如果没有 shouldComponentUpdate生命周期函数或者返回了 true,那么我们会继续执行 render函数,不然我们会停止组件的渲染。

  3. 这个时候 render函数中,已经有了我们最新的 state了,那么对应的接下来会继续走 diff(newChild,oldChild)流程,直到将更改的 state值在真实的 dom结构中的 props中体现出来。

到这里,整个 Clock组件的渲染过程就介绍完了,也希望大家通过这个例子,能够对 Preact的底层工作原理有了更深的认识。

五、Preact hooks

hooksReactv16.8版本中引入的新 APIPreact作为 React的可代替方案,自然也会跟上这个变化,在 Preact中, hooks是作为一个单独的包引入的,包括注释总代码仅300行。

Preact中, hooks可以分为三类:

  • MemoHook

  • ReducerHook

  • EffectHook

接下来我们将通过这三类来介绍

5.1 MemoHook

MemoHook的主要作用是用来做一些性能优化的 hook集合。并且在 MemoHook内部,有一个通用的数据结构,用来表示该 Hook内部的数据结构。

图片

5.1.1 useMemo

useMemo的作用主要是:我们可以记住计算的结果,并且仅在其中一个依赖项发生更改时才重新计算它。

图片

当我们每次进行渲染的时候,都会去执行 expensive这个非常耗费性能的计算,这样下来,会造成一定的性能的损耗,那我们可以使用 useMemo来进行优化。这样如果 expensive依赖的值没有变化,就不需要执行这个函数,而是取它的缓存值。

图片

其实它的内部原理很简单,我们可以通过下图通过它的源码进行分析

图片

本质上就是进行前后比较它的依赖的数据是否发生了改变,如果发生了变化,则调用传入的 callback函数,否则就直接返回原来的内部的 state的值。

5.1.2 useCallback

作用:它可用于确保只要没有依赖项发生更改,返回的函数将始终保持引用相等。

图片

用上图的例子来说明它的作用就是,当它的依赖项 a b未发生变化的时候, onClick这个函数始终是相同的。

实际上 useCallback(fn,deps)useMemo(()=>fn,deps)是等价的,因为 useCallback就是用 useMemo来实现的,只是它返回的是一个没有进行调用的 callback,所以上图的代码可以等价于

图片

即当 a b不发生变化的时候, ()=>console.log(a,b)也就不会发生变化。

5.1.3 useRef

作用:获得对功能组件内部的DOM节点的引用。它的工作原理类似于 createRef

图片

它的原理也是十分的简单

图片

本质上就是初始化的时候创建一个内部状态为 {current:initialValue}的组件,且不依赖任何数据,需要则通过手动赋值修改。

5.2 ReducerHook

ReducerHook的主要作用是用来做一些性能优化的 hook集合。并且在 ReducerHook内部,有一个通用的数据结构,用来表示该 Hook内部的数据结构。

图片

5.2.1 useReducer

useReducer的使用方式和 redux非常像。

图片

对于使用过 redux的同学来说,这样的用法应该会很熟悉。

我们可以通过源码来进行分析它的实现原理

图片

更新 state就是调用 dispatch,也就是通过 reducer(preState,action)计算出下次的state赋值给_value。然后调用组件的 setState方法进行组件的diff和相应更新操作。

5.2.2 useState

useState 大概是平时在开发过程中最常使用的 hook, 它类似于 class 组件中的 state 状态值。

图片

它的原理很简单,就是利用 useReducer来进行实现的, 也就是 useState其实只是传特定 reduceruseReducer一种实现。

图片

5.3 EffectHook

“副作用”一词在很多参与过 React相关的项目开发的同学来说,肯定不会陌生,无论是要从API获取某些数据还是要对文档触发效果,基本上可以发现 EffectHook 几乎可以满足所有需求。这也是 hooks API的主要优点之一,它使得你能够更聚焦于对效果的思考,而不再是对组件生命周期的思考。

在整个 EffectHook中,都贯穿了下面这样的通用数据结构.

图片

5.3.1 useEffect 和 useLayoutEffect

这两个 hook 的用法完全一致,都是在 render 过程中执行一些副作用的操作,可来实现以往 class 组件中一些生命周期的操作。区别在于, useEffectcallback 执行是在本次渲染结束之后,下次渲染之前执行。useLayoutEffect 则是在本次会在浏览器 layout 之后, painting 之前执行,是同步的。

图片

使用的方式和前面的 hook的使用方式基本上一致,传递一个回调函数和一个依赖数组,数组的依赖参数变化时,重新执行回调。

图片

他们的实现机制,稍微有些复杂,我们先看源码

图片

从代码上来看,它们的实现几乎一样,唯一的区别是进入的回调分别是 _renderCallbacks_pendingEffects,从而达到了不同时机下进行渲染,这一块的具体逻辑,大家可以参考这篇文章了解更多的细节。

整体来看, preacthook模块的代码实现虽然不多,但是却体现出了它的精炼以及 preact优秀的架构。

参考:

preact