关于DIFF算法,Vue框架与React框架有不同做法。本文以Vue2.0版本的源码进行学习解析,主讲核心updateChildren部分。
1.先看看官方代码
updateChildren (parentElm, oldCh, newCh) {
let oldStartIdx = 0, newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx
let idxInOld
let elmToMove
let before
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) { // 对于vnode.key的比较,会把oldVnode = null
oldStartVnode = oldCh[++oldStartIdx]
}else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
}else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
}else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}else {
// 使用key时的比较
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
}
else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
}else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
}else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
你肯定会看得头晕目眩,所以我自己总结了个精华版的
2.精华版的updateChildren代码
updateChildren (parentElm, oldCh, newCh) {
..省略 (新节点的头尾的变量newStartIdxh和newEndIdx)
..省略 (旧节点的头尾的变量oldStartIdx和oldEndIdx)
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) { // 对于vnode.key的比较,会把oldVnode = null
...省略 判断oldStartIdx/oldEndIdx/newStartIdxh/newEndIdx(按照这个顺序依次判断)是否为null,是的话,指针往中间移动
}else if (sameVnode(oldStartVnode, newStartVnode)) {
...省略 依次以(oldStartVnode, newStartVnode)/(oldEndIdx, newEndIdx)为参数执行patchVnode方法,执行后,指针往中间移动
}else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
...省略 依次以(oldStartVnode, newEndVnode)/(oldEndIdx, newStartVnode)为参数执行patchVnode方法,执行后,指针往中间移动
1.如果是oldStartVnode, newEndVnode匹配上了,那么真实dom中的第一个节点会移到最后
如果是oldEndIdx, newStartVnode匹配上了,那么真实dom中的最后一个节点会移到最前,匹配上的两个指针向中间移动
2.指针往中间移动
}else {
// 使用key时的比较
// 设key后,除了头尾两端的比较外,还会从用key生成的对象 oldKeyToIdx 中查找匹配的节点,所以为节点设置key可以更高效的利用dom。
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
//没有key时执行
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
}
//有key时执行
else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
}else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
// oldStartIdx > oldEndIdx表示oldCh先遍历完,那么就将多余的vCh根据index添加到dom中去
// oldStartIdx > oldEndIdx表示vCh先遍历完,那么就在真实dom中将区间为[oldStartIdx , oldEndIdx]的多余节点删掉
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
}else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
3.updateChildren中用到的sameVnode方法
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)
)
)
)
}
它是用来判断节点是否可用的关键函数,可以看到,判断是否是
sameVnode
,传递给节点的key
是关键。
4.updateChildren中用到的patchVnode方法
patchVnode (oldVnode, vnode) {
const el = vnode.el = oldVnode.el
let i, oldCh = oldVnode.children, ch = vnode.children
if (oldVnode === vnode) return
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text)
}else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch)
}else if (ch){
createEle(vnode) //create el's children dom
}else if (oldCh){
api.removeChildren(el)
}
}
}
const el = vnode.el = oldVnode.el
这是很重要的一步,让vnode.el
引用到现在的真实dom,当el
修改时,vnode.el
会同步变化。节点的比较有5种情况
if (oldVnode === vnode)
,他们的引用一致,可以认为没有变化。
if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text)
,文本节点的比较,需要修改,则会调用Node.textContent = vnode.text
。
if( oldCh && ch && oldCh !== ch )
, 两个节点都有子节点,而且它们不一样,这样我们会调用updateChildren
函数比较子节点,这是diff的核心,后边会讲到。
else if (ch)
,只有新的节点有子节点,调用createEle(vnode)
,vnode.el
已经引用了老的dom节点,createEle
函数会在老dom节点上添加子节点。
else if (oldCh)
,新节点没有子节点,老节点有子节点,直接删除老节点。
5.总体描述
diff过程整体遵循深度优先、同层比较的策略;先进行头尾节点可能相同做4次比对尝试。如果没有找到相同节点才按照通用方式遍历查找。这是如果设置了key,就会从用key生成的对象 oldKeyToIdx 中查找匹配的节点。如果没有key,则直接将newStartIdxh生成新的节点插入真实DOM。查找结束再按情况处理剩下的节点;
在指针相遇以后,还有两种比较特殊的情况:
有新节点需要加入。如果更新完以后,oldStartIdx
>
oldEndIdx ,说明旧节点都被patch
完了,但是有可能还有新的节点没有被处理到。接着会去判断是否要新增子节点。有旧节点需要删除。如果新节点先patch完了,那么此时会走 newStartIdxh
>
newEndIdx 的逻辑,那么就会去删除多余的旧子节点。
6.为什么 Vue 中不要用 index 作为 key?
假设我们在data中有一个数组num , 值为[1, 2, 3]。我们渲染出来
1 2 3
三个数字。我们先以index
作为key,来跟踪一下它的更新。假设我们用一个点击事件使数组做reverse(翻转)
的操作。这时候会导致key的顺序没变,传入的值完全变了。
本来按照最合理的逻辑来说,旧的第一个vnode
是应该直接完全复用 新的第三个vnode
的,因为它们本来就应该是同一个vnode,自然所有的属性都是相同的。但是在进行子节点的 diff
过程中,会在 旧首节点和新首节点用
sameNode对比。
这一步命中逻辑,因为现在新旧两次首部节点
的 key
都是 0
了,
然后把旧的节点中的第一个 vnode
和 新的节点中的第一个 vnode
进行 patchVnode
操作。
这会发生什么呢?我可以大致给你列一下:首先,正如我之前的文章props的更新如何触发重渲染?里所说,在进行 patchVnode
的时候,会去检查 属性有没有变更,如果有的话,会通过 属性值赋值
这样的逻辑去更新这个响应式的值,触发 dep.notify
,触发子组件视图的重新渲染等一套很重的逻辑。
然后,还会额外的触发以下几个钩子,假设我们的组件上定义了一些dom的属性或者类名、样式、指令,那么都会被全量的更新。
-
updateAttrs
-
updateClass
-
updateDOMListeners
-
updateDOMProps
-
updateStyle
-
updateDirectives
而这些所有重量级的操作(虚拟dom发明的其中一个目的不就是为了减少真实dom的操作么?),都可以通过直接复用 第三个vnode
来避免,是因为我们偷懒写了 index
作为key
,而导致所有的优化失效了。
7.总结key
用组件唯一的
id
(一般由后端返回)作为它的key
,实在没有的情况下,可以在获取到列表的时候通过某种规则为它们创建一个key
,并保证这个key
在组件整个生命周期中都保持稳定。别用
index
作为key
,和没写基本上没区别,因为不管你数组的顺序怎么颠倒,index 都是0, 1, 2
这样排列,导致 Vue 会复用错误的旧子节点,做很多额外的工作。