Vue通过双向绑定来实现数据驱动视图更新,当数据更新会触发Dep对象notify通知所有的订阅者Watcher对象,Watcher对象最后会调用run来执行Watcher对象的getter方法。
其中需要注意的是在挂载阶段创建的一个Watcher对象的getter就是用于updateComponent,其中最主要方法就是调用Vue.prototype._update,而该实例方法主要进行调用patch函数进行节点的diff比较。
通过阅读Vue源码可知,patch函数的逻辑实际上主要就是比较新旧vnode创建相关的DOM,主要逻辑分为如下2点:
新的vnode不存在时,意味着当前页面应该是空的,此时执行的逻辑有2点:
oldVnode是否存在决定了处理逻辑的不同,当oldVnode不存在时,即意味着第一次挂载,处理逻辑只需要:
依据当前虚拟节点vnode来生成真实DOM,即调用createElm
当oldVnode存在时,实际上需要判断是SSR还是客户端渲染已作进一步的处理。
var isRealElement = isDef(oldVnode.nodeType);
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
} else {
// 其他的处理逻辑,实际上主要是调用createElm创建真实DOM
}
本文关注的是patchVnode的具体逻辑,这里是diff算法的核心逻辑。
在具体关注diff算法前,看下sameVnode的逻辑,Vue diff处理的前提是:
客户端渲染 + 相同节点
那何谓相同节点?直接看源码:
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
// 这里可以暂不考虑相关逻辑
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
从上面代码可以得到Vue判断相同节点的逻辑:
diff算法执行的前提是新旧节点必须相同
diff比较算法的核心逻辑都是在patchVnode函数中,具体逻辑如下:
Vue diff算法是按层来处理每一个节点的,而这里需要注意的逻辑就是子节点的处理,这里是关键:
updateChildren函数是实现层序比较的关键,而实际上层序diff的实现也是由于updateChildren内部调用了patchVnode函数,形成了递归调用。
该函数的逻辑实际上使用迭代来实现新旧节点数组的遍历比较,以做到尽可能复用节点的DOM即最小化DOM的创建。
其主要逻辑可以简述为:
/*
- oldStartIndex、oldEndIndex:处理旧节点数组遍历的双指针参数
- newStartIndex、newEndIndex:处理新节点数组遍历的双指针参数
*/
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// patchVnode相关操作
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// patchVnode相关操作
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// patchVnode相关操作
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// patchVnode相关操作
} else {
// 非上述情况的比较逻辑
}
}
// 针对不同情况新增新元素或删除旧元素
if (oldStartIdx > oldEndIdx) {
// 针对新节点数组元素addVnodes操作
} else if (newStartIdx > newEndIdx) {
// 针对旧节点数组元素removeVnodes
}
子组件的比较看似逻辑比较多,实际上逻辑可以分为2类:
实际上diff算法目的非常简单:
就是找到两个数组中相同节点值并复用它
其实现思路的核心在于:
新节点数组就是当前页面上需要显示的,以此为依据来对比新旧数组
假设自己去实现的话,常规的做法可以有:
// 实现1:以新节点数组为基础遍历,查找旧节点数组是否存在相同节点对象
for (let i = 0; i < newArray.length; i++) {
for (let j = 0; j < oldArray.length; j++) {
// 判断是否相同
}
}
//
进一步优化使用双指针方式来处理,可以减少遍历次数:
let newStartIndex = 0;
let newEndIndex = newArray.length - 1;
let newStartVnode, newEndVnode;
for (;newStartIndex <= newEndIndex;newStartIndex++, newEndIndex--) {
newStartVnode = newArray[newStartIndex];
newEndVnode = newArray[newEndIndex];
for (let j = 0; j < oldArray.length; j++) {
// 相关比较处理
}
}
实际上这里还是存在优化空间的,即内部循环也可以采用双指针形式来实现,由此可以延伸到Vue diff算法的实现:新旧节点数组都采用双指针方式来遍历,而额外逻辑是如何更加高效的找到相同节点,为了更加高效Vue diff算法做了大概下面2点的优化:
Vue Diff算法去阅读理解上会比较抽象,而抽象的来源个人感觉是如何控制4个指针参数的变化来保证不会有比较上的遗漏。
从源码上去了解4个下标参数有如下5点的处理:
新节点数组首元素与旧节点数组首元素比较,如果元素相同此时只处理newStartIndex和oldStartIndex,都是递增操作
新节点数组尾元素与旧节点数组尾元素比较,如果元素相同此时只处理newEndIndex和oldEndIndex,都是递减操作
新节点数组尾元素与旧节点数组首元素比较,如果元素相同此时newEndIndex递减,oldStartIndex递增
新节点数组首元素与旧节点数组尾元素比较,如果元素相同此时oldEndIndex递减,newStartIndex递增
首尾元素比较没有相同,则按照正常的比较逻辑,以当前newStartIndex对应的下标开始顺序比较,递增newStartIndex
通过上面的拆解可以有一个大概的思路了,接下来通过一个案例来描述Vue Diff算法大概的过程:
// 旧节点数组old -> 新节点数组new
[a, b, c, d, i, g] -> [b, a, a, f, d]
开始执行diff比较:
此时a和b节点不满足首尾sameVNode的比较,所以执行正常顺序逻辑(这里会查找旧节点数组是否有b来复用,如果复用b后此时需要注意有一个额外逻辑,将old数组中b值对应下标对应的值设置为undefined),newStartIndex++,old数组变成了[a, undefined, c, d, i, g]
此时是old数组中第一个元素a 和 new数组中第2个元素a比较,满足首首相同的条件,此时oldStartIndex++,newStartIndex++
此时old数组中第2个元素b 和 new数组中第3个元素a比较,不满足首尾的sameVnode条件,所以执行正常顺序逻辑(会再次复用a,此时需要注意有一个额外逻辑,将old数组中a值对应下标对应的值设置为undefined),此时newStartIndex++,而old数组变成了[undefined, undefined, c, d, i, g]
此时old数组中第2个元素b和new数组中第4个元素f比较,不满足首尾sameVnode条件,执行正常顺序逻辑(查找旧数组中是否有f,没有则新创建一个节点f),newStartIndex++
此时old数组中第2个元素b和new数组中第4个元素d比较,不满足首尾的sameVnode条件,所以执行正常顺序逻辑(查找旧节点数组中是否存在d,存在复用不存在则新建,存在的话会将old数组中d对应下标对应的值设置为undefined),newStartIndex++,此时old数组变成了[undefined, undefined, c, undefined, i, g]
当实例循环结束时,相应的数组下标停留在:
当迭代都执行完后此时Vue Diff算法还有一个额外的逻辑执行:
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
依据迭代结束后newStartIndex > newEndIndex,会执行旧节点数组相关节点移除动作:
上面实例迭代结后旧节点数组为[undefined, undefined, c, undefined, i, g]
可以看到都是没有使用的节点,执行removeVnodes函数,该函数的逻辑如下:
function removeVnodes (vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
var ch = vnodes[startIdx];
// 节点存在
if (isDef(ch)) {
// 标签的话执行销毁逻辑
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch);
invokeDestroyHook(ch);
} else {
// Text node
removeNode(ch.elm);
}
}
}
}
至此完成一层的迭代处理,而多层结构就是通过递归调用patchVnode来实现的,重复上面整个过程。