node.js極速入門課程:進(jìn)入學(xué)習(xí)
虛擬dom
和diff
算法是vue
學(xué)習(xí)過程中的一個難點,也是面試中必須掌握的一個知識點。這兩者相輔相成,是vue
框架的核心。今天我們再來總結(jié)下vue2
中的虛擬dom
和 diff
算法。(學(xué)習(xí)視頻分享:vue視頻教程)
什么是 VNode
我們知道,render function
會被轉(zhuǎn)化成 VNode
。VNode
其實就是一棵以 JavaScript
對象作為基礎(chǔ)的樹,用對象屬性來描述節(jié)點,實際上它只是一層對真實 DOM
的抽象。最終可以通過一系列操作使這棵樹映射到真實環(huán)境上。
比如有如下template
<template> <span class="demo" v-show="isShow"> This is a span. </span> </template>
它換成 VNode
以后大概就是下面這個樣子
{ tag: "span", data: { /* 指令集合數(shù)組 */ directives: [ { /* v-show指令 */ rawName: "v-show", expression: "isShow", name: "show", value: true, }, ], /* 靜態(tài)class */ staticClass: "demo", }, text: undefined, children: [ /* 子節(jié)點是一個文本VNode節(jié)點 */ { tag: undefined, data: undefined, text: "This is a span.", children: undefined, }, ], };
總的來說,VNode
就是一個 JavaScript
對象。這個JavaScript
對象能完整地表示出真實DOM
。
為什么vue要使用 VNode
筆者認(rèn)為有兩點原因
-
由于
Virtual DOM
是以JavaScript
對象為基礎(chǔ)而不依賴真實平臺環(huán)境,所以使它具有了跨平臺的能力,比如說瀏覽器平臺、Weex、Node 等。 -
減少操作
DOM
,任何頁面的變化,都只使用VNode
進(jìn)行操作對比,只需要在最后一次進(jìn)行掛載更新DOM
,避免了頻繁操作DOM
,減少了瀏覽器的回流和重繪從而提高頁面性能。
diff算法
下面我們來看看組件更新所涉及到的diff算法
。
前面我們講依賴收集的時候有說到,渲染watcher
傳遞給Watcher
的get
方法其實是updateComponent
方法。
updateComponent = () => { vm._update(vm._render(), hydrating) } new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */)
所以組件在響應(yīng)式數(shù)據(jù)發(fā)生變化的時候會再次觸發(fā)該方法,接下來我們來詳細(xì)分析一下updateComponent
里面的_update
方法。
_update
在_update
方法中做了初始渲染和更新的區(qū)分,雖然都是調(diào)用__patch__
方法,但是傳遞的參數(shù)不一樣。
// src/core/instance/lifecycle.js Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this const prevEl = vm.$el const prevVnode = vm._vnode vm._vnode = vnode // 初次渲染沒有 prevVnode,組件更新才會有 if (!prevVnode) { // 初次渲染 vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) } else { // 更新 vm.$el = vm.__patch__(prevVnode, vnode) } // ... }
下面我們再來看看__patch__
方法
__patch__
patch
方法接收四個參數(shù),由于初始渲染的時候oldVnode
為vm.$el
是null
,所以初始渲染是沒有oldVnode
。
// src/core/vdom/patch.js return function patch (oldVnode, vnode, hydrating, removeOnly) { // 新節(jié)點不存在,只有oldVnode就直接銷毀,然后返回 if (isUndef(vnode)) { if (isDef(oldVnode)) invokeDestroyHook(oldVnode) return } let isInitialPatch = false const insertedVnodeQueue = [] // 沒有老節(jié)點,直接創(chuàng)建,也就是初始渲染 if (isUndef(oldVnode)) { isInitialPatch = true createElm(vnode, insertedVnodeQueue) } else { const isRealElement = isDef(oldVnode.nodeType) // 不是真實dom,并且是相同節(jié)點走patch if (!isRealElement && sameVnode(oldVnode, vnode)) { // 這里才會涉及到diff算法 patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly) } else { if (isRealElement) { // ... } // replacing existing element const oldElm = oldVnode.elm const parentElm = nodeOps.parentNode(oldElm) // 1.創(chuàng)建一個新節(jié)點 createElm( vnode, insertedVnodeQueue, // extremely rare edge case: do not insert if old element is in a // leaving transition. Only happens when combining transition + // keep-alive + HOCs. (#4590) oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ) // 2.更新父節(jié)點占位符 if (isDef(vnode.parent)) { let ancestor = vnode.parent const patchable = isPatchable(vnode) while (ancestor) { for (let i = 0; i < cbs.destroy.length; ++i) { cbs.destroy[i](ancestor) } ancestor.elm = vnode.elm if (patchable) { for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, ancestor) } const insert = ancestor.data.hook.insert if (insert.merged) { // start at index 1 to avoid re-invoking component mounted hook for (let i = 1; i < insert.fns.length; i++) { insert.fns[i]() } } } else { registerRef(ancestor) } ancestor = ancestor.parent } } // 3.刪除老節(jié)點 if (isDef(parentElm)) { removeVnodes([oldVnode], 0, 0) } else if (isDef(oldVnode.tag)) { invokeDestroyHook(oldVnode) } } } //觸發(fā)插入鉤子 invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) return vnode.elm }
patch
方法大概流程如下:
-
沒有新節(jié)點只有老節(jié)點直接刪除老節(jié)點。
-
只有新節(jié)點沒有老節(jié)點直接添加新節(jié)點。
-
既有新節(jié)點又有老節(jié)點則判斷是不是相同節(jié)點,相同則進(jìn)入
pathVnode
。patchVnode
我們后面會重點分析。 -
既有新節(jié)點又有老節(jié)點則判斷是不是相同節(jié)點,不相同則直接刪除老節(jié)點添加新節(jié)點。
我們再來看看它是怎么判斷是同一個節(jié)點的。
// src/core/vdom/patch.js function sameVnode (a, b) { return ( a.key === b.key && a.asyncFactory === b.asyncFactory && ( ( a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b) ) || ( isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error) ) ) ) } function sameInputType (a, b) { if (a.tag !== 'input') return true let i const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB) }
判斷兩個VNode
節(jié)點是否是同一個節(jié)點,需要同時滿足以下條件
-
key
相同 -
都有異步組件工廠函數(shù)
-
tag
(當(dāng)前節(jié)點的標(biāo)簽名)相同 -
isComment
是否同為注釋節(jié)點 -
是否
data
(當(dāng)前節(jié)點對應(yīng)的對象,包含了具體的一些數(shù)據(jù)信息,是一個VNodeData類型) -
當(dāng)標(biāo)簽是
<input>
的時候,type
必須相同
當(dāng)兩個VNode
的tag、key、isComment
都相同,并且同時定義或未定義data
的時候,且如果標(biāo)簽為input則type
必須相同。這時候這兩個VNode
則算sameVnode
,可以直接進(jìn)行patchVnode
操作。
patchVnode
下面我們再來詳細(xì)分析下patchVnode
方法。
// src/core/vdom/patch.js function patchVnode ( oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly ) { // 兩個vnode相同則直接返回 if (oldVnode === vnode) { return } if (isDef(vnode.elm) && isDef(ownerArray)) { // clone reused vnode vnode = ownerArray[index] = cloneVNode(vnode) } 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 } /* 如果新舊VNode都是靜態(tài)的,同時它們的key相同(代表同一節(jié)點), 并且新的VNode是clone或者是標(biāo)記了once(標(biāo)記v-once屬性,只渲染一次), 那么只需要替換componentInstance即可。 */ 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 /*調(diào)用prepatch鉤子*/ if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) { i(oldVnode, vnode) } // 獲取新老虛擬節(jié)點的子節(jié)點 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) } // 新節(jié)點不是文本節(jié)點 if (isUndef(vnode.text)) { /*新老節(jié)點均有children子節(jié)點,則對子節(jié)點進(jìn)行diff操作,調(diào)用updateChildren*/ if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) /*如果只有新節(jié)點有子節(jié)點,先清空elm文本內(nèi)容,然后為當(dāng)前DOM節(jié)點加入子節(jié)點。*/ } else if (isDef(ch)) { if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(ch) } if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) /*如果只有老節(jié)點有子節(jié)點,則移除elm所有子節(jié)點*/ } else if (isDef(oldCh)) { removeVnodes(oldCh, 0, oldCh.length - 1) /*當(dāng)新老節(jié)點都無子節(jié)點的時候,因為這個邏輯中新節(jié)點text不存在,所以直接去除ele的文本*/ } else if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, '') } // 新節(jié)點是文本節(jié)點,如果文本不一樣就設(shè)置新的文本 } else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text) } /*調(diào)用postpatch鉤子*/ if (isDef(data)) { if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode) } }
patchVnode
方法大概流程如下:
1.新老節(jié)點相同就直接返回。
2.如果新舊VNode都是靜態(tài)的,同時它們的key相同(代表同一節(jié)點),并且新的VNode是clone或者是標(biāo)記了once(標(biāo)記v-once屬性,只渲染一次),那么只需要替換componentInstance即可。
3.新節(jié)點不是文本節(jié)點,新老節(jié)點均有children
子節(jié)點,則對子節(jié)點進(jìn)行diff
操作,調(diào)用updateChildren
,這個updateChildren
是diff算法
的核心,后面我們會重點說。
4.新節(jié)點不是文本節(jié)點,如果老節(jié)點沒有子節(jié)點而新節(jié)點存在子節(jié)點,先清空老節(jié)點DOM的文本內(nèi)容,然后為當(dāng)前DOM節(jié)點加入子節(jié)點。
5.新節(jié)點不是文本節(jié)點,當(dāng)新節(jié)點沒有子節(jié)點而老節(jié)點有子節(jié)點的時候,則移除該DOM節(jié)點的所有子節(jié)點。
6.新節(jié)點不是文本節(jié)點,并且新老節(jié)點都無子節(jié)點的時候,只需要將老節(jié)點文本清空。
7.新節(jié)點是文本節(jié)點,并且新老節(jié)點文本不一樣,則進(jìn)行文本的替換。
updateChildren(diff算法核心)
updateChildren
是diff
算法的核心,下面我們來重點分析。
這兩張圖代表舊的VNode與新VNode進(jìn)行patch的過程,他們只是在同層級的VNode之間進(jìn)行比較得到變化(相同顏色的方塊代表互相進(jìn)行比較的VNode節(jié)點),然后修改變化的視圖,所以十分高效。所以Diff算法是:深度優(yōu)先算法
。 時間復(fù)雜度:O(n)
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { let oldStartIdx = 0 let 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, idxInOld, vnodeToMove, refElm const canMove = !removeOnly if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(newCh) } while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] // 老 VNode 節(jié)點的頭部與新 VNode 節(jié)點的頭部是相同的 VNode 節(jié)點,直接進(jìn)行 patchVnode,同時 oldStartIdx 與 newStartIdx 向后移動一位。 } else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] // 兩個 VNode 的結(jié)尾是相同的 VNode,同樣進(jìn)行 patchVnode 操作。并將 oldEndVnode 與 newEndVnode 向前移動一位。 } else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] // oldStartVnode 與 newEndVnode 符合 sameVnode 的時候, // 將 oldStartVnode.elm 這個節(jié)點直接移動到 oldEndVnode.elm 這個節(jié)點的后面即可。 // 然后 oldStartIdx 向后移動一位,newEndIdx 向前移動一位。 } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] // oldEndVnode 與 newStartVnode 符合 sameVnode 時, // 將 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。 // oldEndIdx 向前移動一位,newStartIdx 向后移動一位。 } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // createKeyToOldIdx 的作用是產(chǎn)生 key 與 index 索引對應(yīng)的一個 map 表 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) // 如果沒有找到相同的節(jié)點,則通過 createElm 創(chuàng)建一個新節(jié)點,并將 newStartIdx 向后移動一位。 if (isUndef(idxInOld)) { // New element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { vnodeToMove = oldCh[idxInOld] // 如果找到了節(jié)點,同時它符合 sameVnode,則將這兩個節(jié)點進(jìn)行 patchVnode,將該位置的老節(jié)點賦值 undefined // 同時將 newStartVnode.elm 插入到 oldStartVnode.elm 的前面 if (sameVnode(vnodeToMove, newStartVnode)) { patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) oldCh[idxInOld] = undefined canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // 如果不符合 sameVnode,只能創(chuàng)建一個新節(jié)點插入到 parentElm 的子節(jié)點中,newStartIdx 往后移動一位。 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } newStartVnode = newCh[++newStartIdx] } } // 當(dāng) while 循環(huán)結(jié)束以后,如果 oldStartIdx > oldEndIdx,說明老節(jié)點比對完了,但是新節(jié)點還有多的, // 需要將新節(jié)點插入到真實 DOM 中去,調(diào)用 addVnodes 將這些節(jié)點插入即可。 if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) // 如果滿足 newStartIdx > newEndIdx 條件,說明新節(jié)點比對完了,老節(jié)點還有多, // 將這些無用的老節(jié)點通過 removeVnodes 批量刪除即可。 } else if (newStartIdx > newEndIdx) { removeVnodes(oldCh, oldStartIdx, oldEndIdx) } }
vue2
的diff
算法采用的是雙端比較
,所謂雙端比較
就是新列表和舊列表兩個列表的頭與尾互相對比,在對比的過程中指針會逐漸向內(nèi)靠攏,直到某一個列表的節(jié)點全部遍歷過,對比停止。
首尾對比的四種情況
我們首先來看看首尾對比的四種情況。
-
使用舊列表的頭一個節(jié)點
oldStartNode
與新列表的頭一個節(jié)點newStartNode
對比 -
使用舊列表的最后一個節(jié)點
oldEndNode
與新列表的最后一個節(jié)點newEndNode
對比 -
使用舊列表的頭一個節(jié)點
oldStartNode
與新列表的最后一個節(jié)點newEndNode
對比 -
使用舊列表的最后一個節(jié)點
oldEndNode
與新列表的頭一個節(jié)點newStartNode
對比
首先是 oldStartVnode
與 newStartVnode
符合 sameVnode
時,說明老 VNode 節(jié)點的頭部與新 VNode 節(jié)點的頭部是相同的 VNode 節(jié)點,直接進(jìn)行 patchVnode
,同時 oldStartIdx
與 newStartIdx
向后移動一位。
其次是 oldEndVnode
與 newEndVnode
符合 sameVnode
,也就是兩個 VNode 的結(jié)尾是相同的 VNode,同樣進(jìn)行 patchVnode
操作并將 oldEndVnode
與 newEndVnode
向前移動一位。
接下來是兩種交叉的情況。
先是 oldStartVnode
與 newEndVnode
符合 sameVnode
的時候,也就是老 VNode 節(jié)點的頭部與新 VNode 節(jié)點的尾部是同一節(jié)點的時候,將 oldStartVnode.elm
這個節(jié)點直接移動到 oldEndVnode.elm
這個節(jié)點的后面即可。然后 oldStartIdx
向后移動一位,newEndIdx
向前移動一位。
同理,oldEndVnode
與 newStartVnode
符合 sameVnode
時,也就是老 VNode 節(jié)點的尾部與新 VNode 節(jié)點的頭部是同一節(jié)點的時候,將 oldEndVnode.elm
插入到 oldStartVnode.elm
前面。同樣的,oldEndIdx
向前移動一位,newStartIdx
向后移動一位。
最后是當(dāng)以上情況都不符合的時候,這種情況怎么處理呢?
查找對比
那就是查找對比。
首先通過createKeyToOldIdx
方法生成oldVnode
的key
與 index
索引對應(yīng)的一個 map
表。
然后我們根據(jù)newStartVnode.key
,可以快速地從 oldKeyToIdx
(createKeyToOldIdx
的返回值)中獲取相同 key
的節(jié)點的索引 idxInOld
,然后找到相同的節(jié)點。
這里又分三種情況
-
如果沒有找到相同的節(jié)點,則通過
createElm
創(chuàng)建一個新節(jié)點,并將newStartIdx
向后移動一位。 -
如果找到了節(jié)點,同時它符合
sameVnode
,則將這兩個節(jié)點進(jìn)行patchVnode
,將該位置的老節(jié)點賦值undefined
(之后如果還有新節(jié)點與該節(jié)點key相同可以檢測出來提示已有重復(fù)的 key ),同時將newStartVnode.elm
插入到oldStartVnode.elm
的前面。同理,newStartIdx
往后移動一位。
-
如果不符合
sameVnode
,只能創(chuàng)建一個新節(jié)點插入到parentElm
的子節(jié)點中,newStartIdx
往后移動一位。
添加、刪除節(jié)點
最后一步就很容易啦,當(dāng) while
循環(huán)結(jié)束以后,如果 oldStartIdx > oldEndIdx
,說明老節(jié)點比對完了,但是新節(jié)點還有多的,需要將新節(jié)點插入到真實 DOM 中去,調(diào)用 addVnodes
將這些節(jié)點插入即可。
同理,如果滿足 newStartIdx > newEndIdx
條件,說明新節(jié)點比對完了,老節(jié)點還有多,將這些無用的老節(jié)點通過 removeVnodes
批量刪除即可。
總結(jié)
Diff算法是一種對比算法。對比兩者是舊虛擬DOM和新虛擬DOM
,對比出是哪個虛擬節(jié)點
更改了,找出這個虛擬節(jié)點
,并只更新這個虛擬節(jié)點所對應(yīng)的真實節(jié)點
,而不用更新其他數(shù)據(jù)沒發(fā)生改變的節(jié)點,實現(xiàn)精準(zhǔn)
地更新真實DOM,進(jìn)而提高效率和性能
。
精準(zhǔn)
主要體現(xiàn)在,diff
算法首先就是找到可復(fù)用的節(jié)點,然后移動到正確的位置。當(dāng)元素沒有找到的話再來創(chuàng)建新節(jié)點。
擴展
vue中為什么需要使用key,它的作用是什么?
key
是 Vue
中 vnode
的唯一標(biāo)記,通過這個 key
,diff
操作可以更準(zhǔn)確、更快速。
- 更準(zhǔn)確:因為帶
key
就不是就地復(fù)用了,在sameNode
函數(shù)a.key === b.key
對比中可以避免就地復(fù)用的情況。所以會更加準(zhǔn)確。 - 更快速:利用
key
的唯一性生成map
對象來獲取對應(yīng)節(jié)點,比遍歷方式更快。
為什么不推薦使用index作為key
當(dāng)我們的列表只涉及到 展示,不涉及到排序、刪除、添加的時候使用index
作為key
是沒什么問題的。因為此時的index
在每個元素上是唯一的。
但是如果涉及到排序、刪除、添加的時候就不能再使用index
作為key
了,因為每個元素key
不再唯一了。不唯一的key
,對diff
算法沒有任何幫助,寫和沒寫是一樣的。
(學(xué)習(xí)視頻分享:web前端開發(fā)、編程基礎(chǔ)視頻)