寫完這個就差不多了,准備干新項目了。
確實挺不擅長寫東西,感覺都是羅列代碼寫點注釋的感覺,這篇就簡單闡述一下數據變動時DOM是如何更新的,主要講解下其中的diff算法。
先來個正常的html模板:
<body> <div id='app'> <div v-for="item in items">{{item}}</div> <div @click='click'>click me!</div> </div> </body> <script src='./vue.js'></script> <script> new Vue({ el: '#app', data: { items: [1] }, methods: { click: function() { this.items.push(2); } } })
頁面上有一個通過v-for渲染的div,還有一個按鈕,點擊按鈕時會讓div數量+1。
首先需要提到的是,每一次渲染DOM,都會保存一份當前虛擬DOM的副本掛載到_vnode屬性上,如圖:
點擊前,整個VNode結構為:根節點及3個子節點,子節點均包含2個div標簽和一個空白文本節點,div包含對應的文本節點。
點擊后,由於vue劫持了部分數組方法,所以會進入自定義的push方法中,將彈入的新元素進行廣播,過程就不看了。
完成數組添加后,會生成一個新的render函數與新的VNode,diff算法就是比較新舊VNode的差異,通過最小的變化操作渲染新的DOM。
講VNode的diff算法之前,有一個小點先講一下:如何判斷當前VNode可復用?
銷毀一個DOM節點並創建一個新的再插入是消耗非常大的,無論是DOM對象本身的復雜性還是操作引起的重繪重排,所以虛擬DOM的目標是盡可能復用現有DOM進行更新。
其中涉及的概念就是新的VNode能否在舊的基礎上修改並復用呢?有一個函數就是做這個判斷的:
function sameVnode(a, b) { return ( // key來源於v-for或者自定的:key屬性 a.key === b.key && a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b) ) }
該判斷有5重標准:
(1)key:key屬性如果沒有設置默認是undefined,當且僅當v-for的列表渲染中會給節點加一個唯一的key,形式如圖:,key不一樣的節點不進行復用,官方文檔也有說明設置key屬性可以強制重新生成一個新DOM。
(2)tag:復用的節點必須保證標簽名一致,畢竟沒有更改tag名的API
(3)isComment:注釋與普通的DOM不是一個次元,所以需要判斷
(4)isDef(*.data):這個涉及屬性的更新,如果一個節點沒有任何屬性,即data為undefined,與一個有data屬性的節點進行更新不如直接渲染一個新的
(5)sameInputType:這個主要是input標簽type屬性異同判斷,不同的type相當於不同的tag
如果均滿足,可以判定該節點可復用。
前面說了,每一個更改數據源,會生成一個新的VNode,來與舊的VNode進行比較,節點間的比較無非是判斷是否可復用,再進行屬性置換。
而diff算法主要是針對子節點的更新,即兩個數組之間的異同比較與更新。
一個數組的變化無非3個狀態:增、刪、改,但是其中增刪會涉及數組索引與對應元素的變動,總體來講還是比較復雜的。
源碼中有一個函數專門處理子節點比較,整體如下:
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { // var...
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 舊VNode不存在 if (isUndef(oldStartVnode)) { // ... } else if (isUndef(oldEndVnode)) { // ... } else if (sameVnode(oldStartVnode, newStartVnode)) { // ... } else if (sameVnode(oldEndVnode, newEndVnode)) { // ... } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // ... } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // ... } else { // ... } } if (oldStartIdx > oldEndIdx) { // ... } else if (newStartIdx > newEndIdx) { // ... } }
第一次看還是比較懵逼的,主路線while循環中有7重判斷,分別對應7種情況。
分解本例中的情況,不貼代碼,嘗試畫個圖:
對比新舊VNode,可以看出新的VNode在索引0的后面插入了一個新的tag
接下來通過updateChildren函數進行比較,有很多的變量,這里還需要一個圖:
在函數中有8個變量,其中4個舊VNode,4個新VNode,分別是一一對應的,解釋一半就行了:
var oldStartIdx = 0; var newStartIdx = 0; var oldEndIdx = oldCh.length - 1; var oldStartVnode = oldCh[0]; var oldEndVnode = oldCh[oldEndIdx]; var newEndIdx = newCh.length - 1; var newStartVnode = newCh[0]; var newEndVnode = newCh[newEndIdx];
(1)oldStartIdx => 從前往后的舊VNode數組索引,初始化時為0 => 簡稱為前索引
(2)oldStartVnode => 對應索引的舊VNode元素 => 簡稱為前元素
(3)oldEndIdx => 從后往前的舊VNode數組索引,初始化為children的數組長度 => 簡稱為后索引
(4)oldEndVnode => 對應索引的舊Vnode元素 => 簡稱為后元素
后面的闡述全部用簡稱,不然太難講了,並且新VNode的數組簡稱newCh,舊VNode的數組簡稱oldCh
另外4個變量只是將old更換為new,並對應新VNode的索引與元素。
接下來是一個大while循環,終止條件是前索引大於后索引(newCh或oldCh):
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { // ... } else if (isUndef(oldEndVnode)) { // ... } else if (sameVnode(oldStartVnode, newStartVnode)) { // ... } else if (sameVnode(oldEndVnode, newEndVnode)) { // ... } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // ... } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // ... } else { // ... } }
由於有幾種情況我模擬不出來,只能大概過一下。
1、isUndef(oldStartVnode)、isUndef(oldEndVnode)
前兩種是oldCh前元素或oldCh后元素不存在,我能模擬的情況是當oldCh中沒有元素時,會出現這種情況。
這時只是單純加前索引加1或者后索引減1,而oldCh長度此時為0,會立即跳出while循環,進入下一步。
2、sameVnode(a,b)
下面的的4種情況都是判斷節點是否可復用,然后進行更新。其中對比的情況有4對:
oldCh前元素 => newCh前元素
oldCh后元素 => newCh后元素
oldCh前元素 => newCh后元素
oldCh后元素 => newCh前元素
取第一種情況來說,如果比較通過,說明oldCh前元素可以被復用,隨即調用patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)來對DOM進行更新,由於tag是不變的,可以直接對DOM進行各種API調用,比如說事件更改,只要remove舊事件,add新事件就行,這里只是DOM對象的屬性更改,不會影響到DOM的增刪。
當patch完畢后,會將oldCh前索引及newCh的前索引加1,並更新對應的元素,然后進入下一輪循環。
畫一輪圖解釋:
此時第一個子節點已經更新完畢,然后重新開始對比,如果oldCh與newCh的索引1處也可復用,會再次更新並加1,直到前索引大於后索引時,說明所有可能的比較都進行完畢。
這里的4種比較沒有必要重復過一遍,如果是前索引就加1,后索引就減1。
3、else{...}
最后一種情況是需要強制更新元素時才會有的情況,比如:
<body> <div id='app'> <div v-if="!vIfIter" key='o'>old Ele1</div> <div v-if="vIfIter" key='n'>new Ele</div> <div @click='click'>click me!</div> </div> </body> <script src='./vue.js'></script> <script> new Vue({ el: '#app', data: { vIfIter: false }, methods: { click: function() { this.vIfIter = true; } } }) </script>
此時,由於設置了單獨的key值,所以div被標記為不可復用,跳過了所有判斷進入了else階段:
// 這里將舊VNode中剩余的元素key值作為對象輸出 if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); } // 判斷新VNode中是否存在可復用的元素 idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null; // 不存在就創建一個新的插入DOM中 if (isUndef(idxInOld)) { // New element } // 存在 else { elmToMove = oldCh[idxInOld]; if (sameVnode(elmToMove, newStartVnode)) { // 更新VNode patchVnode(elmToMove, newStartVnode, insertedVnodeQueue); // 把舊的VNode置空 此處會觸發到while循環的前兩個判斷 oldCh[idxInOld] = undefined; // 移動更新后的VNode canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm); newStartVnode = newCh[++newStartIdx]; } // 同樣的key值不同的tag 創建新DOM插入 else { // same key but different element. treat as new element } }
簡單來講還是可復用就復用,不可復用創建新DOM插入。
最后來看看while循環跳出來的語句,其實很簡單:
// VNode數量增加了 if (oldStartIdx > oldEndIdx) { // 如果VNode是中間插入就會存在refElm // 否則refElm為null 調用insertBefore會將DOM插入父元素尾部 refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm; addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue); } // 減少了 else if (newStartIdx > newEndIdx) { // 移除多出來的DOM節點 removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx); }
至此,所有的分析完了,上面的案例有興趣可以自己跑跑。