您好,登錄后才能下訂單哦!
這篇文章主要介紹了Vue的雙端diff算法怎么實現(xiàn)的相關(guān)知識,內(nèi)容詳細(xì)易懂,操作簡單快捷,具有一定借鑒價值,相信大家閱讀完這篇Vue的雙端diff算法怎么實現(xiàn)文章都會有所收獲,下面我們一起來看看吧。
Vue 和 React 都是基于 vdom 的前端框架,組件渲染會返回 vdom,渲染器再把 vdom 通過增刪改的 api 同步到 dom。
當(dāng)再次渲染時,會產(chǎn)生新的 vdom,渲染器會對比兩棵 vdom 樹,對有差異的部分通過增刪改的 api 更新到 dom。
這里對比兩棵 vdom 樹,找到有差異的部分的算法,就叫做 diff 算法。
我們知道,兩棵樹做 diff,復(fù)雜度是 O(n^3) 的,因為每個節(jié)點都要去和另一棵樹的全部節(jié)點對比一次,這就是 n 了,如果找到有變化的節(jié)點,執(zhí)行插入、刪除、修改也是 n 的復(fù)雜度。所有的節(jié)點都是這樣,再乘以 n,所以是 O(n * n * n) 的復(fù)雜度。
這樣的復(fù)雜度對于前端框架來說是不可接受的,這意味著 1000 個節(jié)點,渲染一次就要處理 1000 * 1000 * 1000,一共 10 億次。
所以前端框架的 diff 約定了兩種處理原則:只做同層的對比,type 變了就不再對比子節(jié)點。
因為 dom 節(jié)點做跨層級移動的情況還是比較少的,一般情況下都是同一層級的 dom 的增刪改。
這樣只要遍歷一遍,對比一下 type 就行了,是 O(n) 的復(fù)雜度,而且 type 變了就不再對比子節(jié)點,能省下一大片節(jié)點的遍歷。另外,因為 vdom 中記錄了關(guān)聯(lián)的 dom 節(jié)點,執(zhí)行 dom 的增刪改也不需要遍歷,是 O(1)的,整體的 diff 算法復(fù)雜度就是 O(n) 的復(fù)雜度。
1000 個節(jié)點渲染一次最多對比 1000 次,這樣的復(fù)雜度就是可接受的范圍了。但是這樣的算法雖然復(fù)雜度低了,卻還是存在問題的。
比如一組節(jié)點,假設(shè)有 5 個,類型是 ABCDE,下次渲染出來的是 EABCD,這時候逐一對比,發(fā)現(xiàn) type 不一樣,就會重新渲染這 5 個節(jié)點。
而且根據(jù) type 不同就不再對比子節(jié)點的原則,如果這些節(jié)點有子節(jié)點,也會重新渲染。dom 操作是比較慢的,這樣雖然 diff 的算法復(fù)雜度是低了,重新渲染的性能也不高。
所以,diff 算法除了考慮本身的時間復(fù)雜度之外,還要考慮一個因素:dom 操作的次數(shù)。
上面那個例子的 ABCDE 變?yōu)?EABCD,很明顯只需要移動一下 E 就行了,根本不用創(chuàng)建新元素。
但是怎么對比出是同個節(jié)點發(fā)生了移動呢?
判斷 type 么? 那不行,同 type 的節(jié)點可能很多,區(qū)分不出來的。最好每個節(jié)點都是有唯一的標(biāo)識。
所以當(dāng)渲染一組節(jié)點的時候,前端框架會讓開發(fā)者指定 key,通過 key 來判斷是不是有點節(jié)點只是發(fā)生了移動,從而直接復(fù)用。這樣,diff 算法處理一組節(jié)點的對比的時候,就要根據(jù) key 來再做一次算法的優(yōu)化。我們會把基于 key 的兩組節(jié)點的 diff 算法叫做多節(jié)點 diff 算法,它是整個 vdom 的 diff 算法的一部分。
接下來我們來學(xué)習(xí)一下多節(jié)點 diff 算法:
假設(shè)渲染 ABCD 一組節(jié)點,再次渲染是 DCAB,這時候怎么處理呢?
多節(jié)點 diff 算法的目的是為了盡量復(fù)用節(jié)點,通過移動節(jié)點代替創(chuàng)建。
所以新 vnode 數(shù)組的每個節(jié)點我們都要找下在舊 vnode 數(shù)組中有沒有對應(yīng) key 的,有的話就移動到新的位置,沒有的話再創(chuàng)建新的。
也就是這樣的:
const oldChildren = n1.children const newChildren = n2.children let lastIndex = 0 // 遍歷新的 children for (let i = 0; i < newChildren.length; i++) { const newVNode = newChildren[i] let j = 0 let find = false // 遍歷舊的 children for (j; j < oldChildren.length; j++) { const oldVNode = oldChildren[j] // 如果找到了具有相同 key 值的兩個節(jié)點,則調(diào)用 patch 函數(shù)更新 if (newVNode.key === oldVNode.key) { find = true patch(oldVNode, newVNode, container) 處理移動... break //跳出循環(huán),處理下一個節(jié)點 } } // 沒有找到就是新增了 if (!find) { const prevVNode = newChildren[i - 1] let anchor = null if (prevVNode) { anchor = prevVNode.el.nextSibling } else { anchor = container.firstChild } patch(null, newVNode, container, anchor) } }
這里的 patch 函數(shù)的作用是更新節(jié)點的屬性,重新設(shè)置事件監(jiān)聽器。如果沒有對應(yīng)的舊節(jié)點的話,就是插入節(jié)點,需要傳入一個它之后的節(jié)點作為錨點 anchor。
我們遍歷處理新的 vnode:
先從舊的 vnode 數(shù)組中查找對應(yīng)的節(jié)點,如果找到了就代表可以復(fù)用,接下來只要移動就好了。如果沒找到,那就執(zhí)行插入,錨點是上一個節(jié)點的 nextSibling。
那如果找到了可復(fù)用的節(jié)點之后,那移動到哪里呢?其實新的 vnode 數(shù)組中記錄的順序就是目標(biāo)的順序。所以把對應(yīng)的節(jié)點按照新 vnode 數(shù)組的順序來移動就好了。
const prevVNode = newChildren[i - 1] if (prevVNode) { const anchor = prevVNode.el.nextSibling insert(newVNode.el, container, anchor) }
要插入到 i 的位置,那就要取 i-1 位置的節(jié)點的 nextSibling 做為錨點來插入當(dāng)前節(jié)點。
但是并不是所有的節(jié)點都需要移動,比如處理到第二個新的 vnode,發(fā)現(xiàn)它在舊的 vnode 數(shù)組中的下標(biāo)為 4,說明本來就是在后面了,那就不需要移動了。反之,如果是 vnode 查找到的對應(yīng)的舊的 vnode 在當(dāng)前 index 之前才需要移動。
也就是這樣:
let j = 0 let find = false // 遍歷舊的 children for (j; j < oldChildren.length; j++) { const oldVNode = oldChildren[j] // 如果找到了具有相同 key 值的兩個節(jié)點,則調(diào)用 patch 函數(shù)更新之 if (newVNode.key === oldVNode.key) { find = true patch(oldVNode, newVNode, container) if (j < lastIndex) { // 舊的 vnode 數(shù)組的下標(biāo)在上一個 index 之前,需要移動 const prevVNode = newChildren[i - 1] if (prevVNode) { const anchor = prevVNode.el.nextSibling insert(newVNode.el, container, anchor) } } else {// 不需要移動 // 更新 lastIndex lastIndex = j } break } }
查找新的 vnode 在舊的 vnode 數(shù)組中的下標(biāo),如果找到了的話,說明對應(yīng)的 dom 就是可以復(fù)用的,先 patch 一下,然后移動。
移動的話判斷下下標(biāo)是否在 lastIndex 之后,如果本來就在后面,那就不用移動,更新下 lastIndex 就行。如果下標(biāo)在 lastIndex 之前,說明需要移動,移動到的位置前面分析過了,就是就是新 vnode 數(shù)組 i-1 的后面。這樣,我們就完成了 dom 節(jié)點的復(fù)用和移動。
新的 vnode 數(shù)組全部處理完后,舊的 vnode 數(shù)組可能還剩下一些不再需要的,那就刪除它們:
// 遍歷舊的節(jié)點 for (let i = 0; i < oldChildren.length; i++) { const oldVNode = oldChildren[i] // 拿著舊 VNode 去新 children 中尋找相同的節(jié)點 const has = newChildren.find( vnode => vnode.key === oldVNode.key ) if (!has) { // 如果沒有找到相同的節(jié)點,則移除 unmount(oldVNode) } }
這樣,我們就完成了兩組 vnode 的 diff 和對應(yīng) dom 的增刪改。
小結(jié)一下:
diff 算法的目的是根據(jù) key 復(fù)用 dom 節(jié)點,通過移動節(jié)點而不是創(chuàng)建新節(jié)點來減少 dom 操作。
對于每個新的 vnode,在舊的 vnode 中根據(jù) key 查找一下,如果沒查找到,那就新增 dom 節(jié)點,如果查找到了,那就可以復(fù)用。
復(fù)用的話要不要移動要判斷下下標(biāo),如果下標(biāo)在 lastIndex 之后,就不需要移動,因為本來就在后面,反之就需要移動。
最后,把舊的 vnode 中在新 vnode 中沒有的節(jié)點從 dom 樹中刪除。
這就是一個完整的 diff 算法的實現(xiàn):
這個 diff 算法我們是從一端逐個處理的,叫做簡單 diff 算法。簡單 diff 算法其實性能不是最好的,比如舊的 vnode 數(shù)組是 ABCD,新的 vnode 數(shù)組是 DABC,按照簡單 diff 算法,A、B、C 都需要移動。
那怎么優(yōu)化這個算法呢?從一個方向順序處理會有這個問題,那從兩個方向同時對比呢?
這就是雙端 diff 算法:
簡單 diff 算法能夠?qū)崿F(xiàn) dom 節(jié)點的復(fù)用,但有的時候會做一些沒必要的移動。雙端 diff 算法解決了這個問題,它是從兩端進(jìn)行對比。
我們需要 4 個指針,分別指向新舊兩個 vnode 數(shù)組的頭尾:
頭和尾的指針向中間移動,直到 oldStartIdx <= oldEndIdx 并且 newStartIdx <= newEndIdx,說明就處理完了全部的節(jié)點。
每次對比下兩個頭指針指向的節(jié)點、兩個尾指針指向的節(jié)點,頭和尾指向的節(jié)點,是不是 key是一樣的,也就是可復(fù)用的。如果是可復(fù)用的話就直接用,調(diào)用 patch 更新一下,如果是頭尾這種,還要移動下位置。
也就是這樣的:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (oldStartVNode.key === newStartVNode.key) { // 頭頭 patch(oldStartVNode, newStartVNode, container) oldStartVNode = oldChildren[++oldStartIdx] newStartVNode = newChildren[++newStartIdx] } else if (oldEndVNode.key === newEndVNode.key) {//尾尾 patch(oldEndVNode, newEndVNode, container) oldEndVNode = oldChildren[--oldEndIdx] newEndVNode = newChildren[--newEndIdx] } else if (oldStartVNode.key === newEndVNode.key) {//頭尾,需要移動 patch(oldStartVNode, newEndVNode, container) insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling) oldStartVNode = oldChildren[++oldStartIdx] newEndVNode = newChildren[--newEndIdx] } else if (oldEndVNode.key === newStartVNode.key) {//尾頭,需要移動 patch(oldEndVNode, newStartVNode, container) insert(oldEndVNode.el, container, oldStartVNode.el) oldEndVNode = oldChildren[--oldEndIdx] newStartVNode = newChildren[++newStartIdx] } else { // 頭尾沒有找到可復(fù)用的節(jié)點 } }
頭頭和尾尾的對比比較簡單,頭尾和尾頭的對比還要移動下節(jié)點。比如舊 vnode 的頭節(jié)點是新的 vnode 的尾節(jié)點,那就要把它移動到舊的 vnode 的尾節(jié)點的位置。
也就是:
insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
插入節(jié)點的錨點節(jié)點是 oldEndVNode 對應(yīng)的 dom 節(jié)點的 nextSibling。如果舊 vnode 的尾節(jié)點是新 vnode 的頭結(jié)點,那就要把它移動到舊 vnode 的頭結(jié)點的位置。
也就是:
insert(oldEndVNode.el, container, oldStartVNode.el)
插入節(jié)點的錨點節(jié)點是 oldStartVNode 對應(yīng)的 dom 節(jié)點(因為要插在它之前)。從雙端進(jìn)行對比,能盡可能的減少節(jié)點移動的次數(shù)。
當(dāng)然,還要處理下如果雙端都沒有可復(fù)用節(jié)點的情況:
如果雙端都沒有可復(fù)用節(jié)點,那就在舊節(jié)點數(shù)組中找,找到了就把它移動過來,并且原位置置為 undefined。沒找到的話就插入一個新的節(jié)點。
也就是這樣:
const idxInOld = oldChildren.findIndex( node => node.key === newStartVNode.key ) if (idxInOld > 0) { const vnodeToMove = oldChildren[idxInOld] patch(vnodeToMove, newStartVNode, container) insert(vnodeToMove.el, container, oldStartVNode.el) oldChildren[idxInOld] = undefined } else { patch(null, newStartVNode, container, oldStartVNode.el) }
因為有了一些 undefined 的節(jié)點,所以要加上空節(jié)點的處理邏輯:
if (!oldStartVNode) { oldStartVNode = oldChildren[++oldStartIdx] } else if (!oldEndVNode) { oldEndVNode = newChildren[--oldEndIdx] }
這樣就完成了節(jié)點的復(fù)用和移動的邏輯。那確實沒有可復(fù)用的節(jié)點的那些節(jié)點呢?
經(jīng)過前面的移動之后,剩下的節(jié)點都被移動到了中間,如果新 vnode 有剩余,那就批量的新增,如果舊 vnode 有剩余那就批量的刪除。因為前面一個循環(huán)的判斷條件是 oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx,這樣如果 old vnode 多了,最后 newStartIdx 會小于 newEndIdx。如果 new vnode 多了,最后 oldStartIdx 會小于 oldEndIdx。
所以判斷條件是這樣的:
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) { // 添加新節(jié)點 for (let i = newStartIdx; i <= newEndIdx; i++) { patch(null, newChildren[i], container, oldStartVNode.el) } } else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) { // 移除操作 for (let i = oldStartIdx; i <= oldEndIdx; i++) { unmount(oldChildren[i]) } }
這樣就是一個完整的 diff 算法了,包括查找可復(fù)用節(jié)點和移動節(jié)點、新增和刪除節(jié)點。而且因為從兩側(cè)查找節(jié)點,會比簡單 diff 算法性能更好一些。
比如 ABCD 到 DABC,簡單 diff 算法需要移動 ABC 三個節(jié)點,而雙端 diff 算法只需要移動 D 一個節(jié)點。
小結(jié)一下:
雙端 diff 是頭尾指針向中間移動的同時,對比頭頭、尾尾、頭尾、尾頭是否可以復(fù)用,如果可以的話就移動對應(yīng)的 dom 節(jié)點。
如果頭尾沒找到可復(fù)用節(jié)點就遍歷 vnode 數(shù)組來查找,然后移動對應(yīng)下標(biāo)的節(jié)點到頭部。最后還剩下舊的 vnode 就批量刪除,剩下新的 vnode 就批量新增。
雙端 diff 算法是 Vue2 采用的 diff 算法,性能還不錯。后來,Vue3 又對 diff 算法進(jìn)行了一次升級,叫做快速 diff 算法。這個后面再講。
關(guān)于“Vue的雙端diff算法怎么實現(xiàn)”這篇文章的內(nèi)容就介紹到這里,感謝各位的閱讀!相信大家對“Vue的雙端diff算法怎么實現(xiàn)”知識都有一定的了解,大家如果還想學(xué)習(xí)更多知識,歡迎關(guān)注億速云行業(yè)資訊頻道。
免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。