/**
* 递归地回收(或者卸载)节点及其后代节点
* @param node
* @param unmountOnly 如果为`true`,仅仅触发卸载的生命周期,跳过删除
*/
function recollectNodeTree(node, unmountOnly) {
let component = node._component;
if (component) {
// 如果该节点属于某个组件,卸载该组件(最终在这里递归),主要包括组件的回收和相依卸载生命周期的调用
unmountComponent(component);
}
else {
// 如果节点含有ref函数,则执行ref函数,参数为null(这里是React的规范,用于取消设置引用)
// 确实在React如果设置了ref的话,在卸载的时候,也会被回调,得到的参数是null
if (node[ATTR_KEY]!=null && node[ATTR_KEY].ref) node[ATTR_KEY].ref(null);
if (unmountOnly===false || node[ATTR_KEY]==null) {
//要做的无非是从父节点将该子节点删除
removeNode(node);
}
//递归删除子节点
removeChildren(node);
}
}
/**
* 回收/卸载所有的子元素
* 我们在这里使用了.lastChild而不是使用.firstChild,是因为访问节点的代价更低。
*/
export function removeChildren(node) {
node = node.lastChild;
while (node) {
let next = node.previousSibling;
recollectNodeTree(node, true);
node = next;
}
}
/** 从父节点删除该节点
* @param {Element} node 待删除的节点
*/
function removeNode(node) {
let parentNode = node.parentNode;
if (parentNode) parentNode.removeChild(node);
}
我们看到在函数recollectNodeTree中,如果dom元素属于某个组件,首先递归卸载组件(不是本次讲述的重点,主要包括组件的回收和相依卸载生命周期的调用)。否则,只需要先判别该dom节点中是否被在jsx中存在ref函数(也是缓存在__preactattr_属性中),因为存在ref函数时,我们在组件卸载时以null参数作为回调(React文档做了相应的规定,详情见Refs and the DOM)。recollectNodeTree中第二个参数unmountOnly,表示仅仅触发卸载的生命周期,跳过删除的过程,如果unmountOnly为false或者dom中的ATTR_KEY属性不存在(说明这个属性不是preact所渲染的,否则肯定会存在该属性),则直接将其从父节点删除。最后递归删除子节点,我们可以看到递归删除子元素的过程是从右到左删除的(首先删除的lastChild元素),主要考虑到的是从后访问会有性能的优势。我们在这里(block-1)调用函数recollectNodeTree的第二个参数是true,原因是在调用之前我们已经将其在父元素中进行替换,所以是不需要进行调用的函数removeNode再进行删除该节点的。
if (keyedLen) {
for (let i in keyed) if (keyed[i]!==undefined) recollectNodeTree(keyed[i], false);
}
// 移除没有父节点的不带有key值的子元素
while (min<=childrenLen) {
if ((child = children[childrenLen--])!==undefined) recollectNodeTree(child, false);
}
前言
首先欢迎大家关注我的掘金账号和Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。 之前分享过几篇关于React的文章:
其实我在阅读React源码的时候,真的非常痛苦。React的代码及其复杂、庞大,阅读起来挑战非常大,但是这却又挡不住我们的React的原理的好奇。前段时间有人就安利过Preact,千行代码就基本实现了React的绝大部分功能,相比于React动辄几万行的代码,Preact显得别样的简洁,这也就为了我们学习React开辟了另一条路。本系列文章将重点分析类似于React的这类框架是如何实现的,欢迎大家关注和讨论。如有不准确的地方,欢迎大家指正。 在上篇文章从preact了解一个类React的框架是怎么实现的(一): 元素创建我们了解了我们平时所书写的JSX是怎样转化成Preact中的虚拟DOM结构的,接下来我们就要了解一下这些虚拟DOM节点是如何渲染成真实的DOM节点的以及虚拟DOM节点的改变如何映射到真实DOM节点的改变(也就是diff算法的过程)。这篇文章相比第一篇会比较冗长和枯燥,为了能集中分析diff过程,我们只关注dom元素,暂时不去考虑组件。
渲染与diff
render
函数我们知道在React中渲染是并不是由React完成的,而是由ReactDOM中的
render
函数去实现的。其实在最早的版本中,render
函数也是属于React的,只不过后来React的开发者想实现一个于平台无关的库(其目的也是为了React Native服务的),因此将Web中渲染的部分独立成ReactDOM库。Preact作为一个极度精简的库,render
函数是属于Preact本身的。Preact的render
函数与ReactDOM的render
函数也是有有所区别的:ReactDOM.render
接受三个参数,element
是需要渲染的React元素,而container
挂载点,即React元素将被渲染进container
中,第三个参数callback
是可选的,当组件被渲染或者更新的时候会被调用。ReactDOM.render
会返回渲染组元素的真实DOM节点。如果之前container
中含有dom节点,则渲染时会将之前的所有节点清除。例如:html:
javascript:
最终的显示效果为:
而Preact的
render
函数为:Preact.render
与ReactDOM.render
的前两个参数代表的意义相同,区域在于最后一个,Preact.render
可选的第三个参数merge
,要求必须是第二个参数的子元素,是指会被替换的根节点,否则,如果没有这个参数,Preact 默认追加,而不是像React进行替换。 例如不存在第三个参数的情况下:html:
javascript:
最终的显示效果为:
如果调用函数有第三个参数:
javascript:
显示效果是:
其实在Preact中无论是初次渲染还是之后虚拟DOM改变导致的UI更新最终调用的都是
diff
函数,这也是非常合理的,毕竟我们可以将首次渲染当做是diff
过程中用现有的虚拟dom去与空的真实dom基础上进行更新的过程。下面我们首先给出整个diff
过程的大致流程图,我们可以对照流程图对代码进行分析: 首先从render
函数入手,render
函数调用的就是diff
函数:我们可以看到Preact中的
render
调用了diff
函数,而diff
定义在vdom/diff
中:这部分的函数内容比较庞杂,很难做到面面俱到,我会在代码中做相关的注释。
diff
函数主要负责就是将当前的虚拟node节点映射到真实的DOM节点中。参数如下:vnode
: 不用说,就是我们需要渲染的虚拟dom节点parent
: 就是你要将虚拟dom挂载的父节点dom
: 这里的dom其实就是当前的vnode所对应的之前未更新的真实dom。那么就有两种可能: 第一就是null
或者是上面例子的contaienr
(就是render
函数对应的第三个参数),其本质都是首次渲染,第二种就是vnode的对应的未更新的真实dom,那么对应的就是渲染刷新界面。context
: 组件相关,暂时可以不考虑,对应React中的context
。mountAll
: 组件相关,暂时可以不考虑componentRoot
: 组件相关,暂时可以不考虑vnode
对应的就是一个递归的结构,那么不用想diff
函数肯定也是递归的。我们首先看一下函数初始的几个变量:diffLevel
:用来记录当前渲染的层数(递归的深度),其实在代码中并没有在进入每层递归的时候都增加并且退出递归的时候减小。isSvgMode
:用来指代当前的渲染是否内SVG元素的内部或者我们是否在diff一个SVG元素(SVG元素需要特殊处理)。hydrating
: 这个变量是我一直所困惑的,我还专门查了一下,hydrating
指的是保湿、吸水 的意思。hydrating = dom != null && !(ATTR_KEY in dom);
(ATTR_KEY
对应常量__preactattr_
,preact会将props等缓存信息存储在dom的__preactattr_
属性中),作者给的是下面的注释:也就是说
hydrating
是指当前的diff
的元素没有缓存但是对应的dom元素必须存在。那么什么时候才会出现dom节点中没有存储缓存?只有当前的dom节点并不是由Preact所创建并渲染的才会使得hydrating
为true。idiff
函数就是diff
算法的内部实现,相对来说代码会比较复杂,idiff
会返回虚拟dom对应创建的真实dom节点。下面的代码是是向父级元素有选择性添加创建的dom节点,之所以这么做,主要是有可能之前该节点就没有渲染过,所以需要将新创建的dom节点添加到父级dom。但是如果仅仅只是修改了之前dom中的某一个属性(比如样式),那么其实是不需要添加的,因为该dom节点已经存在于父级dom。 后面的内容,一方面结束递归之后,回置diffLevel
(diffLevel
此时应该为0,表明此时要退出diff
函数),退出diff
前,将hydrating
置为false
,相当于一个复位的功能。下面的flushMounts
函数是组件相关,在这里我们只需要知道它要做的就是去执行所有刚才安装组件的componentDidMount
生命周期函数。 下面让我们看看idiff
的实现(代码已经分块,具体见注释),代码比较长,可以先大致浏览一下,做到心里有数,下面会逐块分析,可以对照流程图看:idiff
函数所接受的参数与diff
是完全相同的,但是二者也是有所区别的。diff
在渲染过程(或者更新过程)中接受的vnode
就是整个应用的虚拟dom(或者组件的虚拟DOM)。但是idiff
的调用是递归的,因此dom
和vnode
在开始时与diff
函数相等,但是在之后递归的过程中,就对应的是整个应用的部分。变量
prevSvgMode
用来存储之前的isSvgMode
,目的就是在退出这一次递归调用时恢复到调用前的值。然后如果vnode是null
或者布尔类型,都按照空字符去处理。接下的渲染是整对于字符串(sting
或者number
类型),主要分为两部分: 更新或者创建元素。如果dom本身存在并且就是一个文本节点,那就只需要将其中的值更新为当前的值即可。否则创建一个新的文本节点,并且将其替换到父元素上,并回收之前的节点值。因为文本节点是没有什么需要缓存的属性值(文本的颜色等属性实际是存储的父级的元素中),所以直接将其ATTR_KEY
(实际值为__preactattr_
)赋值为true
,并返回新创建的元素。这段代码有两个需要注意的地方:为什么在赋值文本节点值时,需要首先进行一个判断?根据代码注释得知Firfox浏览器不会默认做等值比较(其他的浏览器例如Chrome即使直接赋值,如果相等也不会修改dom元素),所以人为的增加了比较的过程,目的就是为了防止文本节点每次都会被更新,这算是一个浏览器怪癖(quirk)。
回收dom节点的
recollectNodeTree
函数做了什么?看代码:我们看到在函数
recollectNodeTree
中,如果dom元素属于某个组件,首先递归卸载组件(不是本次讲述的重点,主要包括组件的回收和相依卸载生命周期的调用)。否则,只需要先判别该dom节点中是否被在jsx中存在ref
函数(也是缓存在__preactattr_
属性中),因为存在ref
函数时,我们在组件卸载时以null
参数作为回调(React文档做了相应的规定,详情见Refs and the DOM)。recollectNodeTree
中第二个参数unmountOnly
,表示仅仅触发卸载的生命周期,跳过删除的过程,如果unmountOnly
为false
或者dom中的ATTR_KEY
属性不存在(说明这个属性不是preact所渲染的,否则肯定会存在该属性),则直接将其从父节点删除。最后递归删除子节点,我们可以看到递归删除子元素的过程是从右到左删除的(首先删除的lastChild
元素),主要考虑到的是从后访问会有性能的优势。我们在这里(block-1)调用函数recollectNodeTree
的第二个参数是true
,原因是在调用之前我们已经将其在父元素中进行替换,所以是不需要进行调用的函数removeNode
再进行删除该节点的。第二块代码,主要是针对的组件的渲染,如果
vnode.nodeName
对应的是函数类型,表明要渲染的是一个组件,直接调用了函数buildComponentFromVNode
(组件不是本次叙述内容)。第三块代码,首先:
变量
isSvgMode
还是用来标记当前创建的元素是否是SVG元素。foreignObject
元素允许包含外来的XML命名空间,一个foreignObject
内部的任何SVG元素都不会被绘制,所以如果是vnodeName
为foreignObject
话,isSvgMode
会被置为false
(其实Svg对我来说也是比较生疏的内容,但是不影响我们分析整个渲染过程)。然后开始尝试创建dom元素,如果之前的dom为空(说明之前没有渲染)或者dom的名称与vnode.nodename不一致时,说明我们要创建新的元素,然后如果之前的dom节点中存在子元素,则将其全部移入新创建的元素中。如果之前的dom已经有父元素了,则将其替换成新的元素,最后回收该元素。 在判断节点dom类型与虚拟dom的vnodeName类型是否相同时使用了函数
isNamedNode
:如果节点是由Preact创建的(即由函数
createNode
创建的),其中dom节点中含有属性normalizedNodeName
(node.normalizedNodeName = nodeName
),则使用normalizedNodeName
去判断节点类型是否相等,否则直接采用dom节点中的nodeName
属性去判断。 到此为止渲染的当前虚拟dom的过程已经结束,接下来就是处理子元素的过程。然后我们看到,如果out是新创建的元素或者该元素不是由Preact创建的(即不存在属性
__preactattr_
),我们会初始化out
中的__preactattr_
属性中并将out元素(刚创建的dom元素)中属性attributes
缓存在out
元素的ATTR_KEY
(__preactattr_
)属性上。但是需要注意的是,比如某个节点的属性发生改变,比如name
由1
变成了2
,那么out属性中的缓存(__preactattr_
)也需要得到更新,但是更新的操作并不发生在这里,而是下面的diffAttributes
函数中。 接下来就是处理子元素只有一个文本节点的情况(其实这部分也可以没有,通过下一层的递归也能解决,这样做只不过是为了优化性能),比如处理下面的情形:进入单个节点的判断条件也是比较明确的,唯一需要注意的一点是,必须满足
hydrating
不为true
,因为我们知道当hydrating
为true
是说明当前的节点并不是由Preact渲染的,因此不能进行直接的优化,需要由下一层递归中创建新的文本元素。函数
diffAttributes
的主要作用就是将虚拟dom中attributes
更新到真实的dom中(后面详细讲)。最后重置变量isSvgMode
,并返回vnode所渲染的真实dom节点。 看完了函数idiff
,接下来要关心的就是,在idiff
中对虚拟dom的子元素调用的innerDiffNode
函数(代码依然很长,我们依然做分块,对照流程图看):首先看innerDiffNode函数的参数:
dom
:diff
的虚拟子元素的父元素对应的真实dom节点vchildren
:diff
的虚拟子元素context
: 类似于React中的context,组件使用mountAll
: 组件相关,暂时可以不考虑componentRoot
: 组件相关,暂时可以不考虑函数代码将近百行,为了方便阅读,我们将其分为四个部分(看代码注释):
我们所希望的
diff
的过程肯定是以最少的dom操作使得更改后的dom与虚拟dom相匹配,所以之前父节点的dom重用也是非常必要。len
是父级dom的子元素个数,首先对所有的子元素进行遍历,如果该元素是由Preact所渲染(也就是有props的缓存)并且含有key值(不考虑组件的情况下,我们暂时只看该元素props中是否有key值),我们将其存储在keyed
中,否则如果该元素也是Preact所渲染(有props的缓存)或者满足条件(child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)
时,我们将其分配到children
中。这样我们其实就将子元素划分为两类,一类是带有key值的子元素,一类是没有key的子元素。关于条件
(child.splitText!==undefined ? (isHydrating ? child.nodeValue.trim() : true) : isHydrating)
我们分析一下,我们知道hydrating
为true
时表示的是dom元素不是Preact创建的,我们知道调用函数innerDiffNode
时,isHydrating
的值是hydrating || props.dangerouslySetInnerHTML!=null
,那么isHydrating
为true
表示的就是子dom节点不是由Preact所创建的,那么现在看起来上面的判断条件也非常容易理解了。如果节点child
不是文本节点,根据该节点是否是由Preact所创建的做决定,如果是不是由Preact创建的,则添加到children
,否则不添加。如果是文本节点的话,如果是由Preact创建的话则添加,否则执行child.nodeValue.trim()
,我们知道函数trim
返回的是去掉字符串前后空格的新字符串,如果该节点有非空字符,则会被添加到children
中,否则不添加。这样做的目的也无非是最大程度利用之前的文本节点,减少创建不必要的文本节点。该部分代码首先对虚拟dom中的子元素进行遍历,对每一个子元素,首先判断该子元素是否含有属性key,如果含有则在
keyed
中查找对应keyed的dom元素,并在keyed
将该元素删除。否则在children
查找是否含有和该元素相同类型的节点(利用函数isSameNodeType
),如果查找到相同类型的节点,则在children
中删除并根据对应的情况(即查到的元素在children
查找范围的首尾)缩小排查范围。然后递归执行函数idiff
,如果之前child
没有查找到的话,会在idiff
中创建对应类型的节点。然后根据之前的所分析的,idiff
会返回新的dom节点。 如果idiff
返回dom不为空并且该dom与原始dom中对应位置的dom不相同时,将其添加到父节点。如果不存在对应位置的真实节点,则直接添加到父节点。如果child
已经添加到对应位置的真实dom后,则直接将其移除当前位置的真实dom,否则都将其添加到对应位置之前。这段代码所作的工作就是将
keyed
中与children
中没有用到的原始dom节点回收。到此我们已经基本讲完了整个diff
的所有大致流程,还剩idiff
中的diffAttributes
函数没有讲,因为里面涉及到dom中的事件触发,所以还是有必要讲一下:diffAttributes
的参数分别对应于:dom
: 虚拟dom对应的真实domattrs
: 期望的最终键值属性对old
: 当前或者之前的属性(从之前的VNode或者元素props属性缓存中)函数
diffAttributes
并不复杂,首先遍历old
中的属性,如果当前的属性attrs
中不存在是,则通过函数setAccessor
将其删除。然后将attr
中的属性赋值通过setAccessor
赋值给当前的dom元素。是否需要赋值需要同时满足下满三个条件:属性不能是
children
,原因children
表示的是子元素,其实Preact在h函数已经做了处理(详情见系列文章第一篇),这里其实是不会存在children
属性的。属性也不能是
innerHTML
。其实这一点Preact与React是在这点是相同的,不能通过innerHTML
给dom添加内容,只能通过dangerouslySetInnerHTML
进行设置。属性在该dom中不存在 或者 如果当该属性不是
value
或者checked
时,缓存的属性(old)必须和现在的属性(attrs)不一样,如果该属性是value
或者checked
时,则dom的属性必须和现在不一样,这么判断的主要目的就是如果属性值是value
或者checked
表明该dom属于表单元素,防止该表单元素是不受控的,缓存的属性存在可能不等于当前dom中的属性。那为什么不都用dom中的属性呢?肯定是由于JavaScript对象中取属性要比dom中拿到属性的速度快很多。到这里我们有个地方需要注意的是,调用函数
setAccessor
时的第三个实参为old[name] = undefined
或者old[name] = attrs[name]
,我们在前面说过,如果虚拟dom中的attributes
发生改变时也需要将真实dom中的__preactattr_
进行更新,其实更新的过程就发生在这里,old
的实参就是props = out[ATTR_KEY]
,所以更新old
时也对应修改了dom的缓存。我们最后需要关注的是函数
setAccessor
,这个函数比较长但是结构是及其的简单:整个函数都是
if-else
的结构,首先看看各个参数:node
: 对应的dom节点name
: 属性名old
: 该属性之前存储的值value
: 该属性当前要修改的值isSvg
: 是否为SVG元素然后看一下函数的流程:
className
,则属性名修改为class
,这一点Preact与React是不相同的,React对css中的类仅支持属性名className
,但Preact既支持className
的属性名也支持class
,并且Preact更推荐使用class
.key
时,不做任何处理。class
并且不是svg元素
,则直接将值赋值给dom元素。style
时,第一种情况是将字符串类型的样式赋值给dom.style.cssText
。如果value是空或者是字符串这么赋值非常能够理解,但是为什么之前的属性值old
是字串符为什么也需要通过dom.style.cssText
,经过我的实验发现作用应该是覆盖之前通过cssText
赋值的样式(所以这里的代码并不是if-else
),而是两个if
的结构。下面的第二种情况是value
是对象类型,所进行的操作是剔除取消的属性,添加新的或者更改的属性。dangerouslySetInnerHTML
,则将value
中的__html
值赋值给innerHtml
属性。on
开头,说明要绑定的是事件,因为我们知道Preact不同于React,并没有采用事件代理的机制,所有的事件都会被注册到真实的dom中。而且另一点与React不相同的是,如果你的事件名后添加Capture
,例如onClickCapture
,那么该事件将在dom的捕获阶段响应,默认会在冒泡事件响应。如果value
存在则是注册事件,否则会将注册的事件移除。我们发现在调用addEventListener
并没有直接将value
作为其第二个参数传入,而是传入了eventProxy
:我们看到因为有语句
(node._listeners || (node._listeners = {}))[name] = value
,所以某个对应事件的处理函数是保存在node._listeners
对象中,因此当函数eventProxy
调用时,就可以触发对应的事件处理程序,其实这也算是一种简单的事件代理机制,如果该元素对应的某个事件处理程序发生改变时,也就不需要删除之前的处理事件并绑定新的处理,只需要改变node._listeners
对象存储的对应事件处理函数即可。type
和list
以外的自有属性进行赋值或者删除。其中函数setProperty
为:这个函数尝试给为DOM的自有属性赋值,赋值的过程可能在于IE浏览器和FireFox中抛出异常。所以这里有一个
try-catch
的结构。setAttribute
进行赋值。到此为止,我们已经基本全部分析完了Preact中
diff
算法的过程,我们看到Preact相比于庞大的React,短短数百行语句就实现了diff
的功能并能达到一个相当不错的性能。由于本人能力所限,不能达到面面俱到,但希望这篇文章能起到抛砖引玉的作用,如果不正确指出,欢迎指出和讨论~