Vue中采用了 虚拟DOM + Diff算法
减少了对DOM的操作次数,大大提高了性能,那么我们今天就来详细的讲一下Vue中这一部分的实现逻辑,希望可以帮助还不理解这部分的小伙伴理解这一部分,纯手打,希望各位小伙伴点个赞支持一下!
首先我们要明确的是,vnode代表本次修改后新生成的虚拟节点,oldVnode代表目前真实DOM结构所对应的虚拟节点。所以我们更新是以vnode为基准,通过oldVnode的结构去操作真实DOM,vnode和oldVnode都不会被改变,被改变的只有真实DOM结构。
patch
Vue在首次渲染和数据更新的时候,会去调用 _update
方法,而 _update
方法的核心就是去调用 vm.__patch__
方法,那么我们就先从patch
方法入手。patch方法中的逻辑比较复杂,这里我们只看主要流程部分,大致流程如下图:
function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
if (isUndef(oldVnode)) {
/*oldVnode未定义的时候,创建一个新的节点*/
isInitialPatch = true
createElm(vnode, insertedVnodeQueue, parentElm, refElm)
} else {
/*标记旧的VNode是否有nodeType*/
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
/*是同一个节点的时候直接修改现有的节点*/
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
if (isRealElement) {
oldVnode = emptyNodeAt(oldVnode)
}
// 插入到视图中旧节点的旁边
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
if (isDef(parentElm)) {
/*移除老节点*/
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
/*调用destroy钩子*/
invokeDestroyHook(oldVnode)
}
}
}
return vnode.elm
}
这里有个 isRealElement
用来标识oldVnode是否有 nodeType
,有nodeType
就说明这是个真是的dom节点,要通过emptyNodeAt转化为vNode。
patch中用到了两个比较重要而方法,一个是createElm
,另一个是patchVnode
。
createElm
createElm
的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中,主要流程如下:
function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) {
/*创建一个组件节点*/
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return
}
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
if (isDef(tag)) {
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode) // 创建dom节点
setScope(vnode)
if (__WEEX__) {
// ...
} else {
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue)
}
insert(parentElm, vnode.elm, refElm)
}
} else if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
}
createComponent
只有在vnode是组件的时候才会返回true,这部分以后再分析。
createChildren
实际上是遍历子虚拟节点,递归调用 createElm
,遍历过程中会把 vnode.elm
作为父容器的 DOM 节点传入,因为是递归调用,子元素会优先调用 insert
,所以整个vnode树节点的插入顺序是先子后父
。
patchVnode
patchVnode的主要流程和主要代码如下:
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
/*两个VNode节点相同则直接返回*/
if (oldVnode === vnode) {
return
}
/*
如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),
并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),
那么只需要替换elm以及componentInstance即可。
*/
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))) {
vnode.elm = oldVnode.elm
vnode.componentInstance = oldVnode.componentInstance
return
}
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
/*如果这个VNode节点没有text文本时*/
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
/*新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren*/
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
/*如果老节点没有子节点而新节点存在子节点,先清空elm的文本内容,然后为当前节点加入子节点*/
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
/*当新节点没有子节点而老节点有子节点的时候,则移除所有ele的子节点*/
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
/*当新老节点都无子节点的时候,只是文本的替换,因为这个逻辑中新节点text不存在,所以直接去除ele的文本*/
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
/*当新老节点text不一样时,直接替换这段文本*/
nodeOps.setTextContent(elm, vnode.text)
}
}
其实也就几种情况:
- 新旧vnode都有子节点,对子节点进行diff。
- 新vnode有,旧vnode没有,新增。
- 旧vnode有,新vnode没有,删除。
- 如果有text属性,替换文本。
主要来看其中调用的 updateChildren
方法。
updateChildren
在整个 patchVnode
过程中,最复杂的就是 updateChildren
方法了,这个方法里进行了新旧虚拟节点子节点的对比。
如何对比新旧子节点列表呢?很简单,循环!循环新子节点列表
,其中每一项再去旧子节点列表
里循环查找,然后做处理。但通常情况下,并不是所有子节点的位置都会发生改变。
举个例子🌰,现在有一个列表,我们对它的操作大多数就是新增一项,删除一项或者改变其中的一项,大多数节点的位置是不变的,是可预测的
,没必要每次都去循环查找,所以Vue中使用了一种更快捷的查找方式,大大提高了性能,简单来说就是头尾比较
:
- 新头:新子节点列表中所有
未处理
的第一个节点 - 旧头:旧子节点列表中所有
未处理
的第一个节点 - 新尾:新子节点列表中所有
未处理
的最后一个节点 - 旧尾:旧子节点列表中所有
未处理
的最后一个节点
Vue中用四个变量 newStartIdx
、newEndIdx
、oldStartIdx
、 oldEndIdx
来标识新旧头部与尾部节点,
1. 新头与旧头
如果新头与旧头是同一节点,它们的位置也一样,所以只需更新节点即可,没有移动操作。
2. 新尾与旧尾
如果新尾与旧尾是同一节点,它们的位置也一样,所以只需更新节点即可,没有移动操作。
3. 新尾与旧头
如果新尾与旧头是同一节点,由于它们位置不一样,所以除了更新,还要进行移动操作。
首先我们要明确的是更新是以vnode为基准,oldVnode代表的就是真实DOM结构,所以我们要更新真实DOM其实就是去更新oldVnode。
这里我们要注意,一定是移动到所有 未处理节点
后面,因为新尾是新子节点列表里未处理
的最后一个。
4. 新头与旧尾
如果新头与旧尾是同一节点,由于它们位置不一样,所以除了更新,还要进行移动操作。
移动逻辑和上面的 新尾与旧头
大致相同,把旧尾移动到所有 未处理节点之前
。
如果经过这四次对比还是没有在 旧子节点列表
中找到相同的节点,那么先用oldVnode生成一个key为oldVnode的key,value为对应下标的哈希表 {key0: 0, key1: 1}
,然后用 新头(newStartVnode)
的key在哈希表里查找,如果找到对应的,判断他们是不是 sameVnode
:
- 如果是,那么就表示在
旧子节点列表
中找到了相同的节点,进行更新节点操作,最后还要将这个老节点赋值undefined
,避免后续有相同的key重复对比。 - 如果不是,那么就有可能是标签不一样了,或者input的type改变了,这时直接创建一个新的节点。
如果在哈希表里没找到对应的,就简单了,直接创建一个新的节点。最后遍历结束,会有两种情况:
- oldStartIdx > oldEndIdx,说明老节点遍历结束,那么剩余新节点就是要新增的,那么一个一个创建出来加入到真实Dom中。
- newStartIdx > newEndIdx,说明新节点遍历结束,那么剩余的老节点就是要删除的,删除即可。
逻辑理清楚了,是不是觉得也很简单呢!