原创: 宝丁 玄说前端
本文作者:字节跳动 - 宝丁
- 一、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 编写的几个例子:
图 1
图 2
大家第一眼看上去,和 React 的写法基本上一致的,如果仔细的看,大家可能会几个疑问:
- h 进行了变量的声明,但是没有使用,这个有什么意义?可以去掉么?
- 表单里面使用的是 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 规范的描述。
当然除了这些,Preact 和 React 直接还有一些差别,由于它不是本文的重点,在这里我们就不一一展开介绍,大家可以直接通过 Preact 官网来进一步了解。
三、Preact 是怎么工作的
在本节,我们将开始介绍 Preact 的内部工作流程,希望阅读本节过后,大家对 Preact 会有进一步的认识。
3.1 JSX
在介绍 JSX 之前,我们先想一下如何在 JS 中来描述 DOM 结构,很多同学可能会想,可以通过浏览器的操作 DOM 的 API 来完成,或者封装成一个工厂函数来进行接收一定的输入,输出就是相应的 DOM。
图 4
但是如果每次需要让我们通过这么复杂的方式来进行 DOM 结构的描述,想必 React 的性能再优秀,也能进一步的进行推广。
这个时候,如果换一种图 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 中的 Virtual DOM 是什么?那么它和我们前面说的 JSX 之间有什么关联呢?
我们前面提到了 h 函数是一个工厂函数,输入我们知道了,是一些描述 DOM 结构的基本信息,那么它的输出是什么呢?我们可以通过下图来揭晓谜底。
图 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
图 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
图 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
图 10
我相信,大家可能会有点奇怪这一个阶段是做什么的?在上文中我们提到了当两个 DOM node 节点类型相同的时候,会执行更新操作,那么该环节主要是为这个更新操作而服务。
它的原理很简单:先循环老的 DOM 的 props,如果它不在新的 DOM 上,那么就会将它设为空,然后循环新的 props,然后和老的 props 中相同的 prop 去做比较,然后设置最新的 prop 的值。
到这里,我们整个的 Virtual DOM 过程也就完成了,Preact 内部的工作原理也基本上介绍完了,但是大家可能还比较难和一个真实的组件来相关联,接下来我们通过一个真实的组件,来将上面的过程进行串联,加深大家对它的理解。
四、结合实际组件了解整体渲染流程
首先,我们先编写一个如下图的 Clock 组件:
图 11
接下来我们会通过两个阶段来介绍:
- 初次渲染
- 执行 setState
为了方便介绍,我在画了一个流程图,大家可以搭配图 12 的流程图(点击这里获取高清大图)和文字来看,方便大家更容易理解。
图 12
4.1 初次渲染
- 入口函数为
render(<Clock />, document.body)
。 - 将 JSX 语法转化成 h 函数的形式之后,也就是 createElement 函数来创建一个用来描述子组件为 Clock 组件的 vitrual node(下文简称为 vnode),类似于这种结构
{type: Fragment, children: [Clock], props: null }
。 - 将该 vnode,用数组包裹起来,然后送入到 Diff children 阶段
- 当 Diff children 阶段结束之后,会执行 commitRoot 方法来执行挂载组件的 componentDidMount 方法,内部主要是通过 promise 或者 setTimeout 来做有异步的处理。
- 接下来我们主要来进行描述 Diff children 的流程。
- 因为是第一次渲染,所以我们都没有老的 vnode 也就没有所谓的是否具有相同 key 或者相同 type 的新老 vnode。
- 直接进入到 diff(newChild, oldChild) 这一阶段。
- 判断我们的 vnode 的 type 是一个 component, 并且是一个新的组件,这个时候我们创建新组件,并且执行对应的生命周期,然后调用我们的 render 函数。
- 因为 render 函数的返回值其实依然是一个 vnode,所以会继续流转到 diff(newChild, oldChild) 这一个阶段,直到判断 type 是 DOM node 时,会执行 DOM 的操作变化。
4.2 执行 setState
- 我们可以从流程图中看到,其实 setState 本质上的操作,会将它所在的 vnode 送入到 diff(newChild, oldChild) 中,而 newChild 和 oldChild 的主要区别其实就是 state 的变化。
- 因为 Clock 组件是一个 component 类型的 vnode,所以我们会继续判断它是不是新组件,很显然已经不是了,于是会执行对应的生命周期,如果没有 shouldComponentUpdate 生命周期函数或者返回了 true,那么我们会继续执行 render 函数,不然我们会停止组件的渲染。
- 这个时候 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 内部的数据结构。
图 13
5.1.1 useMemo
useMemo 的作用主要是:我们可以记住计算的结果,并且仅在其中一个依赖项发生更改时才重新计算它。
图 14
当我们每次进行渲染的时候,都会去执行 expensive 这个非常耗费性能的计算,这样下来,会造成一定的性能的损耗,那我们可以使用 useMemo 来进行优化。这样如果 expensive 依赖的值没有变化,就不需要执行这个函数,而是取它的缓存值。
图 15
其实它的内部原理很简单,我们可以通过下图通过它的源码进行分析。
图 16
本质上就是进行前后比较它的依赖的数据是否发生了改变,如果发生了变化,则调用传入的 callback 函数,否则就直接返回原来的内部的 state 的值。
5.1.2 useCallback
作用:它可用于确保只要没有依赖项发生更改,返回的函数将始终保持引用相等。
图 17
用上图的例子来说明它的作用就是,当它的依赖项 a、b 未发生变化的时候,onClick 这个函数始终是相同的。
实际上 useCallback(fn, deps)
和 useMemo(() => fn, deps)
是等价的,因为 useCallback 就是用 useMemo 来实现的,只是它返回的是一个没有进行调用的 callback,所以上图的代码可以等价于:
图 18
即当 a、b 不发生变化的时候,() => console.log(a, b)
也就不会发生变化。
5.1.3 useRef
作用:获得对功能组件内部的 DOM 节点的引用。 它的工作原理类似于 createRef。
图 19
它的原理也是十分的简单。
图 20
本质上就是初始化的时候创建一个内部状态为 {current:initialValue} 的组件,且不依赖任何数据,需要则通过手动赋值修改。
5.2 ReducerHook
ReducerHook 的主要作用是用来做一些性能优化的 Hook 集合。并且在 ReducerHook 内部,有一个通用的数据结构,用来表示该 Hook 内部的数据结构。
图 21
5.2.1 useReducer
useReducer 的使用方式和 Redux 非常像。
图 22
对于使用过 Redux 的同学来说,这样的用法应该会很容易接受和熟悉。
我们可以通过源码来进行分析它的实现原理。
图 23
更新 state 就是调用 dispatch,也就是通过 reducer(preState, action) 计算出下次的 state 赋值给 _value。然后调用组件的 setState 方法进行组件的 Diff 和相应更新操作。
5.2.2 useState
useState 大概是平时在开发过程中最常使用的 Hook,它类似于 class 组件中的 state 状态值。
图 24
它的原理很简单,就是利用 useReducer 来进行实现的,也就是 useState 其实只是传特定 reducer 的 useReducer 一种实现。
图 25
5.3 EffectHook
“副作用”一词在很多参与过 React 相关的项目开发的同学来说,肯定不会陌生,无论是要从 API 获取某些数据还是要对文档触发效果,基本上可以发现 EffectHook 几乎可以满足所有需求。 这也是 Hooks API 的主要优点之一,它使你的思维重塑了对效果的思考,而不是对组件生命周期的思考。
在整个 EffectHook 中,都贯穿了下面这样的通用数据结构。
图 26
5.3.1 useEffect 和 useLayoutEffect
这两个 Hook 的用法完全一致,都是在 render 过程中执行一些副作用的操作,可来实现以往 class 组件中一些生命周期的操作。区别在于, useEffect 的 callback 执行是在本次渲染结束之后,下次渲染之前执行。useLayoutEffect 则是在本次会在浏览器 layout 之后,painting 之前执行,是同步的。
图 27
使用的方式和前面的 Hook 的使用方式基本上一致,传递一个回调函数和一个依赖数组,数组的依赖参数变化时,重新执行回调。
图 28
它们的实现机制,稍微有些复杂,我们先看源码。
图 28
从代码上来看,它们的实现几乎一样,唯一的区别是进入的回调分别是 _renderCallbacks、_pendingEffects,从而达到了不同时机下进行渲染,这一块的具体逻辑,大家可以参考这篇文章了解更多的细节。
整体来看,Preact 的 Hook 模块的代码实现虽然内不多,但是是却体现出了它的精炼以及 Preact 优秀的架构。
结束语
最后希望大家能够通过本文,对 Preact 的整体工作机制有了更加深入的理解,有时间的同学也可以自己尝试阅读 Preact 的源码并结合本文,我相信阅读之后一定能够对 React 的理解更上一层楼。再次感谢大家!
欢迎关注“玄说-前端”微信公众号
福利:
扫描下方二维码,加”助理“好友,回复”加群“,进入“玄说-前端” 微信群,一起讨论前端技术,更有大厂内推机会。