面试官:你了解vue的diff算法吗?说说看
一、是什么 diff
算法是一种通过同层的树节点进行比较的高效算法
其有两个特点:
比较只会在同层级进行, 不会跨层级比较
在diff比较的过程中,循环从两边向中间比较
diff
算法在很多场景下都有应用,在 vue
中,作用于虚拟 dom
渲染成真实 dom
的新旧 VNode
节点比较
二、比较方式 diff
整体策略为:深度优先,同层比较
比较只会在同层级进行, 不会跨层级比较
比较的过程中,循环从两边向中间收拢
下面举个vue
通过diff
算法更新的例子:
新旧VNode
节点如下图所示:
第一次循环后,发现旧节点D与新节点D相同,直接复用旧节点D作为diff
后的第一个真实节点,同时旧节点endIndex
移动到C,新节点的 startIndex
移动到了 C
第二次循环后,同样是旧节点的末尾和新节点的开头(都是 C)相同,同理,diff
后创建了 C 的真实节点插入到第一次创建的 D 节点后面。同时旧节点的 endIndex
移动到了 B,新节点的 startIndex
移动到了 E
第三次循环中,发现E没有找到,这时候只能直接创建新的真实节点 E,插入到第二次创建的 C 节点之后。同时新节点的 startIndex
移动到了 A。旧节点的 startIndex
和 endIndex
都保持不动
第四次循环中,发现了新旧节点的开头(都是 A)相同,于是 diff
后创建了 A 的真实节点,插入到前一次创建的 E 节点后面。同时旧节点的 startIndex
移动到了 B,新节点的 startIndex
移动到了 B
第五次循环中,情形同第四次循环一样,因此 diff
后创建了 B 真实节点 插入到前一次创建的 A 节点后面。同时旧节点的 startIndex
移动到了 C,新节点的 startIndex 移动到了 F
新节点的 startIndex
已经大于 endIndex
了,需要创建 newStartIdx
和 newEndIdx
之间的所有节点,也就是节点F,直接创建 F 节点对应的真实节点放到 B 节点后面
三、原理分析 当数据发生改变时,set
方法会调用Dep.notify
通知所有订阅者Watcher
,订阅者就会调用patch
给真实的DOM
打补丁,更新相应的视图
源码位置:src/core/vdom/patch.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 function patch (oldVnode, vnode, hydrating, removeOnly ) { if (isUndef (vnode)) { if (isDef (oldVnode)) invokeDestroyHook (oldVnode) return } let isInitialPatch = false const insertedVnodeQueue = [] if (isUndef (oldVnode)) { isInitialPatch = true createElm (vnode, insertedVnodeQueue) } else { const isRealElement = isDef (oldVnode.nodeType ) if (!isRealElement && sameVnode (oldVnode, vnode)) { patchVnode (oldVnode, vnode, insertedVnodeQueue, null , null , removeOnly) } else { if (isRealElement) { if (oldVnode.nodeType === 1 && oldVnode.hasAttribute (SSR_ATTR )) { oldVnode.removeAttribute (SSR_ATTR ) hydrating = true } if (isTrue (hydrating)) { if (hydrate (oldVnode, vnode, insertedVnodeQueue)) { invokeInsertHook (vnode, insertedVnodeQueue, true ) return oldVnode } } oldVnode = emptyNodeAt (oldVnode) } return vnode.elm } } }
patch
函数前两个参数位为oldVnode
和 Vnode
,分别代表新的节点和之前的旧节点,主要做了四个判断:
没有新节点,直接触发旧节点的destory
钩子
没有旧节点,说明是页面刚开始初始化的时候,此时,根本不需要比较了,直接全是新建,所以只调用 createElm
旧节点和新节点自身一样,通过 sameVnode
判断节点是否一样,一样时,直接调用 patchVnode
去处理这两个节点
旧节点和新节点自身不一样,当两个节点不一样的时候,直接创建新节点,删除旧节点
下面主要讲的是patchVnode
部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) { if (oldVnode === vnode) { return } const elm = vnode.elm = oldVnode.elm if (isTrue (oldVnode.isAsyncPlaceholder )) { if (isDef (vnode.asyncFactory .resolved )) { hydrate (oldVnode.elm , vnode, insertedVnodeQueue) } else { vnode.isAsyncPlaceholder = true } return } if (isTrue (vnode.isStatic ) && isTrue (oldVnode.isStatic ) && vnode.key === oldVnode.key && (isTrue (vnode.isCloned ) || isTrue (vnode.isOnce )) ) { vnode.componentInstance = oldVnode.componentInstance return } let i const data = vnode.data if (isDef (data) && isDef (i = data.hook ) && isDef (i = i.prepatch )) { i (oldVnode, vnode) } const oldCh = oldVnode.children const ch = vnode.children if (isDef (data) && isPatchable (vnode)) { for (i = 0 ; i < cbs.update .length ; ++i) cbs.update [i](oldVnode, vnode) if (isDef (i = data.hook ) && isDef (i = i.update )) i (oldVnode, vnode) } if (isUndef (vnode.text )) { if (isDef (oldCh) && isDef (ch)) { if (oldCh !== ch) updateChildren (elm, oldCh, ch, insertedVnodeQueue, removeOnly) } else if (isDef (ch)) { if (isDef (oldVnode.text )) nodeOps.setTextContent (elm, '' ) addVnodes (elm, null , ch, 0 , ch.length - 1 , insertedVnodeQueue) } else if (isDef (oldCh)) { removeVnodes (elm, oldCh, 0 , oldCh.length - 1 ) } else if (isDef (oldVnode.text )) { nodeOps.setTextContent (elm, '' ) } } else if (oldVnode.text !== vnode.text ) { nodeOps.setTextContent (elm, vnode.text ) } if (isDef (data)) { if (isDef (i = data.hook ) && isDef (i = i.postpatch )) i (oldVnode, vnode) } }
patchVnode
主要做了几个判断:
新节点是否是文本节点,如果是,则直接更新dom
的文本内容为新节点的文本内容
新节点和旧节点如果都有子节点,则处理比较更新子节点
只有新节点有子节点,旧节点没有,那么不用比较了,所有节点都是全新的,所以直接全部新建就好了,新建是指创建出所有新DOM
,并且添加进父节点
只有旧节点有子节点而新节点没有,说明更新后的页面,旧节点全部都不见了,那么要做的,就是把所有的旧节点删除,也就是直接把DOM
删除
子节点不完全一致,则调用updateChildren
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { let oldStartIdx = 0 let newStartIdx = 0 let oldEndIdx = oldCh.length - 1 let newEndIdx = newCh.length - 1 let oldStartVnode = oldCh[0 ] let oldEndVnode = oldCh[oldEndIdx] let newStartVnode = newCh[0 ] let newEndVnode = newCh[newEndIdx] let oldKeyToIdx, idxInOld, vnodeToMove, refElm const canMove = !removeOnly while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef (oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] } else if (isUndef (oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode (oldStartVnode, newStartVnode)) { patchVnode (oldStartVnode, newStartVnode, insertedVnodeQueue) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode (oldEndVnode, newEndVnode)) { patchVnode (oldEndVnode, newEndVnode, insertedVnodeQueue) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode (oldStartVnode, newEndVnode)) { patchVnode (oldStartVnode, newEndVnode, insertedVnodeQueue) canMove && nodeOps.insertBefore (parentElm, oldStartVnode.elm , nodeOps.nextSibling (oldEndVnode.elm )) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode (oldEndVnode, newStartVnode)) { patchVnode (oldEndVnode, newStartVnode, insertedVnodeQueue) canMove && nodeOps.insertBefore (parentElm, oldEndVnode.elm , oldStartVnode.elm ) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { if (isUndef (oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx (oldCh, oldStartIdx, oldEndIdx) idxInOld = isDef (newStartVnode.key ) ? oldKeyToIdx[newStartVnode.key ] : findIdxInOld (newStartVnode, oldCh, oldStartIdx, oldEndIdx) if (isUndef (idxInOld)) { createElm (newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm ) } else { vnodeToMove = oldCh[idxInOld] if (process.env .NODE_ENV !== 'production' && !vnodeToMove) { warn ( 'It seems there are duplicate keys that is causing an update error. ' + 'Make sure each v-for item has a unique key.' ) } if (sameVnode (vnodeToMove, newStartVnode)) { patchVnode (vnodeToMove, newStartVnode, insertedVnodeQueue) oldCh[idxInOld] = undefined canMove && nodeOps.insertBefore (parentElm, vnodeToMove.elm , oldStartVnode.elm ) } else { createElm (newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm ) } } newStartVnode = newCh[++newStartIdx] } }
while
循环主要处理了以下五种情景:
当新老 VNode
节点的 start
相同时,直接 patchVnode
,同时新老 VNode
节点的开始索引都加 1
当新老 VNode
节点的 end
相同时,同样直接 patchVnode
,同时新老 VNode
节点的结束索引都减 1
当老 VNode
节点的 start
和新 VNode
节点的 end
相同时,这时候在 patchVnode
后,还需要将当前真实 dom
节点移动到 oldEndVnode
的后面,同时老 VNode
节点开始索引加 1,新 VNode
节点的结束索引减 1
当老 VNode
节点的 end
和新 VNode
节点的 start
相同时,这时候在 patchVnode
后,还需要将当前真实 dom
节点移动到 oldStartVnode
的前面,同时老 VNode
节点结束索引减 1,新 VNode
节点的开始索引加 1
如果都不满足以上四种情形,那说明没有相同的节点可以复用,则会分为以下两种情况:
从旧的 VNode
为 key
值,对应 index
序列为 value
值的哈希表中找到与 newStartVnode
一致 key
的旧的 VNode
节点,再进行patchVnode
,同时将这个真实 dom
移动到 oldStartVnode
对应的真实 dom
的前面
调用 createElm
创建一个新的 dom
节点放到当前 newStartIdx
的位置
小结
当数据发生改变时,订阅者watcher
就会调用patch
给真实的DOM
打补丁
通过isSameVnode
进行判断,相同则调用patchVnode
方法
patchVnode
做了以下操作:
找到对应的真实dom
,称为el
如果都有都有文本节点且不相等,将el
文本节点设置为Vnode
的文本节点
如果oldVnode
有子节点而VNode
没有,则删除el
子节点
如果oldVnode
没有子节点而VNode
有,则将VNode
的子节点真实化后添加到el
如果两者都有子节点,则执行updateChildren
函数比较子节点
updateChildren
主要做了以下操作:
设置新旧VNode
的头尾指针
新旧头尾指针进行比较,循环向中间靠拢,根据情况调用patchVnode
进行patch
重复流程、调用createElem
创建一个新节点,从哈希表寻找 key
一致的VNode
节点再分情况操作
参考文献