patch
1. patch介绍
patch
的目的其实是修改DOM节点,也可以理解为视图渲染。patch
不是暴力替换节点,而是在现有的DOM上进行修改来达到渲染视图的目的。对现有DOM修改需要做三件事:
- 创建新增的节点
- 删除已经废除的节点
- 修改需要更新的节点
1. 新增节点
新增节点,本质上是为了使用JavaScript
的计算成本来换取DOM的操作成本。只有那些因为状态的改变而新增的节点在DOM中并不存在时,我们才需要创建一个节点并插入到DOM中。
新增节点的场景:
- oldVnode不存在而vnode存在
- vnode和oldVnode完全不是听一个节点
2. 删除节点
当一个节点只在oldVnode中存在时,需要把它从DOM中删除。
在渲染视图时,需要以vnode为标准,所以vnode中不存在的节点都属于被废弃的节点,而被废弃的节点需要从DOM中删除。
3. 更新节点
在删除和新增节点的操作中,如果两个虚拟节点是完全不同的,需要以新节点为标准渲染视图。
当新旧节点是同一个节点时,需要对这两个节点进行比较细致的对比,然后对oldVnode在视图中所对应的真实节点进行更新。
4. 小结
当oldVnode不存在时,直接使用vnode渲染视图。
当oldVnode和vnode都存在但不是同一个节点时,使用vnode创建的DOM元素替换旧的DOM元素。
当oldVnode和vnode是同一个节点时,使用更详细的对比操作对真实的DOM节点进行更新。
2. 创建节点
只有三种类型的节点会被插入到DOM中:
- 元素节点
元素节点具有tag
属性。创建元素节点的步骤:- 调用当前环境下的
createElement
方法来创建真实的元素。 - 将它的子节点也创建出来,并 插入到这个刚创建出的节点下面。
创建子节点是一个递归过程。vnode中的children属性上保存了当前节点的所有子虚拟节点:- 将vnode中的children属性都循环一遍
- 将每个子虚拟节点都执行一遍创建元素的逻辑
- 调用当前环境下的appendChild方法,将元素插入到指定的父节点中。
- 调用当前环境下的
- 注释节点:
注释节点有一个唯一的标识属性isComment
,如果是注释节点,调用当前环境下的ceateComment方法来创建真实的注释节点并将其插入到指定的父节点中。 - 文本节点:
文本节点只有一个text属性。如果是文本节点,调用当前环境下的createTextNode方法来创建真实的文本节点并将其插入到指定的父节点中。
创建节点并渲染到视图的全过程:
3. 删除节点
删除实现逻辑如下:
function removeNodes(vnodes,startIdx,endIdx){
for(;startIdx<=endIdx;startIdx++){
const ch = vnodes[startIdx];
if(isDef(ch)){
removeNode(ch.elm)
}
}
}
删除vnode数组中从startIdx
指定的位置到endIdx
指定位置的内容。
removeNode
用于删除视图中的单个节点,而removeVnodes
用于删除一组指定的节点。逻辑如下:
const nodeOps = {
removeChild(){
nodeOps.removeChild(child)
}
}
function removeNode(el){
const parent = nodeOps.parentNode(el);
if(isDef(parent)){
nodeOps.removeChild(parent,el);
}
}
将当前元素从它的父节点中删除,其中nodeOps
是对节点操作的封装。
4. 更新节点
更新节点不是很暴力的使用新节点覆盖旧节点,而是通过对比找出两个新旧节点不一样的地方,针对那些不一样的地方进行更新。
1. 静态节点
静态节点指的是那些一旦渲染到界面上之后,无论日后状态如何变化,都不会发生任何变化的节点。
更新节点时,首先要判断新旧两个节点是否是静态节点,如果是,就不需要进行更新操作,可以直接跳过更新节点的操作。
2. 新虚拟节点有文本属性
当新虚拟节点有文本属性,并且和旧虚拟节点的文本属性不一样时,我们可以直接把视图中真实的DOM节点的内容改成新虚拟节点的文本。
3. 新虚拟节点无文本属性
如果新创建的虚拟节点没有text
属性,那么它就是一个元素节点。元素节点通常会有子节点,也就是children
属性,但也有可能没有子节点,所以存在两种不同的情况:
1. 有children
的情况
当新创建的虚拟节点有children
属性时,其实还会有两种情况,那就是要看旧虚拟节点是否有children
属性。
- 旧虚拟节点有
children
属性
对新旧两个虚拟节点进行更详细的对比并更新。 - 旧虚拟节点没有
children
属性
说明旧虚拟节点要么是空标签,要么是有文本的文本节点。 如果是文本节点,先把文本清空变成空标签,然后将新虚拟节点中的children
挨个创建成真实的DOM元素并将其插入到视图中的DOM节点下面。
2. 无children
的情况
当新创建的虚拟节点既没有text
属性也没有children
属性时,这说明这个新创建的节点是一个空节点,它下面既没有文本也没有子节点,这时如果旧虚拟节点中有子节点就删除子节点,有文本就删文本。有什么删什么,最后达到视图中是空标签的目的。
4.小结
更新节点的逻辑:
在源码中,真实的实现过程如下:
5. 更新子节点
更新子节点大概可以分为四种操作:更新节点、删除节点、新增节点、移动节点位置》更新节点更多的是在讨论什么情况下需要更新操作,什么情况下新增节点等。
1.更新策略
1. 创建子节点
关注问题:
什么情况下需要创建节点?把创建的节点插入到真实的DOM节点中的哪个位置?
创建节点的操作是在循环体内执行的,其具体实现实在oldChildren
(旧子节点列表)中寻找本次循环所指向的新子节点。
如果在oldChildren
中没有找到与本次循环所指向的新子节点相同的节点,那么说明本次循环所指向的新子节点是一个新增节点。对于新增节点,我们需要执行创建节点的操作,并将新创建的节点插入到oldChildren
中所有未处理节点(未处理就是没有进行任何更新操作的节点)的前面。当节点成功插入DOM后,这一轮循环就结束了。
2. 更新子节点
更新节点本质上是一个节点同时存在于newChildren
和oldChildren
中时需要执行的操作。
3. 移动子节点
移动节点通常发生在newChildren
中的某个节点和oldChildren
中的某个节点是同一个节点,但是位置不同,所以在真实的DOM中需要将这个节点的位置以新虚拟节点的位置为基准进行移动。
通过Node.insetBefore()
方法,我们可以成功的将一个已有节点移动到一个指定的位置。
4. 删除子节点
删除子节点,本质上删除那些oldChildren
中存在但newChildren
中不存在的节点
当newChildren
中的所有节点都被循环了一遍后,也就是循环结束后,如果oldChildren
中还有剩余的没有被处理的节点,那么这些节点就是被废弃、需要删除的节点。
2. 优化策略
问题场景:
只修改了列表中某个数据的内容,而没有新增数据或者删除数据,这种情况下newChildren
和oldChildren
中所有节点的位置都是相同的,这时节点的位置就是可以预测的,不需要循环也可以知道oldChildren
中的哪个节点和被寻找的新子节点是同一个节点。
解决办法:
只需要尝试使用相同位置的两个节点来对比是否是同一个节点:如果恰巧是同一个节点,直接就可以进入更多节点的操作;如果尝试失败了,再用循环的方式来查找节点。
这样做可以很大程度的避免循环oldChildren
来查找节点,从而使执行速度得到很大的提升。
我们把这种很快速查找节点的方式成为快捷查找,那么他们共有四种查找方式:
- 新前与旧前
- 新后与旧后
- 新后与旧前
- 新前与旧后
解释:
新前:newChildren
中所有未处理的第一个子节点
新后:newChildren
中所有未处理的最后一个节点
旧前:oldChidren
中所有未处理的第一个子节点
旧后:oldChildren
中所有未处理的最后一个节点
- 新前与旧前
如果“新前”和"旧前"的位置相同,所以并不需要执行移动节点的操作,只需要更新节点即可。 - 新后与旧前
如果“新后”与”旧前“这两个节点的位置相同,所以只需要执行更新节点的操作即可,不需要执行移动节点的操作。 - 新后与旧前
当”新后“与”旧前“是同一个节点时,在真实DOM中除了做更新操作外,还需要将节点移动到oldChildren
中所有未处理节点的最后面。 - 新前与旧后
当”新前“与”旧后“是同一个节点时,在真实DOM中除了做更新操作外,还需要将节点移动到oldChildren
中所有未处理节点的最前面。
3. 哪些节点是未处理过的
由于优化策略,节点是有可能从后面对比的,对比成功就会进行更新处理,也就是说,我们的循环体内的逻辑由于优化策略,不再只是处理所有未处理过的节点的第一个,而是有可能会处理最后一个,这种情况下就不能从前向后循环,而应该是从中间向两边循环。
如何实现两边向中间循环?
先准备四个变量:
oldStratIdx
:oldChildren
的开始位置的下标;oldEndIdx
:oldChildren
的结束位置的下标;newStartIdx
:newChildren
的开始位置下标;newEndIdx
:newChildren
的结束位置下标;
在循环体内,每处理一个节点,就将下标向指定的方向移动一个位置,通常情况下是对新旧两个节点进行更新操作,就相当于一次处理两个节点,将新旧两个节点的下标都向指定方向移动一个位置。
开始位置所表示的节点被处理后,就向后移动一个位置;结束位置的节点被处理后,则向前移动一个位置。
也就是说,oldStartIdx
和newStartIdx
只能向后移动,而oldEndIdx
和newEndIdx
只能向前移动。
当开始位置大于结束位置时,说明所有节点都遍历过了,则结束循环:
while(oldStartIdx<=oldEndIdx&&newStartIdx<=newEndIdx){
//do something
}
这个循环条件是无论newChildren
或者oldChildren
,只要他们两个中有一个循环完毕,就会退出循环。那么,当新子节点和旧子节点的节点数量不一致时,会导致循环结束后仍然有未处理的节点,也就是说这个循环将无法覆盖所有节点。
如果是oldChildren
先循环完毕,这个时候newChildren
中还有剩余的节点,说明这些节点都是需要新增的节点,直接把这个节点插入到DOM中就行了。
如果是newChildren
先循环完毕,这个时候oldChildren
中还有剩余的节点,说明这些节点都是被废弃的节点,是应该被删除的节点。
找到newChildren
中所剩余的节点:因为oldChildren
先被循环完毕,所以此时newStartIdx
肯定小于newEndIdx
的,那么在newChildren
中,下标在newStartIdx
和newEndIdx
之间的所有点都是未处理过的节点。
找到oldChildren
中所剩余的节点:因为newChildren
先被循环完毕,所以oldStartIdx
小于oldEndIdx
,那么在oldChildren
中,下标在oldStartIdx
和oldEndIdx
之间的所有节点都是未处理过的节点。
4. 小结
在新增节点中,详细讨论了什么情况下需要创建子节点,以及把创建的子节点插入到什么位置。
更新子节点的过程,如果在oldChildren
中可以找到与新子节点相同的节点,就需要更新它们。
如果在oldChildren
中找到节点的位置和新子节点的位置不一样,需要将DOM中的节点移动到新子节点所在的位置。
删除节点的操作发生在循环结束之后。当循环结束之后,oldChildren
中所有未处理的节点都是需要被删除的节点。
更新子节点的整体流程:
oldStartVnode
:oldChildren
中所有未处理的第一个节点,与前文中提到的"旧前"是同一个节点。
oldEndVnode
:oldChildren
中所有未处理的最后一个节点,与前文中提到的"旧后"是同一个节点。
newStartVnode
:newChildren
中所有未处理的第一个节点,与前文中提到的"新前"是同一个节点。
newEndVnode
:newChildren
中所有未处理的最后一个节点,与前文中提到的"新后"是同一个节点。
在上图中,有一部分逻辑是建立key
和index
索引的对应关系。在Vue.js
的模板中,渲染列表时可以为节点设置一个属性key
,这个属性可以标识一个节点的唯一ID。
前面提到过,在更新子节点时,需要在oldChildren
中循环去找一个节点。但是如果我们在渲染列表时,为子节点设置了属性key
,那么在建立key
与index
索引的对应的关系时,就生成了一个key
对应着一个节点下标这样一个对象。也就是说,如果在节点上设置了属性key
,那么在oldChildren
中找相同节点时,可以直接通过key
拿到下标,从而获取节点。这样我们根本不需要通过循环来查找节点。