也看過其他講vue diff過程的文章,但是感覺都只是講了其中的一部分(對比方式),沒有對其中細節的部分做詳細的講解,如
- 匹配成功後進行的
patchVnode
是做了什麼?為什麼的有的緊接著要進行dom操作,有的沒有? - 在diff的過程中,指標的具體如何移動?及哪些部分發生了變化?
insertedVnodeQueue
又是何用?為何一直帶著?- 然後也是困惑很久的,很多文章在移動這部分直接操作的oldChildren,然而oldChildren會發生移動麼?那麼到底是誰發生了移動呢?
這裡並不會直接就開始講diff,為了讓大家能瞭解到diff的詳細過程,所在開始核心部分之前,有些簡單的概念和流程需要提前說明一下,當然最好是希望你已經對vue原始碼patch這部分有些瞭解。
幾個概念
由於核心是說明diff的過程,所以會先把diff涉及到的核心概念簡單說明一下,對於這些若仍有疑問可以在評論區留言:
1. vnode
簡單的說就是真實 dom 的描述物件,這也是vue的特點之一 - virtual dom。由於原生的dom結構過於複雜,當需要獲取並瞭解節點資訊的時候,並不需要操作複雜的 dom,相應的vue 是先用其描述物件進行分析(diff 對比也就是vnode的對比),然後再反應到真實的 dom。
export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
ns: string | void;
context: Component | void; // rendered in this component's scope
key: string | number | void;
componentOptions: VNodeComponentOptions | void;
componentInstance: Component | void; // component instance
parent: VNode | void; // component placeholder node
// strictly internal
raw: boolean; // contains raw HTML? (server only)
isStatic: boolean; // hoisted static node
isRootInsert: boolean; // necessary for enter transition check
isComment: boolean; // empty comment placeholder?
isCloned: boolean; // is a cloned node?
isOnce: boolean; // is a v-once node?
asyncFactory: Function | void; // async component factory function
asyncMeta: Object | void;
isAsyncPlaceholder: boolean;
ssrContext: Object | void;
functionalContext: Component | void; // real context vm for functional nodes
functionalOptions: ?ComponentOptions; // for SSR caching
functionalScopeId: ?string; // functioanl scope id support
constructor () {
...
}
}
複製程式碼
需要注意的是後面會涉及到的幾個屬性:
children
和parent
通過這個建立其vnode之間的層級關係,對應的也就是真實dom的層級關係text
如果存在值,證明該vnode對應的就是一個檔案節點,跟children是一個互斥的關係,不可能同時有值tag
表明當前vnode,對應真實 dom 的標籤名,如‘div’、‘p’elm
就是當前vnode對應的真實的dom
2. patch
閱讀原始碼中複雜函式的小技巧:看‘一頭’‘一尾’。‘頭’指的的入參,提煉出能看懂和能理解的引數(oldVnode
、vnode
、parentElm
),‘尾’指的是函式的處理結果,這個返回的elm
。所以可以根據‘頭尾’總結下,patch
完成之後,新的vnode
上會對應生成elm
,也就是真實的 dom,且是已經掛載到parentElm
下的dom。簡單的來說,如vue 例項初始化、資料更改導致的頁面更新等,都需要經過patch
方法來生成elm。
function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
// ...
const insertedVnodeQueue = []
// ...
if (isUndef(oldVnode)) {
isInitialPatch = true
createElm(vnode, insertedVnodeQueue, parentElm, refElm)
}
// ...
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
}
// ...
return vnode.elm
}
複製程式碼
patch 的過程(除去邊界條件)主要會有三種 case:
-
不存在 oldVnode,則進行
createElm
-
存在 oldVnode 和 vnode,但是
sameVnode
返回 false, 則進行createElm
-
存在 oldVnode 和 vnode,但是
sameVnode
返回 true, 則進行patchVnode
3. sameVnode
上面提到了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)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
複製程式碼
簡單的舉個的case,比如之前是一個<div>
標籤,由於邏輯的變動,變為<p>
標籤了,則sameVnode
會返回false
(a.tag === b.tag
返回 false)。所以sameVnode
表明的是,滿足以上條件就是同一個元素,才可進行patchVnode
。反過來理解就是,只要以上任意一個發生改變,則無需進行pathchVnode
,直接根據vnode
進行createElm
即可。
注意,sameVnode
返回true,不能說明是同一個vnode,這裡的相同是指當前的以上指標一致,他們的children可能發生了變化,仍需進行patchVnode
進行更新。
patchVnode
由patch
方法,我們知道patchVnode
方法和createElm
的方法最終的處理結果一樣,就是生成或更新了當前vnode對應的dom。
經過上面的分析,總結下,就是當需要生成 dom,且前後vnode進行sameVnode
為true
的情況下,則進行patchVnode
。
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
// ...
const elm = vnode.elm = oldVnode.elm
// ...
const oldCh = oldVnode.children
const ch = vnode.children
// ...
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)
}
// ...
}
複製程式碼
以上是patchVnode
的部分程式碼,展示出來的這部分邏輯,也是patchVnode
的核心處理邏輯。
以上程式碼,充斥大量的if
else
,大家可以思考幾個問題?
- 根據以上程式碼分析,對於一個vnode,可分成三種vnode: 文字vnode、存在chilren的vnode、不存在children的vnode。對於oldVnode和vnode交叉組合的話,應該會有9種 case,那麼以上的程式碼有全部覆蓋所有 case 麼?
- 那比如,具體哪些
case
會進入到removeVnodes
的邏輯?
這其實也是我在閱讀的時候思考的問題,最終我採用了以下的方式(對著程式碼繪製表格)來解決這種複雜的if
else
邏輯的解讀:
oldVnode.text | oldCh | !oldCh | |
---|---|---|---|
vnode.text | setTextContent | setTextContent | setTextContent |
ch | addVnodes | updateChildren | addVnodes |
!ch | setTextContent | removeVnodes | setTextContent |
對應著表格,然後對應著程式碼,相信你能找到答案。
updateChildren
經過上面的分析,只有在oldCh
和ch
都存在的情況下才會執行updateChildren
,此時入參是oldCh
和ch
,所以可以知道的是,updateChildren
進行的是同層級下的children
的更新比較,也就是‘傳說中的’diff了。
開始分析之前,可以思考下:若現在js來操作原生dom的一個<ul>
列表,當然這個列表也是用原生的js來實現的,現在如果其中的資料順序發生了變化,第一條要排到末尾或具體的某個位置,或者有新增資料、刪除資料等,該如何操作。
let listData = [
'測試資料1',
'測試資料2',
'測試資料3',
'測試資料4',
'測試資料5',
]
let ulElm = document.createElement('ul');
let liStr = '';
for(let i = 0; i < listData.length; i++){
liStr += `<li>${listData[i]}</li>`
}
ulElm.append(liStr)
document.body.innerHTML = ''
document.body.append(ulElm)
複製程式碼
這個時候由於變化的不確定性,不希望在業務程式碼邏輯中維護繁瑣的insertBefore
、appendChild
、removeChild
、replaceChild
,立馬能想到的粗暴的解決方式是,我們拿到最新的listData
,把上面面建立的流程再走一遍。
然而vue採取的是diff演算法,簡單的說就是:
- 還是和上面一樣,依然先獲取到最新的
listData
- 然後新的 data 進行
_render
操作,得到新的vnode - 對比前後vnode,也就是patch過程
- 對於同一層級的節點,會進行
updateChildren
操作(diff),進行最小的變動
diff
updateChildren
程式碼如下:
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
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} 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)) { // Vnode moved right
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)) { // Vnode moved left
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)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
} else {
vnodeToMove = oldCh[idxInOld]
/* istanbul ignore if */
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 {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
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(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
複製程式碼
之前分析了,oldCh
和ch
表示的是同層級的vnode的列表,也就是兩個陣列
開始之前定義了一系列的變數,分別如下:
oldStartIdx
開始指標,指向oldCh中待處理部分的頭部,對應的vnode也就是oldStartVnode
oldEndIdx
結束指標,指向oldCh中待處理部分的尾部,對應的vnode也就是oldEndVnode
newStartIdx
開始指標,指向ch中待處理部分的頭部,對應的vnode也就是newStartVnode
newEndIdx
結束指標,指向ch中待處理部分的尾部,對應的vnode也就是newEndVnode
oldKeyToIdx
是一個map,其中key就是常在for迴圈中寫的v-bind:key
的值,value 對應的就是當前vnode,也就是可以通過唯一的key,在map中找到對應的vnode
updateChildren
使用的是while迴圈來更新dom的,其中的退出條件就是!(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
,換種理解方式:oldStartIdx > oldEndIdx || newStartIdx > newEndIdx
,什麼意思呢,就是隻要有一個發生了‘交叉’(下面的例子會出現交叉)就退出迴圈。
舉個例子
原有的oldCh的順序是 A 、B、C、D、E、F、G,更新後成ch的順序 F、D、A、H、E、C、B、G。
圖解說明
為了更好理解後續的round,開始之前先看下相關符合標記的說明
diff的過程
round1: 對比順序:A-F -> G-G,匹配成功,然後:
- 對G進行
patchVnode
的操作,更新oldEndVnode
G和newEndVnode
G的elm - 指標移動,兩個尾部指標向左移動,即
oldEndIdx--
newEndIdx--
round2: 對比順序:A-F -> F-B -> A-B -> F-F,匹配成功,然後:
- 對F進行
patchVnode
的操作,更新oldEndVnode
F和newEndVnode
F的elm - 指標移動,移動指標,即
oldEndIdx--
newStartIdx++
- 找到
oldStartVnode
在dom中所在的位置A,然後在其前面插入更新過的F的elm
round3:
對比順序:A-D -> E-B -> A-B -> E-D,仍未成功,取D的key,在oldKeyToIdx
中查詢,找到對應的D,查詢成功,然後:
- 將D取出賦值到
vnodeToMove
- 對D進行
patchVnode
的操作,更新vnodeToMove
D和newStartVnode
D的elm - 指標移動,移動指標,即
newStartIdx++
- 將oldCh中對應D的vnode置
undefined
- 在dom中找到
oldStartVnode
A的elm對應的節點,然後在其前面插入更新過的D的elm
round4: 對比順序:A-A,對比成功,然後:
- 對A進行
patchVnode
的操作,更新oldStartVnode
A和newStartVnode
A的elm - 指標移動,兩個尾部指標向左移動,即
oldStartIdx++
newStartIdx++
round5: 對比順序:B-H -> E-B -> B-B ,對比成功,然後:
- 對B進行
patchVnode
的操作,更新oldStartVnode
B和newStartVnode
B的elm - 指標移動,即
oldStartIdx++
newEndIdx--
- 在dom中找到
oldEndVnode
E的elm的nextSibling
節點(即G的elm),然後在其前面插入更新過的B的elm
round6: 對比順序:C-H -> E-C -> C-C ,對比成功,然後(同round5):
- 對C進行
patchVnode
的操作,更新oldStartVnode
C和newStartVnode
C的elm - 指標移動,即
oldStartIdx++
newEndIdx--
- 在dom中找到
oldEndVnode
E的elm的nextSibling
節點(即剛剛插入的B的elm),然後在其前面插入更新過的C的elm
round7: 獲取oldStartVnode失敗(因為round3的步驟4),然後:
- 指標移動,即
oldStartIdx++
round8: 對比順序:E-H、E-E,匹配成功,然後(同round1):
- 對E進行
patchVnode
的操作,更新oldEndVnode
E和newEndVnode
E的elm - 指標移動,兩個尾部指標向左移動,即
oldEndIdx--
newEndIdx--
last round8之後oldCh提前發生了‘交叉’,退出迴圈。
last:- 找到
newEndIdx+1
對應的元素A - 待處理的部分(即
newStartIdx
-newEndIdx
中的vnode)則為新增的部分,無需patch,直接進行createElm
- 所有的這些待處理的部分,都會插到步驟1中dom中A的elm所在位置的後面
需要注意的點:
- oldCh和ch在過程中他們的位置並不會發生變化
- 真正進行操作的是進入
updateChildren
傳入的parentElm
,即父vnode的elm - while每一次的迴圈體,我稱之為回和,也就是round
- 多次提到
patchVnode
,往前看patchVnode
的部分,其處理的結果就是oldVnode.elm和vnode.elm得到了更新 - 有多次的原生的dom的操作,
insertBefore
,重點是要先找到插入的地方
總結
每一個round(以上例子中涉及到的)做的事情如下(優先順序從上至下):
- 無
oldStartVnode
則移動(參照round6) - 對比頭部,成功則更新並移動(參照round4)
- 對比尾部,成功則更新並移動(參照round1)
- 頭尾對比,成功則更新並移動(參照round5)
- 尾頭對比,成功則更新並移動(參照round2)
- 在
oldKeyToIdx
中根據newStartVnode
的可以進行查詢,成功則更新並移動(參照round3) (更新並移動:patchVnode更新對應vnode的elm,並移動指標)
關於插入的問題,為何有的緊接著進行的dom操作,有的沒有?何時在oldStartVnode
的elm前插,何時在oldEndVnode
的elm的nextSibling
前插?
這裡只要記住,oldCh
和ch
都是參照物,其中,ch
是我們的目標順序,而oldCh
是我們用來了解當前dom順序的參照,也就是開篇提到的vnode的介紹。所以整個diff過程,就是對比oldCh
和ch
,確認當前round,oldCh
如何移動更靠近ch
,由於oldCh
中待處理的部分仍在dom中,所以可以根據oldCh
中的oldStartVnode
的elm和 oldEndVnode
的elm的位置,來確定匹配成功的元素該如何插入。
- ‘頭頭’匹配成功的時候,證明當前
oldStartVnode
位置正是現在的位置,無需移動,進行patchVnode
更新即可 - ‘尾尾’匹配成功同‘頭頭’匹配成功,也無需移動
- 若‘尾頭匹配成功’,即
oldEndVnode
與newSatrtVnode
匹配成功,這裡注意成功的是newSatrtVnode
,所以是在待處理dom的頭部前插。如round2,當前待處理的部分,也就是oldCh
中黑塊的部分,頭部也就是oldStartVnode
。也就是在oldStartVnode
的elm前面插入newSatrtVnode
的elm。 - 同理,若‘頭尾匹配成功’,即
oldStartVnode
與newEndVnode
匹配成功,這裡注意成功的是newEndVnode
,所以是在待處理dom的尾部插入(就是尾部元素的下一個元素前插)。如round5,當前待處理的部分,也就是oldCh
中黑塊的部分,尾部也就是oldEndVnode
。也就是先找到oldEndVnode
的elm的nextSibling
前面插入newEndVnode
的elm。
(這裡有提到‘待處理塊’,具體大家可以看示意圖,注意oldCh
中的待處理塊部分和dom中待處理的部分)
以上已經包含updateChildren
中大部分的內容了,當然還有部分沒有涉及到的就不一一說明的,具體的大家可以對著原始碼,找個例項走整個的流程即可。
最後還有一個問題沒回答,insertedVnodeQueue
有何用?為啥一直帶著?
這部分涉及到元件的patch的過程,這裡可以簡單說下:元件的$mount
函式之後之後並不會立即觸發元件例項的mounted
鉤子,而是把當前例項push
到insertedVnodeQueue
中,然後在patch的倒數第二行,會執行invokeInsertHook
,也就是觸發所有元件例項的insert
的鉤子,而元件的insert
鉤子函式中才會觸發元件例項的mounted
鉤子。比方說,在patch的過程中,patch了多個元件vnode,他們都進行了$mount
即生成dom,但沒有立即觸發$mounted
,而是等整個patch
完成,再逐一觸發。