You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
如果组件中不存在render函数,说明该函数是PFC(Pure Function Component)类型,即是纯函数组件。这时直接调用函数Component创建实例,实例的constructor属性设置为传入的函数。由于实例中不存在render函数,则将doRender函数作为实例的render属性,doRender函数会将Ctor的返回的虚拟dom作为结果返回。
前言
首先欢迎大家关注我的掘金账号和Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。
之前分享过几篇关于React的文章:
其实我在阅读React源码的时候,真的非常痛苦。React的代码及其复杂、庞大,阅读起来挑战非常大,但是这却又挡不住我们的React的原理的好奇。前段时间有人就安利过Preact,千行代码就基本实现了React的绝大部分功能,相比于React动辄几万行的代码,Preact显得别样的简洁,这也就为了我们学习React开辟了另一条路。本系列文章将重点分析类似于React的这类框架是如何实现的,欢迎大家关注和讨论。如有不准确的地方,欢迎大家指正。
在前两篇文章
我们分别了解了Preact中元素创建以及
diff
算法,其中就讲到了组件相关一部分内容。对于一个类React库,组件(Component
)可能是最需要着重分析的部分,因为编写类React程序的过程中,我们几乎都是在写一个个组件(Component
)并将其组合起来形成我们所需要的应用。下面我们就从头开始了解一下Preact中的组件是怎么实现的。组件渲染
首先我们来了解组件返回的虚拟dom是怎么渲染为真实dom,来看一下Preact的组件是如何构造的:
可能我们会想当然地认为组件
Component
的构造函数定义将会及其复杂,事实上恰恰相反,Preact的组件定义代码极少。组件的实例属性仅仅有四个:_dirty
: 用来表示存在脏数据(即数据与存在的对应渲染不一致),例如多次在组件实例调用setState
,使得_dirty
为true
,但因为该属性的存在,只会使得组件仅有一次才会被放入更新队列。context
: 组件的context
属性props
: 组件的props
属性state
: 组件的state
属性通过
extends
方法(原理类似于ES6中的Object.assign
或者Underscore.js中的_.extends
),我们给组件的构造函数的原型中创建一下几个方法:setState
: 与React的setState
相同,用来更新组件的state
forceUpdate
: 与React的forceUpdate
相同,立刻同步重新渲染组件render
: 返回组件的渲染内容的虚拟dom,此处函数体为空所以当我们编写组件(
![](https://camo.githubusercontent.com/4363607c62f375b66e193db28228e8e400449fedbd9a0031e6175d184b57fda8/687474703a2f2f6f6a323332757a71662e626b742e636c6f7564646e2e636f6d2f7072656163742e706e67)
Component
)类继承preact.Component
时,也就仅仅只能继承上述的方法和属性,这样所以对于用户而言,不仅提供了及其简洁的API以供使用,而且最重要的是我们将组件内部的逻辑封装起来,与用户相隔离,避免用户无意间修改了组件的内部实现,造成不必要的错误。对于阅读过从Preact了解一个类React的框架是怎么实现的(二): 元素diff的同学应该还记的
preact
所提供的render
函数调用了内部的diff
函数,而diff
实际会调用idiff
函数(更详细的可以阅读第二篇文章):从上面的图中可以看到,在
idiff
函数内部中在开始如果vnode.nodeName
是函数(function
)类型,则会调用函数buildComponentFromVNode
:函数
buildComponentFromVNode
的作用就是将表示组件的虚拟dom(VNode)转化成真实dom。参数分别是:dom
: 组件对应的真实dom节点vnode
: 组件的虚拟dom节点context
: 组件的中的context
属性mountAll
: 表示组件的内容需要重新渲染而不是基于上一次渲染内容进行修改。为了方便分析,我们将函数分解成几个部分,依次分析:
dom
是组件对应的真实dom节点(如果未渲染,则为undefined
),在dom
节点中的_component
属性是组件实例的缓存。isDirectOwner
用来指示用来标识原dom
节点对应的组件类型是否与当前虚拟dom的组件类型相同。然后使用函数getNodeProps
来获取虚拟dom节点的属性值。函数
getNodeProps
的逻辑并不复杂,将vnode
的attributes
和chidlren
的属性赋值到props
,然后如果存在组件中存在defaultProps
的话,将defaultProps
存在的属性并且对应props
不存在的属性赋值进入了props
中,并将props
返回。dom
节点对应的组件类型与当前虚拟dom对应的组件类型不一致时,会向上在父组件中查找到与虚拟dom节点类型相同的组件实例(但也有可能不存在)。其实这个只是针对于高阶组件,假设有高阶组件的顺序:上面
HOC
代表高阶组件,返回组件component
,然后组件component
渲染DOM元素。在Preact,这种高阶组件与返回的子组件之间存在属性标识,即HOC
的组件实例中的_component
指向compoent
的组件实例而组件component
实例的_parentComponent
属性指向HOC
实例。我们知道,DOM中的属性_component
指向的是对应的组件实例,需要注意的是在上面的例子中DOM对应的_component
指向的是HOC
实例,而不是component
实例。如果理解了上面的部分,就能理解为什么会存在这个循环了,其目的就是为了找到最开始渲染该DOM的高阶组件(防止某些情况下dom
对应的_component
属性指代的实例被修改),然后再判断该高阶组件是否与当前的vnode类型一致。第三段代码: 如果存在当前虚拟dom对应的组件实例存在,则直接调用函数
setComponentProps
,相当于基于组件的实例进行修改渲染,然后组件实例中的base
属性即为最新的dom
节点。第四段代码: 我们先不具体关心某个函数的具体实现细节,只关注代码逻辑。首先如果之前的dom节点对应存在组件,并且虚拟dom对应的组件类型与其不相同时,则卸载之前的组件(
unmountComponent
)。接着我们通过调用函数createComponent
创建当前虚拟dom对应的组件实例,然后调用函数setComponentProps
去创建组件实例的dom
节点,最后如果当前的dom
与之前的dom
元素不相同时,将之前的dom
回收(recollectNodeTree
函数在diff的文章中已经介绍)。其实如果之前就阅读过Preact的diff算法的同学来说,其实整个组件大致渲染的流程我们已经清楚了,但是如果想要更深层次的了解其中的细节我们必须去深究函数
createComponent
与setComponentProps
的内部细节。createComponent
关于函数
createComponent
,我们看一下component-recycler.js
文件:变量
components
的主要作用就是为了能重用组件渲染的内容而设置的共享池(Share Pool),通过函数collectComponent
就可以实现回收一个组件以供以后重复利用。在函数collectComponent
中通过组件名(component.constructor.name
)分类将可重用的组件缓存在缓存池中。函数
createComponent
主要作用就是创建组件实例。参数props
与context
分别对应的是组件的中属性和context
(与React一致),而Ctor
组件则是需要创建的组件类型(函数或者是类)。我们知道如果我们的组件定义用ES6定义如下:我们知道
class
仅仅只是一个语法糖,上面的代码使用ES5去实现相当于:如果你对ES5中的
Object.create
也不熟悉的话,我简要的介绍一下,Object.create
的作用就是实现原型继承(Prototypal Inheritance)来实现基于已有对象创建新对象。Object.create
的第一个参数就是所要继承的原型对象,第二个参数就是新对象定义额外属性的对象(类似于Object.defineProperty
的参数),如果要我自己实现一个简单的Object.create
函数我们可以这样写:现在你肯定知道了如果你的组件继承了Preact中的
Component
的话,在原型中一定存在render
方法,这时候通过new
创建Ctor
的实例inst
(实例中已经含有了你自定义的render
函数),但是如果没有给父级构造函数super
传入props
和context
,那么inst
中的props
和context
的属性为undefined
,通过强制调用Component.call(inst, props, context)
可以给inst
中props
、context
进行初始化赋值。如果组件中不存在
render
函数,说明该函数是PFC(Pure Function Component)类型,即是纯函数组件。这时直接调用函数Component
创建实例,实例的constructor
属性设置为传入的函数。由于实例中不存在render
函数,则将doRender
函数作为实例的render
属性,doRender
函数会将Ctor
的返回的虚拟dom作为结果返回。然后我们从组件回收的共享池中那拿到同类型组件的实例,从其中取出该实例之前渲染的实例(
nextBase
),然后将其赋值到我们的新创建组件实例的nextBase
属性上,其目的就是为了能基于此DOM元素进行渲染,以更少的代价进行相关的渲染。setComponentProps
函数
setComponentProps
的主要作用就是为组件实例设置属性(props),其中props
通常来源于JSX中的属性(attributes
)。函数的参数component
、props
、context
与mountAll
的含义从名字就可以看出来,值得注意地是参数opts
,代表的是不同的刷新模式:NO_RENDER
: 不进行渲染SYNC_RENDER
: 同步渲染FORCE_RENDER
: 强制刷新渲染ASYNC_RENDER
: 异步渲染首先如果组件
component
中_disable
属性为true
时则直接退出,否则将属性_disable
置为true
,其目的相当于一个锁,保证修改过程的原子性。如果传入组件的属性props
中存在ref
与key
,则将其分别缓存在组件的__ref
与__key
,并将其从props
将其删除。组件实例中的
base
中存放的是之前组件实例对应的真实dom节点,如果不存在该属性,说明是该组件的初次渲染,如果组件中定义了生命周期函数(钩子函数)componentWillMount
,则在此处执行。如果不是首次执行,如果存在生命周期函数componentWillReceiveProps
,则需要将最新的props
与context
作为参数调用componentWillReceiveProps
。然后分别将当前的属性context
与props
缓存在组件的preContext
与prevProps
属性中,并将context
与props
属性更新为最新的context
与props
。最后将组件的_disable
属性置回false
。如果组件更新的模式为
NO_RENDER
,则不需要进行渲染。如果是同步渲染(SYNC_RENDER
)或者是首次渲染(base
属性为空),则执行函数renderComponent
,其余情况下(例如setState
触发的异步渲染ASYNC_RENDER
)均执行函数enqueueRender
(enqueueRender
函数将在setState
处分析)。在函数的最后,如果存在ref
函数,则将组件实例作为参数调用ref
函数。在这里我们可以显然可以看出在Preact中是不支持React的中字符串类型的ref
属性,不过这个也并不重要,因为React本身也不推荐使用字符串类型的ref
属性,并表示可能会在将来版本中废除这一属性。接下来我们还需要了解
renderComponent
函数(非常冗长)与enqueueRender
函数的作用:renderComponent
为了方便阅读,我们将代码分成了八个部分,不过为了更方便的阅读代码,我们首先看一下函数开始处的变量声明:
所要渲染的
component
实例中的props
、context
、state
属性表示的是最新的所要渲染的组件实例属性。而对应的preProps
、preContext
、preState
代表的是渲染之前上一个状态组件实例属性。变量isUpdate
代表的是当前是处于组件更新的过程还是组件渲染的过程(mount),我们通过之前组件实例是否对应存在真实DOM节点来判断,如果存在则认为是更新的过程,否则认为是渲染(mount)过程。nextBase
表示可以基于此DOM元素进行修改(可能来源于上一次渲染或者是回收之前同类型的组件实例),以寻求最小的渲染代价。组件实例中的
_component
属性表示的组件的子组件,仅仅只有当组件返回的是组件时(也就是当前组件为高阶组件),才会存在。变量skip
用来标志是否需要跳过更新的过程(例如: 生命周期函数shouldComponentUpdate
返回false
)。第一段代码: 如果存在
component.base
存在,说明该组件之前对应的真实dom元素,说明组件处于更新的过程。要将props
、state
、context
替换成之前的previousProps
、previousState
、previousContext
,这是因为在生命周期函数shouldComponentUpdate
、componentWillUpdate
中的this.props
、this.state
、this.context
仍然是更新前的状态。如果不是强制刷新(FORCE_RENDER
)并存在生命周期函数shouldComponentUpdate
,则以最新的props
、state
、context
作为参数执行shouldComponentUpdate
,如果返回的结果为false
表明要跳过此次的刷新过程,即置标志位skip
为true。否则如果生命周期shouldComponentUpdate
返回的不是false
(说明如果不返回值或者其他非false
的值,都会执行更新),则查看生命周期函数componentWillUpdate
是否存在,存在则执行。最后则将组件实例的props
、state
、context
替换成最新的状态,并置空组件实例中的prevProps
、prevState
、prevContext
的属性,以及将_dirty
属性置为false
。需要注意的是只有_dirty
为false
才会被放入更新队列,然后_dirty
会被置为true
,这样组件实例就不会被多次放入更新队列。如果没有跳过更新的过程(即
skip
为false
),则执行到第二段代码。首先执行组件实例的render
函数(相比于React中的render
函数,Preact中的render
函数执行时传入了参数props
、state
、context
),执行render
函数的返回值rendered
则是组件实例对应的虚拟dom元素(VNode)。如果组件存在函数getChildContext
,则生成当前需要传递给子组件的context
。我们从代码extend(extend({}, context), component.getChildContext())
可以看出,如果父组件存在某个context
属性并且当前组件实例中getChildContext
函数返回的context
也存在相同的属性时,那么当前组件实例getChildContext
返回的context
中的属性会覆盖父组件的context
中的相同属性。接下来到第三段代码,
childComponent
是组件实例render
函数返回的虚拟dom的类型(rendered.nodeName
),如果childComponent
的类型为函数,说明该组件为高阶组件(High Order Component),如果你不了解高阶组件,可以戳这篇文章。如果是高阶组件的情况下,首先通过getNodeProps
函数获得虚拟dom中子组件的属性。如果组件存在子组件的实例并且子组件实例的构造函数与当前组件返回的子组件虚拟dom类型相同(inst.constructor===childComponent
)而且前后的key
值相同时(childProps.key==inst.__key
),仅需要以同步渲染(SYNC_RENDER
)的模式递归调用函数setComponentProps
来更新子组件的属性props
。之所以这样是因为如果满足前面的条件说明,前后两次渲染的子组件对应的实例不发生改变,仅改变传入子组件的参数(props)。这时子组件仅需要根据当前最新的props
对应渲染真实dom即可。否则如果之前的子组件实例的构造函数与当前组件返回的子组件虚拟dom类型不相同时或者根据key
值标定两个组件实例不相同时,则需要渲染的新的子组件,不仅需要调用createComponent
创建子组件的实例(createComponent(childComponent, childProps, context)
)并为当前的子组件和组件设置相关关系(即_component
、_parentComponent
属性)而且用toUnmount
指示待卸载的组件实例。然后通过调用setComponentProps
来设置组件的ref
和key
等,以及调用组件的相关生命周期函数(例如:componentWillMount
),需要注意的是这里的调用模式是NO_RENDER
,不会进行渲染。而在下一句调用renderComponent(inst, SYNC_RENDER, mountAll, true)
去同步地渲染子组件。所以我们就要注意为什么在调用函数setComponentProps
时没有采用SYNC_RENDER
模式,SYNC_RENDER
模式也本身就会触发renderComponent
去渲染组件,其原因就是为了在调用renerComponent
赋予isChild
值为true
,这个标志量的作用我们后面可以看到。调用完renderComponent
之后,inst.base
中已经是我们子组件渲染的真实dom节点。在第四段代码中,处理的是当前组件需要渲染的虚拟dom类型是非组件类型(即普通的DOM元素)。首先赋值
cbase = initialBase
,我们知道initialBase
来自于initialBase = isUpdate || nextBase
,也就是说如果当前是更新的模式,则initialBase
等于isUpdate
,即为上次组件渲染的内容。否则,如果组件实例存在nextBase
(从回收池得到的DOM结构),也可以基于其进行修改,总的目的是为了以更少的代价去渲染。如果之前的组件渲染的是函数类型的元素(即组件),但现在却渲染的是非函数类型的,赋值toUnmount = initialChildComponent
,用来存储之后需要卸载的组件,并且由于cbase
对应的是之前的组件的dom节点,因此就无法使用了,需要赋值cbase = null
以使得重新渲染。而component._component = null
目的就是切断之前组件间的父子关系,毕竟现在返回的都不是组件。如果是同步渲染(SYNC_RENDER
),则会通过调用idiff
函数去渲染组件返回的虚拟dom(详情见第二篇文章diff)。我们来看看调用idiff
函数的形参和实参:cbase
对应的是diff
的dom
参数,表示用来渲染的VNode之前的真实dom。可以看到如果之前是组件类型,那么cbase
值为undefined
,我们就需要重新开始渲染。否则我们就可以在之前的渲染基础上更新以寻求最小的更新代价。rendered
对应diff
中的vnode
参数,表示需要渲染的虚拟dom节点。context
对应diff
中的context
参数,表示组件的context
属性。mountAll || !isUpdate
对应的是diff
中的mountAll
参数,表示是否是重新渲染DOM节点而不是基于之前的DOM修改,!isUpdate
表示的就是非更新状态。initialBase && initialBase.parentNode
对应的是diff
中的parent
参数,表示的是当前渲染节点的父级节点。diff
函数的第六个参数为componentRoot
,实参为true
表示的是当前diff
是以组件中render
函数的渲染内容的形式调用,也可以说当前的渲染内容是属于组件类型的。我们知道
idiff
函数返回的是虚拟dom对应渲染后的真实dom节点,所以变量base
存储的就是本次组件渲染的真实DOM元素。baseParent.replaceChild(base, initialBase)
),如果没有需要卸载的组件实例,则调用函数recollectNodeTree
回收该DOM节点。否则如果之前组件渲染的是函数类型的元素,但需要废弃,则调用函数unmountComponent
进行卸载(调用相关的生命周期函数)。来看
unmountComponent
函数的作用,首先将函数实例中的_disable
置为true
表示组件禁用,如果组件存在生命周期函数componentWillUnmount
进行调用。然后递归调用函数unmountComponent
递归卸载组件。如果之前组件渲染的DOM节点,并且最外层节点存在ref
函数,则以参数null
执行(和React保持一致,ref
函数会执行两次,第一次是mount
会以DOM元素或者组件实例回调,第二次是unmount
会回调null
表示卸载)。然后将DOM元素存入nextBase
用以回收。调用removeNode
函数作用是将base
节点的父节点脱离出来。函数removeChildren
的目的是用递归遍历所有的子DOM元素,回收节点(之前的文章已经介绍过,其中就涉及到子元素的ref
调用)。最后如果组件本身存在ref
属性,则直接以null
为参数调用。component.base = base
用来将当前的组件渲染的dom元素存储在组件实例的base
属性中。下面的代码我们先举个例子,假如有如下的结构:其中
HOC
代表高阶组件,component
代表自定义组件。你会发现HOC1
、HOC2
与compoent
的base
属性都指向最后的DOM元素,而DOM元素的中的_component
是指向HOC1
的组价实例的。看懂了这个你就能明白为什么会存在下面这个循环语句,其目的就是为了给父组件赋值正确的base
属性以及为DOM节点的_component
属性赋值正确的组件实例。在第七段代码中,如果是非更新模式,则需要将当前组件存入
mounts
(unshift
方法存入,pop
方法取出,实质上是相当于队列的方式,并且子组件先于父组件存储队列mounts
,因此可以保证正确的调用顺序),方便在后期调用组件对应类似于componentDidMount
生命周期函数和其他的操作。如果没有跳过更新过程(skip === false
),则在此时调用组件对应的生命周期函数componentDidUpdate
。然后如果存在组件存在_renderCallbacks
属性(存储对应的setState
的回调函数,因为setState
函数实质也是通过renderComponent
实现的),则在此处将其弹出并执行。在第八段代码中,如果
diffLevel
为0
并且isChild
为false
时,对应执行flushMounts
函数其实
flushMounts
也是非常的简单,就是将队列mounts
中取出组件实例,然后如果存在生命周期函数componentDidMount
,则对应执行。其实如果阅读了之前diff的文章的同学应该记得在
diff
函数中有:上面有两处调用函数
flushMounts
,一个是在renderComponent
内部①,一个是在diff
函数②。那么在什么情况下触发上下两段代码呢?首先componentRoot
表示的是当前diff
是不是以组件中渲染内容的形式调用(比如组件中render
函数返回HTML类型的VNode),那么preact.render
函数调用时肯定componentRoot
是false
,diffLevel
表示渲染的层次,diffLevel
回减到0说明已经要结束diff的调用,所以在使用preact.render
渲染的最后肯定会使用上面的代码去调用函数flushMounts
。但是如果其中某个已经渲染的组件通过setState
或者forceUpdate
的方式导致了重新渲染并且致使子组件创建了新的实例(比如前后两次返回了不同的组件类型),这时,就会采用第一种方式在调用flushMounts
函数。setState
对于Preact的组件而言,
state
是及其重要的部分。其中涉及到的API为setState
,定义在函数Component
的原型中,这样所有的继承于Component
的自定义组件实例都可以引用到函数setState
。首先我们看到
setState
接受两个参数: 新的state
以及state
更新后的回调函数,其中state
既可以是对象类型的部分对象,也可以是函数类型。首先使用函数extend
生成当前state
的拷贝prevState
,存储之前的state
的状态。然后如果state
类型为函数时,将函数的生成值覆盖进入state
,否则直接将新的state
覆盖进入state
,此时this.state
已经成为了新的state
。如果setState
存在第二个参数callback
,则将其存入实例属性_renderCallbacks
(如果不存在_renderCallbacks
属性,则需要初始化)。然后执行函数enqueueRender
。enqueueRender
接下来我们看一下神奇的
enqueueRender
函数:我们可以看到当组件实例中的
_dirty
属性为false
时,会将属性_dirty
置为true
,并将其放入items
中。当更新队列第一次被items
时,则延迟异步执行函数rerender
。这个延迟异步函数在支持Promise
的浏览器中,会使用Promise.resolve().then
,否则会使用setTimeout
。rerender
函数就是将items
中待更新的组件,逐个取出,并对其执行renderComponent
。其实renderComponent
的opt
参数不传入ASYNC_RENDER
,而是传入undefined
两者之间并无区别。唯一要注意的是:我们渲染过程一定是要执行
diff
,那就说明initialBase
一定是个非假值,这也是可以保证的。其实因为之前组件已经渲染过,所以是可以保证
isUpdate
一定为非假值,因为isUpdate = component.base
并且component.base
是一定存在的并且为上次渲染的内容。大家可能会担心如果上次组件render
函数返回的是null
该怎么办?其实阅读过第二篇文章的同学应该知道在idiff
函数内部即使
render
返回的是null
也会被当做一个空文本去控制,对应会渲染成DOM中的Text
类型。forceUpdate
执行
forceUpdate
所需要做的就是将回调函数放入组件实例中的_renderCallbacks
属性并调用函数renderComponent
强制刷新当前的组件。需要注意的是,我们渲染的模式是FORCE_RENDER
强制刷新,与其他的模式到的区别就是不需要经过生命周期函数shouldComponentUpdate
的判断,直接进行刷新。结语
至此我们已经看完了Preact中的组件相关的代码,可能并没有对每一个场景都进行讲解,但是我也尽量尝试去覆盖所有相关的部分。代码相对比较长,看起来也经常令人头疼,有时候为了搞清楚某个变量的部分不得不数次回顾。但是你会发现你多次地、反复性的阅读、仔细地推敲,代码的含义会逐渐清晰。书读百遍其义自见,其实对代码来说也是一样的。文章若有不正确的地方,欢迎指出,共同学习。
The text was updated successfully, but these errors were encountered: