前言:隨之vue3.0beta版本的發佈,vue3.0正式版本相信不久就會與我們相遇。尤玉溪在直播中也說了vue3.0的新特性typescript強烈支持,proxy響應式原理,重新虛擬dom,優化diff算法性能提升等等。小編在這裏仔細研究了vue3.0beta版本diff算法的源碼,並希望把其中的細節和奧妙和大家一起分享。
首先我們來思考一些大中廠面試中,很容易問到的問題:
1 什麼時候用到diff算法,diff算法作用域在哪裏?
2 diff算法是怎麼運作的,到底有什麼作用?
3 在v-for 循環列表 key 的作用是什麼
4 用索引index做key真的有用? 到底用什麼做key纔是最佳方案。
如果遇到這些問題,大家是怎麼回答的呢?我相信當你讀完這篇文章,這些問題也會迎刃而解。
一 什麼時候用到了diff算法,diff算法作用域?
1.1diff算法的作用域
patch概念引入
在vue update過程中在遍歷子代vnode的過程中,會用不同的patch方法來patch新老vnode,如果找到對應的 newVnode 和 oldVnode,就可以複用利用裏面的真實dom節點。避免了重複創建元素帶來的性能開銷。畢竟瀏覽器創造真實的dom,操縱真實的dom,性能代價是昂貴的。
patch過程中,如果面對當前vnode存在有很多chidren的情況,那麼需要分別遍歷patch新的children Vnode和老的 children vnode。
存在chidren的vnode類型
首先思考一下什麼類型的vnode會存在children。
①element元素類型vnode
第一中情況就是element類型vnode 會存在 children vode,此時的三個span標籤就是chidren vnode情況
<div>
<span> 蘋果🍎 </span>
<span> 香蕉🍌 </span>
<span> 鴨梨🍐 </span>
</div>
在vue3.0源碼中 ,patchElement用於處理element類型的vnode
②flagment碎片類型vnode
在Vue3.0中,引入了一個fragment碎片概念。
你可能會問,什麼是碎片?如果你創建一個Vue組件,那麼它只能有一個根節點。
<template>
<span> 蘋果🍎 </span>
<span> 香蕉🍌 </span>
<span> 鴨梨🍐 </span>
</template>
這樣可能會報出警告,原因是代表任何Vue組件的Vue實例需要綁定到一個單一的DOM元素中。唯一可以創建一個具有多個DOM節點的組件的方法就是創建一個沒有底層Vue實例的功能組件。
flagment出現就是用看起來像一個普通的DOM元素,但它是虛擬的,根本不會在DOM樹中呈現。這樣我們可以將組件功能綁定到一個單一的元素中,而不需要創建一個多餘的DOM節點。
<Fragment>
<span> 蘋果🍎 </span>
<span> 香蕉🍌 </span>
<span> 鴨梨🍐 </span>
</Fragment>
在vue3.0源碼中 ,processFragment用於處理Fragment類型的vnode
1.2 patchChildren
從上文中我們得知了存在children的vnode類型,那麼存在children就需要patch每一個
children vnode依次向下遍歷。那麼就需要一個patchChildren方法,依次patch子類vnode。
patchChildren
vue3.0中 在patchChildren方法中有這麼一段源碼
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
/* 對於存在key的情況用於diff算法 */
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
/* 對於不存在key的情況,直接patch */
patchUnkeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
return
}
}
patchChildren根據是否存在key進行真正的diff或者直接patch。
既然diff算法存在patchChildren方法中,而patchChildren方法用在Fragment類型和element類型的vnode中,這樣也就解釋了diff算法的作用域是什麼。
1.3 diff算法作用?
通過前言我們知道,存在這children的情況的vnode,需要通過patchChildren遍歷children依次進行patch操作,如果在patch期間,再發現存在vnode情況,那麼會遞歸的方式依次向下patch,那麼找到與新的vnode對應的vnode顯的如此重要。
我們用兩幅圖來向大家展示vnode變化。
如上兩幅圖表示在一次更新中新老dom樹變化情況。
假設不存在diff算法,依次按照先後順序patch會發生什麼
如果不存在diff算法,而是直接patchchildren 就會出現如下圖的邏輯。
第一次patchChidren
第二次patchChidren
第三次patchChidren‘
第四次patchChidren
如果沒有用到diff算法,而是依次patch虛擬dom樹,那麼如上稍微修改dom順序,就會在patch過程中沒有一對正確的新老vnode,所以老vnode的節點沒有一個可以複用,這樣就需要重新創造新的節點,浪費了性能開銷,這顯然不是我們需要的。
那麼diff算法的作用就來了。
diff作用就是在patch子vnode過程中,找到與新vnode對應的老vnode,複用真實的dom節點,避免不必要的性能開銷
二 diff算法具體做了什麼(重點)?
在正式講diff算法之前,在patchChildren的過程中,存在 patchKeyedChildren
patchUnkeyedChildren
patchKeyedChildren 是正式的開啓diff的流程,那麼patchUnkeyedChildren的作用是什麼呢? 我們來看看針對沒有key的情況patchUnkeyedChildren會做什麼。
c1 = c1 || EMPTY_ARR
c2 = c2 || EMPTY_ARR
const oldLength = c1.length
const newLength = c2.length
const commonLength = Math.min(oldLength, newLength)
let i
for (i = 0; i < commonLength; i++) { /* 依次遍歷新老vnode進行patch */
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
patch(
c1[i],
nextChild,
container,
null,
parentComponent,
parentSuspense,
isSVG,
optimized
)
}
if (oldLength > newLength) { /* 老vnode 數量大於新的vnode,刪除多餘的節點 */
unmountChildren(c1, parentComponent, parentSuspense, true, commonLength)
} else { /* /* 老vnode 數量小於於新的vnode,創造新的即誒安 */
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized,
commonLength
)
}
我們可以得到結論,對於不存在key情況
① 比較新老children的length獲取最小值 然後對於公共部分,進行從新patch工作。
② 如果老節點數量大於新的節點數量 ,移除多出來的節點。
③ 如果新的節點數量大於老節點的數量,從新 mountChildren新增的節點。
那麼對於存在key情況呢? 會用到diff算法 , diff算法做了什麼呢?
patchKeyedChildren方法究竟做了什麼?
我們先來看看一些聲明的變量。
/* c1 老的vnode c2 新的vnode */
let i = 0 /* 記錄索引 */
const l2 = c2.length /* 新vnode的數量 */
let e1 = c1.length - 1 /* 老vnode 最後一個節點的索引 */
let e2 = l2 - 1 /* 新節點最後一個節點的索引 */
①第一步從頭開始向尾尋找
(a b) c
(a b) d e
/* 從頭對比找到有相同的節點 patch ,發現不同,立即跳出*/
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
/* 判斷key ,type是否相等 */
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
parentAnchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
} else {
break
}
i++
}
第一步的事情就是從頭開始尋找相同的vnode,然後進行patch,如果發現不是相同的節點,那麼立即跳出循環。
具體流程如圖所示
isSameVNodeType
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
return n1.type === n2.type && n1.key === n2.key
}
isSameVNodeType 作用就是判斷當前vnode類型 和 vnode的 key是否相等
②第二步從尾開始同前diff
a (b c)
d e (b c)
/* 如果第一步沒有patch完,立即,從後往前開始patch ,如果發現不同立即跳出循環 */
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
parentAnchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
} else {
break
}
e1--
e2--
}
經歷第一步操作之後,如果發現沒有patch完,那麼立即進行第二部,從尾部開始遍歷依次向前diff。
如果發現不是相同的節點,那麼立即跳出循環。
具體流程如圖所示
③④主要針對新增和刪除元素的情況,前提是元素沒有發生移動, 如果有元素髮生移動就要走⑤邏輯。
③ 如果老節點是否全部patch,新節點沒有被patch完,創建新的vnode
(a b)
(a b) c
i = 2, e1 = 1, e2 = 2
(a b)
c (a b)
i = 0, e1 = -1, e2 = 0
/* 如果新的節點大於老的節點數 ,對於剩下的節點全部以新的vnode處理( 這種情況說明已經patch完相同的vnode ) */
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
while (i <= e2) {
patch( /* 創建新的節點*/
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG
)
i++
}
}
}
i > e1
如果新的節點大於老的節點數 ,對於剩下的節點全部以新的vnode處理( 這種情況說明已經patch完相同的vnode ),也就是要全部create新的vnode.
具體邏輯如圖所示
④ 如果新節點全部被patch,老節點有剩餘,那麼卸載所有老節點
i > e2
(a b) c
(a b)
i = 2, e1 = 2, e2 = 1
a (b c)
(b c)
i = 0, e1 = 0, e2 = -1
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
對於老的節點大於新的節點的情況 ,對於超出的節點全部卸載 ( 這種情況說明已經patch完相同的vnode )
具體邏輯如圖所示
⑤ 不確定的元素 ( 這種情況說明沒有patch完相同的vnode ),我們可以接着①②的邏輯繼續往下看
diff核心
在①②情況下沒有遍歷完的節點如下圖所示。
剩下的節點。
const s1 = i //第一步遍歷到的index
const s2 = i
const keyToNewIndexMap: Map<string | number, number> = new Map()
/* 把沒有比較過的新的vnode節點,通過map保存 */
for (i = s2; i <= e2; i++) {
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
let j
let patched = 0
const toBePatched = e2 - s2 + 1 /* 沒有經過 path 新的節點的數量 */
let moved = false /* 證明是否 */
let maxNewIndexSoFar = 0
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
/* 建立一個數組,每個子元素都是0 [ 0, 0, 0, 0, 0, 0, ] */
遍歷所有新節點把索引和對應的key,存入map keyToNewIndexMap中
keyToNewIndexMap 存放 key -> index 的map
D : 2
E : 3
C : 4
I : 5
接下來聲明一個新的指針 j,記錄剩下新的節點的索引。
patched ,記錄在第⑤步patched新節點過的數量
toBePatched 記錄⑤步之前,沒有經過patched 新的節點的數量。
moved代表是否發生過移動,咱們的demo是已經發生過移動的。
newIndexToOldIndexMap 用來存放新節點索引和老節點索引的數組。
newIndexToOldIndexMap 數組的index是新vnode的索引 , value是老vnode的索引。
接下來
for (i = s1; i <= e1; i++) { /* 開始遍歷老節點 */
const prevChild = c1[i]
if (patched >= toBePatched) { /* 已經patch數量大於等於, */
/* ① 如果 toBePatched新的節點數量爲0 ,那麼統一卸載老的節點 */
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex
/* ② 如果,老節點的key存在 ,通過key找到對應的index */
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else { /* ③ 如果,老節點的key不存在 */
for (j = s2; j <= e2; j++) { /* 遍歷剩下的所有新節點 */
if (
newIndexToOldIndexMap[j - s2] === 0 && /* newIndexToOldIndexMap[j - s2] === 0 新節點沒有被patch */
isSameVNodeType(prevChild, c2[j] as VNode)
) { /* 如果找到與當前老節點對應的新節點那麼 ,將新節點的索引,賦值給newIndex */
newIndex = j
break
}
}
}
if (newIndex === undefined) { /* ①沒有找到與老節點對應的新節點,刪除當前節點,卸載所有的節點 */
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
/* ②把老節點的索引,記錄在存放新節點的數組中, */
newIndexToOldIndexMap[newIndex - s2] = i + 1
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
/* 證明有節點已經移動了 */
moved = true
}
/* 找到新的節點進行patch節點 */
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
optimized
)
patched++
}
}
這段代碼算是diff算法的核心。
第一步: 通過老節點的key找到對應新節點的index:開始遍歷老的節點,判斷有沒有key, 如果存在key通過新節點的keyToNewIndexMap找到與新節點index,如果不存在key那麼會遍歷剩下來的新節點試圖找到對應index。
第二步:如果存在index證明有對應的老節點,那麼直接複用老節點進行patch,沒有找到與老節點對應的新節點,刪除當前老節點。
第三步:newIndexToOldIndexMap找到對應新老節點關係。
到這裏,我們patch了一遍,把所有的老vnode都patch了一遍。
如圖所示
但是接下來的問題。
1 雖然已經patch過所有的老節點。可以對於已經發生移動的節點,要怎麼真正移動dom元素。
2 對於新增的節點,(圖中節點I)並沒有處理,應該怎麼處理。
/*移動老節點創建新節點*/
/* 根據最長穩定序列移動相對應的節點 */
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
j = increasingNewIndexSequence.length - 1
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) { /* 沒有老的節點與新的節點對應,則創建一個新的vnode */
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG
)
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) { /*如果沒有在長*/
/* 需要移動的vnode */
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
⑥最長穩定序列
首選通過getSequence得到一個最長穩定序列,對於index === 0 的情況也就是新增節點(圖中I) 需要從新mount一個新的vnode,然後對於發生移動的節點進行統一的移動操作
什麼叫做最長穩定序列
對於以下的原始序列
0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15
最長遞增子序列爲
0, 2, 6, 9, 11, 15.
爲什麼要得到最長穩定序列
因爲我們需要一個序列作爲基礎的參照序列,其他未在穩定序列的節點,進行移動。
總結
經過上述我們大致知道了diff算法的流程
1 從頭對比找到有相同的節點 patch ,發現不同,立即跳出。
2如果第一步沒有patch完,立即,從後往前開始patch ,如果發現不同立即跳出循環。
3如果新的節點大於老的節點數 ,對於剩下的節點全部以新的vnode處理( 這種情況說明已經patch完相同的vnode )。
4 對於老的節點大於新的節點的情況 , 對於超出的節點全部卸載 ( 這種情況說明已經patch完相同的vnode )。
5不確定的元素( 這種情況說明沒有patch完相同的vnode ) 與 3 ,4對立關係。
1 把沒有比較過的新的vnode節點,通過map保存
記錄已經patch的新節點的數量 patched
沒有經過 path 新的節點的數量 toBePatched
建立一個數組newIndexToOldIndexMap,每個子元素都是[ 0, 0, 0, 0, 0, 0, ] 裏面的數字記錄老節點的索引 ,數組索引就是新節點的索引
開始遍歷老節點
① 如果 toBePatched新的節點數量爲0 ,那麼統一卸載老的節點
② 如果,老節點的key存在 ,通過key找到對應的index
③ 如果,老節點的key不存在
1 遍歷剩下的所有新節點
2 如果找到與當前老節點對應的新節點那麼 ,將新節點的索引,賦值給newIndex
④ 沒有找到與老節點對應的新節點,卸載當前老節點。
⑤ 如果找到與老節點對應的新節點,把老節點的索引,記錄在存放新節點的數組中,
1 如果節點發生移動 記錄已經移動了
2 patch新老節點 找到新的節點進行patch節點
遍歷結束
如果發生移動
① 根據 newIndexToOldIndexMap 新老節點索引列表找到最長穩定序列
② 對於 newIndexToOldIndexMap -item =0 證明不存在老節點 ,從新形成新的vnode
③ 對於發生移動的節點進行移動處理。
三 key的作用,如何正確key。
1key的作用
在我們上述diff算法中,通過isSameVNodeType方法判斷,來判斷key是否相等判斷新老節點。
那麼由此我們可以總結出?
在v-for循環中,key的作用是:通過判斷newVnode和OldVnode的key是否相等,從而複用與新節點對應的老節點,節約性能的開銷。
2如何正確使用key
①錯誤用法 1:用index做key。
用index做key的效果實際和沒有用diff算法是一樣的,爲什麼這麼說呢,下面我就用一幅圖來說明:
如果所示當我們用index作爲key的時候,無論我們怎麼樣移動刪除節點,到了diff算法中都會從頭到尾依次patch(圖中:所有節點均未有效的複用)
②錯誤用法2 :用index拼接其他值作爲key。
當已用index拼接其他值作爲索引的時候,因爲每一個節點都找不到對應的key,導致所有的節點都不能複用,所有的新vnode都需要重新創建。都需要重新create
如圖所示。
③正確用法 :用唯一值id做key(我們可以用前後端交互的數據源的id爲key)。
如圖所示。每一個節點都做到了複用。起到了diff算法的真正作用。
四 總結
我們在上面,已經把剛開始的問題統統解決了,最後用一張思維腦圖來從新整理一下整個流程。diff算法,你學會了嗎?
微信掃碼關注公衆號,定期分享技術文章