Preact(React)核心原理详解

1,689 阅读16分钟

原创: 宝丁 玄说前端

本文作者:字节跳动 - 宝丁

  • 一、Preact 是什么
  • 二、Preact 和 React 的区别有哪些?
  • 三、Preact 是怎么工作的
  • 四、结合实际组件了解整体渲染流程
  • 五、Preact Hooks
  • 结束语
  • 2.1 事件系统
  • 2.2 更符合 DOM 规范的描述
  • 3.3.1 Diff children
  • 3.3.2 Diff
  • 3.3.3 Diff props
  • 3.1 JSX
  • 3.2 Virtual DOM
  • 3.3 Preact 的 Virtual DOM 的 Diff 算法
  • 4.1 初次渲染
  • 4.2 执行 setState
  • 5.3.1 useEffect 和 useLayoutEffect
  • 5.2.1 useReducer
  • 5.2.2 useState
  • 5.1.1 useMemo
  • 5.1.2 useCallback
  • 5.1.3 useRef
  • 5.1 MemoHook
  • 5.2 ReducerHook
  • 5.3 EffectHook

在前端界,React 一定是我们耳熟能详的前端开发框架之一,它的出现可以说是带给了我们全的 Web 开发体验,其中也带来了许多新的概念:JSX、virtual-dom、组件化、合成事件等。当我们想从源码层面去研究它的原理时,由于 React 的源码的庞大和晦涩难懂,这也会变得异常困难。但是在爱好“造轮子”的前端界,我们会发现一些和 React 有着近乎相同的框架,本文的主人公 Preact 也是其一,但是它相对简练的代码,使得我们更好地去学习和研究它的原理。本文将从以下几个方面介绍:

  • Preact 是什么?

  • Preact 和 React 的区别有哪些?

  • Preact是怎么工作的

  • JSX

  • Virtual Dom

  • Preact 的 Virtual DOM Diff 算法

  • Preact Hooks 的实现

  • 一个组件的生命周期

一、Preact 是什么 简单而言,Preact 是 React 的 3KB 轻量级替代方案,它拥有着和 React 一样的 API。有同学或许会问,Preact 中的 P 的含义是什么,根据 Preact 的作者表述的是 performance 的含义,这也是 Preact 框架的目标之一。 我们先来看用 Preact 编写的几个例子:

image

图 1

image

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

  1. h 进行了变量的声明,但是没有使用,这个有什么意义?可以去掉么?
  2. 表单里面使用的是 onInput 方法,而不是在 React 中写的 onChange 方法,这是为什么?

在这里我先不直接告诉大家答案,这些疑问会在下面的内容中一一为大家解答。 二、Preact 和 React 的区别有哪些? Preact 号称打包后的体积只有 3KB,自然相比 React 而言,在某些方面进行了精简,并且它本身的定位也不是准备从新实现一个 React,所以两者之间肯定是存在一些区别。 我们在这里主要介绍两者最主要的区别:

  • 事件系统
  • 更符合 Dom 规范的描述

2.1 事件系统 通过一个例子,大家或许就能知道两者的区别。

image

图 3 在 React 内部,其自身实现了一套事件合成系统,所以我们一般在 React 的表单组件中使用的都是 onChange 方法来进行组件值的更新,而在 Preact 内部,没有事件合成系统,它直接使用的是由浏览器原生提供的事件系统,这也是为什么 Preact 在表单里面使用的是 onInput 方法,而不是在 React 中写的 onChange 方法。这也是它体积更小的直接原因之一。 2.2 更符合 DOM 规范的描述 在 React 中我们想描述一个 DOM 的类名,必须要使用 className, 而在 Preact 中,不仅可以使用 className 来描述,也可以直接使用 class 来描述 DOM 的类名,这也使得 Preact 更接近原生 DOM 规范的描述。 当然除了这些,Preact 和 React 直接还有一些差别,由于它不是本文的重点,在这里我们就不一一展开介绍,大家可以直接通过 Preact 官网来进一步了解。 三、Preact 是怎么工作的 在本节,我们将开始介绍 Preact 的内部工作流程,希望阅读本节过后,大家对 Preact 会有进一步的认识。 3.1 JSX 在介绍 JSX 之前,我们先想一下如何在 JS 中来描述 DOM 结构,很多同学可能会想,可以通过浏览器的操作 DOM 的 API 来完成,或者封装成一个工厂函数来进行接收一定的输入,输出就是相应的 DOM。

image

图 4 但是如果每次需要让我们通过这么复杂的方式来进行 DOM 结构的描述,想必 React 的性能再优秀,也能进一步的进行推广。 这个时候,如果换一种图 5 这样的的方式,是不是大家就很熟悉?

image

图 5 没错,左侧其实就是我们平时写的 JSX 语法,经过 babel 或者其他的插件转换之后变成我们上面所说的函数式的描述,然后再经过一系列的处理,变成我们所熟悉的原生 DOM 的结构,这也是 JSX 产生的本质原因。 综合来看,其实 JSX 的本质就是 JS 的扩展,它允许你用类似 HTML/XML 的结构,进而编译成类似图 6 的一个函数调用。

image

图 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 中的 Virtual DOM 是什么?那么它和我们前面说的 JSX 之间有什么关联呢? 我们前面提到了 h 函数是一个工厂函数,输入我们知道了,是一些描述 DOM 结构的基本信息,那么它的输出是什么呢?我们可以通过下图来揭晓谜底。

image

图 7 从图 7 我们可以看出,其实 h 函数的输出是一个特殊类型的数据结构,而 Virtual DOM 本质上就是一种用来描述 DOM 结构的数据结构,所以 h 函数的输出其实就是我们常说的 Virtual DOM。 不管在 React 中还是在 Preact 中,最核心的都是 Virtual DOM 的 Diff 算法,怎么把最新的数据所驱动的 DOM 结构表现在页面当中,这个也是大家最关心的环节。 3.3 Preact 的 Virtual DOM 的 Diff 算法 在 Preact 中,Virtual DOM 的 Diff 算法可以拆解为三大块。

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

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

image

图 8 在对 children 主要会有两个流程,首先我们先看左侧的流程图,在这个 Diff 阶段,我们会先对新的 children 进行遍历,如果发现新的 child 可以在老的 children 中找到相同的 key,那么会执行 diff <type> 这个阶段,如果没找到相同的 key,会去看是不是相同的类型,比如是不是相同的 DOM node 的类型,或者是相同的构造函数等,找到了的话 也会执行 diff <type> 这个阶段,如果没有找到,会把这个老的 child 放到一个数组当中。 当对新的 children 遍历完毕之后,我们会执行下一个流程,也就是右侧的流程图,会进行遍历没有使用的 old child 数组,将它们一一unmout 掉,这个时候也会执行相应的生命周期。当这个 child 是一个父组件的话,会对它的 children 重复这个流程,直到全部 unmount。 在这个阶段,我们也可以得到为什么写 key 是一个非常小但是却非常有用的性能优化手段,因为在一定的程度上它会有效地减少 Diff 过程中所带来的性能损耗。

3.3.2 Diff

image

图 9 Diff <type> 环节可以说是在整个 Diff 算法中最重要的一个环节,也是最复杂的一个环节。手首先我们会进行新的 vnode 判断它所属于的类型,目前来看,主要包括: Fragment、Component 和 DOM node,其中当判断 vnode 的组件是一个空函数的时候表示的就是 Fragment,而为非空函数的就是 Component 类型。然后根据当前的 vnode 所属的类型进行下一步的处理。 当 type 为 Fragment 的时候,就直接会将 Fragment 内部的 children 进入到上文中提到的 Diff children 阶段。 当 type 为 component 时,我们会先判断当前的 vnode 所代表的组件是否已经存在过,如果没有存在则执行 create 操作,同时也会执行相对应的生命周期,如果已经存在对应的组件,那么则会执行 update 操作,并且执行相对应的生命周期函数,在这里我们可以强调一下 shouldComponentUpdate 生命周期函数,当它返回 false 的时候,那么我们就不会再去执行下一步要执行的 render 函数,只有当该生命周期函数不存在或者返回非 false 的时候,我们会继续执行 render 函数,然后继续走该 Diff <type> 阶段。 当 type 为 DOM node 时,我们首先会判断新老 vnode 是否为同一 node type,如果不同,则会创建新的 DOM 并且代替,如果相同,则会进行更新操作。 回过头来看 Diff <type> 环节,并且结合我们平时写组件的习惯,可以发现,最后我们写的组件都是原生的 DOM 结构,所以最后都会进入到 Diff DOM node 这一流程中,也是在这一流程中,真正的去创建和更新 DOM。 3.3.3 Diff props

image

图 10 我相信,大家可能会有点奇怪这一个阶段是做什么的?在上文中我们提到了当两个 DOM node 节点类型相同的时候,会执行更新操作,那么该环节主要是为这个更新操作而服务。 它的原理很简单:先循环老的 DOM 的 props,如果它不在新的 DOM 上,那么就会将它设为空,然后循环新的 props,然后和老的 props 中相同的 prop 去做比较,然后设置最新的 prop 的值。 到这里,我们整个的 Virtual DOM 过程也就完成了,Preact 内部的工作原理也基本上介绍完了,但是大家可能还比较难和一个真实的组件来相关联,接下来我们通过一个真实的组件,来将上面的过程进行串联,加深大家对它的理解。 四、结合实际组件了解整体渲染流程 首先,我们先编写一个如下图的 Clock 组件:

image

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

  1. 初次渲染
  2. 执行 setState

为了方便介绍,我在画了一个流程图,大家可以搭配图 12 的流程图(点击这里获取高清大图)和文字来看,方便大家更容易理解。

image

图 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 Hooks 是 React v16.8 版本中引入的新 API,Preact 作为 React 的可代替方案,自然也会跟上这个变化,在 Preact 中,Hooks 是作为一个单独的包引入的,包括注释总代码仅 300 行。 在 Preact 中,Hooks 可以分为三类:

  • MemoHook
  • ReducerHook
  • EffectHook

接下来我们将通过这三类来介绍。 5.1 MemoHook MemoHook 的主要作用是用来做一些性能优化的 Hook 集合。并且在 MemoHook 内部,有一个通用的数据结构,用来表示该 Hook 内部的数据结构。

image

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

image

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

image

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

image

图 16 本质上就是进行前后比较它的依赖的数据是否发生了改变,如果发生了变化,则调用传入的 callback 函数,否则就直接返回原来的内部的 state 的值。 5.1.2 useCallback 作用:它可用于确保只要没有依赖项发生更改,返回的函数将始终保持引用相等。

image

图 17 用上图的例子来说明它的作用就是,当它的依赖项 a、b 未发生变化的时候,onClick 这个函数始终是相同的。 实际上 useCallback(fn, deps) 和 useMemo(() => fn, deps) 是等价的,因为 useCallback 就是用 useMemo 来实现的,只是它返回的是一个没有进行调用的 callback,所以上图的代码可以等价于:

image

图 18 即当 a、b 不发生变化的时候,() => console.log(a, b) 也就不会发生变化。 5.1.3 useRef 作用:获得对功能组件内部的 DOM 节点的引用。 它的工作原理类似于 createRef。

image

图 19 它的原理也是十分的简单。

image

图 20 本质上就是初始化的时候创建一个内部状态为 {current:initialValue} 的组件,且不依赖任何数据,需要则通过手动赋值修改。 5.2 ReducerHook ReducerHook 的主要作用是用来做一些性能优化的 Hook 集合。并且在 ReducerHook 内部,有一个通用的数据结构,用来表示该 Hook 内部的数据结构。

image

图 21 5.2.1 useReducer useReducer 的使用方式和 Redux 非常像。

image

图 22 对于使用过 Redux 的同学来说,这样的用法应该会很容易接受和熟悉。 我们可以通过源码来进行分析它的实现原理。

image

图 23 更新 state 就是调用 dispatch,也就是通过 reducer(preState, action) 计算出下次的 state 赋值给 _value。然后调用组件的 setState 方法进行组件的 Diff 和相应更新操作。 5.2.2 useState useState 大概是平时在开发过程中最常使用的 Hook,它类似于 class 组件中的 state 状态值。

image

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

image

图 25 5.3 EffectHook “副作用”一词在很多参与过 React 相关的项目开发的同学来说,肯定不会陌生,无论是要从 API 获取某些数据还是要对文档触发效果,基本上可以发现 EffectHook 几乎可以满足所有需求。 这也是 Hooks API 的主要优点之一,它使你的思维重塑了对效果的思考,而不是对组件生命周期的思考。 在整个 EffectHook 中,都贯穿了下面这样的通用数据结构。

image

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

image

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

image

图 28 它们的实现机制,稍微有些复杂,我们先看源码。

image

图 28 从代码上来看,它们的实现几乎一样,唯一的区别是进入的回调分别是 _renderCallbacks、_pendingEffects,从而达到了不同时机下进行渲染,这一块的具体逻辑,大家可以参考这篇文章了解更多的细节。 整体来看,Preact 的 Hook 模块的代码实现虽然内不多,但是是却体现出了它的精炼以及 Preact 优秀的架构。 结束语 最后希望大家能够通过本文,对 Preact 的整体工作机制有了更加深入的理解,有时间的同学也可以自己尝试阅读 Preact 的源码并结合本文,我相信阅读之后一定能够对 React 的理解更上一层楼。再次感谢大家! 欢迎关注“玄说-前端”微信公众号

image

福利:

扫描下方二维码,加”助理“好友,回复”加群“,进入“玄说-前端” 微信群,一起讨论前端技术,更有大厂内推机会。

image