手寫Vue2核心(五):節(jié)點差異與diff算法

網(wǎng)上找的圖,懶得自己畫,畢竟本人PS一般(程序員程度的一般,對比設(shè)計師為未畢業(yè)渣渣級)

diff算法

在這里也多說一句,節(jié)點對比不屬于diff算法,diff算法僅對于父節(jié)點一致,并且都有子節(jié)點的時候才需要用到,其他的就是簡單的邏輯判斷一直去if...else if...而已
開發(fā)準備工作,創(chuàng)建兩個vnode,前面方法已經(jīng)實現(xiàn)了,直接調(diào)用即可(開發(fā)完刪除即可)。

// index.js
import { compileToFunctions } from './compiler/index.js'
import { createdElm, patch } from './vdom/patch.js'

// 構(gòu)建兩個虛擬Dom
let vm1 = new Vue({
    data () {
        return {
            name: 'yose'
        }
    }
})

let render1 = compileToFunctions(`<div id="a" a="1" style="color: red">{{name}}</div>`) // 將模板變?yōu)閞ender函數(shù)
let oldVnode = render1.call(vm1) // 老的虛擬節(jié)點
let el = createdElm(oldVnode) // 創(chuàng)建真實節(jié)點
document.body.appendChild(el)

let vm2 = new Vue({
    data () {
        return {
            name: 'Catherine'
        }
    }
})

let render2 = compileToFunctions(`<div id="b" b="1" style="background: blue">{{name}}</div>`) // 將模板變?yōu)閞ender函數(shù)
let newVnode = render2.call(vm2) // 老的虛擬節(jié)點

setTimeout(() => {
    patch(oldVnode, newVnode)
}, 2000)

父元素對比

patch負責(zé)兩件事:渲染成真實Dom(初渲染)及diff算法,之前diff已經(jīng)預(yù)留過,非真實節(jié)點!isRealElement走diff算法,現(xiàn)在就是來完善diff

父元素對比情況列舉:

  1. 對比父節(jié)點標簽,如果不一致,直接替換
  2. 標簽一致,但是為文本標簽(tagundefined),替換文本內(nèi)容
  3. 標簽一致,非文本標簽,但是屬性不同,復(fù)用老節(jié)點并且更新屬性
// vdom/patch.js
export function patch(oldVnode, vnode) {
    if (isRealElement) {
        // code...
    } else {
+       // 1. 如果兩個虛擬節(jié)點的標簽不一致,就直接替換掉
+       if (oldVnode.tag !== vnode.tag) {
+           return oldVnode.el.parentNode.replaceChild(createdElm(vnode), oldVnode.el)
+       }

+       // 2. 標簽一樣,但是是兩個文本元素(tag: undefined)
+       if (!oldVnode.tag) {
+           if (oldVnode.text !== vnode.text) {
+               return oldVnode.el.textContent = vnode.text
+           }
+       }

+       // 3. 元素相同,屬性不同,復(fù)用老節(jié)點并且更新屬性
+       let el = vnode.el = oldVnode.el
+       // 用老的屬性和新的虛擬節(jié)點進行比對
+       updateProperties(vnode, oldVnode.data)

+       // 4. 更新子元素
+       let oldChildren = oldVnode.children || []
+       let newChildren = vnode.children || []

+       if (oldChildren.length > 0 && newChildren.length > 0) { // 新的老的都有子元素,需要使用diff算法

+       } else if (oldChildren.length > 0) { // 1. 老的有子元素,新的沒有子元素,刪除老的子元素
+           el.innerHTML = '' // 清空所有子節(jié)點
+       } else if (newChildren.length > 0) { // 2. 新的有子元素,老的沒有子元素,在老節(jié)點增加子元素即可
+           newChildren.forEach(child => el.appendChild(createElm(child)))
+       }
    }
}

// 更新屬性,注意這里class與style無法處理表達式,因為從前面解析的時候就沒處理,還是那句,重點不在完全實現(xiàn),而是學(xué)習(xí)核心思路
function updateProperties (vnode, oldProps = {}) {
    const newProps = vnode.data || {}
    const el = vnode.el

+   // 1. 老的屬性,新的沒有,刪除屬性
+   // 前面提到過一次,以前vue1需要考慮重繪,現(xiàn)在新版瀏覽器已經(jīng)會做合并,所以不用再去考慮使用documentFlagment來優(yōu)化了
+   for (let key in oldProps) {
+       if (!newProps[key]) {
+           el.removeAttribute(key)
+       }
+   }

+   let newStyle = newProps.style || {}
+   let oldStyle = oldProps.style || {}
+   for (let key in oldStyle) { // 新老樣式先進行比對,刪除新vnode中沒有的樣式
+       if (!newStyle[key]) {
+           el.style[key] = ''
+       }
+   }

    // 2. 新的屬性,老的沒有,直接用新的覆蓋,不用考慮有沒有
    // 原本code...
}

diff算法

diff算法是當(dāng)父元素一致,并且都有子節(jié)點的情況下使用的
diff算法是借鑒于snabbdom.js的,有興趣的可自行拓展了解
diff算法的核心思路是去操作vnode,通過vnode來排查是否需要重新創(chuàng)建節(jié)點,而不是直接去訪問真實節(jié)點(減少過橋費)
對到節(jié)點,能移動的采用移動,能復(fù)用的節(jié)點則復(fù)用,不能移動或復(fù)用的才創(chuàng)建插入(減少節(jié)點的銷毀創(chuàng)建,因為Dom操作是具備移動性的,會移動節(jié)點,Dom映射)
還要注意一點,對比使用的vnode,移動真實Dom(這句在下面代碼最后一個情景里自行體會,比如后面比對可復(fù)用,會將節(jié)點置為null,置null的是虛擬節(jié)點,真實節(jié)點是直接移動)

// vdom\index.js
// 是否為相同虛擬節(jié)點
+ export function isSameVnode (oldVnode, newVnode) {
+     return (oldVnode.tag === newVnode.tag) && (oldVnode.key === newVnode.key)
+ }

diff算法主要實現(xiàn)邏輯代碼

export function patch(oldVnode, vnode) {
    // code...

    if (isRealElement) {
        // code...
+   } else {
+       // 1. 如果兩個虛擬節(jié)點的標簽不一致,就直接替換掉
+       if (oldVnode.tag !== vnode.tag) {
+           return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el)
+       }
+
+       // 2. 標簽一樣,但是是兩個文本元素(tag: undefined)
+       if (!oldVnode.tag) {
+           if (oldVnode.text !== vnode.text) {
+               return oldVnode.el.textContent = vnode.text
+           }
+       }
+
+       // 3. 元素相同,屬性不同,復(fù)用老節(jié)點并且更新屬性
+       let el = vnode.el = oldVnode.el
+       // 用老的屬性和新的虛擬節(jié)點進行比對
+       updateProperties(vnode, oldVnode.data)
+
+       // 4. 更新子元素
+       let oldChildren = oldVnode.children || []
+       let newChildren = vnode.children || []
+
+       if (oldChildren.length > 0 && newChildren.length > 0) { // 新的老的都有子元素,需要使用diff算法
+           updateChildren(el, oldChildren, newChildren)
+       } else if (oldChildren.length > 0) { // 1. 老的有子元素,新的沒有子元素,刪除老的子元素
+           el.innerHTML = '' // 清空所有子節(jié)點
+       } else if (newChildren.length > 0) { // 2. 新的有子元素,老的沒有子元素,在老節(jié)點增加子元素即可
+           newChildren.forEach(child => el.appendChild(createElm(child)))
+       }
+   }
}

+ // diff算法主要邏輯
+ function updateChildren (parent, oldChildren, newChildren) {
+     let oldStartIndex = 0 // 老的父元素起始指針
+     let oldEndIndex = oldChildren.length - 1 // 老的父元素終止指針
+     let oldStartVnode = oldChildren[0] // 老的開始節(jié)點
+     let oldEndVnode = oldChildren[oldEndIndex] // 老的結(jié)束節(jié)點
+ 
+     let newStartIndex = 0 // 新的父元素起始指針
+     let newEndIndex = newChildren.length - 1 // 新的父元素終止指針
+     let newStartVnode = newChildren[0] // 新的開始節(jié)點
+     let newEndVnode = newChildren[newEndIndex] // 新的結(jié)束節(jié)點
+ 
+     // 創(chuàng)建字典表,用于亂序
+     function makeIndexByKey (oldChildren) {
+         let map = {}
+         oldChildren.forEach((item, index) => {
+             map[item.key] = index
+         })
+         return map
+     }
+ 
+     let map = makeIndexByKey(oldChildren)
+ 
+     // 1. 前端中比較常見的操作有:尾部插入 頭部插入 頭部移動到尾部 尾部移動到頭部 正序和反序
+     while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
+         if (!oldStartVnode) { // 亂序diff算法中處理過的虛擬節(jié)點
+             oldStartVnode = oldChildren[++oldStartIndex]
+         } else if (!oldEndVnode) { // 亂序diff算法中處理過的虛擬節(jié)點
+             oldEndVnode = oldChildren[--oldEndIndex]
+         } else if (isSameVnode(oldStartVnode, newStartVnode)) { // 向后插入操作,開始的虛擬節(jié)點一致
+             patch(oldStartVnode, newStartVnode) // 遞歸比對節(jié)點
+             oldStartVnode = oldChildren[++oldStartIndex]
+             newStartVnode = newChildren[++newStartIndex]
+         } else if (isSameVnode(oldEndVnode, newEndVnode)) { // 向前插入,開始的虛擬節(jié)點不一致,結(jié)束的虛擬節(jié)點一致
+             patch(oldEndVnode, newEndVnode)
+             oldEndVnode = oldChildren[--oldEndIndex]
+             newEndVnode = newChildren[--newEndIndex]
+         } else if (isSameVnode(oldStartVnode, newEndVnode)) { // 開始結(jié)束都不一致,舊的開始與新的結(jié)尾一致(頭部插入尾部)
+             patch(oldStartVnode, newEndVnode)
+             parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling)
+             oldStartVnode = oldChildren[++oldStartIndex]
+             newEndVnode = newChildren[--newEndIndex]
+         } else if (isSameVnode(oldEndVnode, newStartVnode)) { // 開始結(jié)束都不一致,舊的結(jié)尾與新的起始一致(尾部插入頭部)
+             patch(oldEndVnode, newStartVnode)
+             parent.insertBefore(oldEndVnode.el, oldStartVnode.el)
+             oldEndVnode = oldChildren[--oldEndIndex]
+             newStartVnode = newChildren[++newStartIndex]
+         } else { // 亂序diff算法,檢測是否有可復(fù)用的key值,有則將原本節(jié)點移動,老的位置置為null,否則將新的節(jié)點插入進老的節(jié)點中來
+             // 1. 需要先查找當(dāng)前索引 老節(jié)點索引和key的關(guān)系
+             // 移動的時候通過新的 key 去找對應(yīng)的老節(jié)點索引 => 獲取老節(jié)點,可以移動老節(jié)點
+             let moveIndex = map[newStartVnode.key]
+             if (moveIndex === undefined) { // 不在字典中存在,是個新節(jié)點,直接插入
+                 parent.insertBefore(createElm(newStartVnode), oldStartVnode.el)
+             } else {
+                 let moveVnode = oldChildren[moveIndex]
+                 oldChildren[moveIndex] = undefined // 表示該虛擬節(jié)點已經(jīng)處理過,后續(xù)遞歸時可直接跳過
+                 patch(moveVnode, newStartVnode) // 如果找到了,需要兩個虛擬節(jié)點對比
+                 parent.insertBefore(moveVnode.el, oldStartVnode.el)
+             }
+             newStartVnode = newChildren[++newStartIndex]
+         }
+     }
+ 
+     // 新的比老的多,插入新節(jié)點
+     if (newStartIndex <= newEndIndex) {
+         // 將多出來的節(jié)點一個個插入進去
+         for (let i = newStartIndex; i <= newEndIndex; i++) {
+             // 排查下一個節(jié)點是否存在,如果存在證明指針是從后往前(insertBefore),反之指針是從頭往后(appendChild)
+             let nextEle = newChildren[newEndIndex + 1] === undefined ? null : newChildren[newEndIndex + 1].el
+             // 這里不需要分情況使用 appendChild 或 insertBefore
+             // 如果 insertBefore 傳入 null,等價于 appendChild
+             parent.insertBefore(createElm(newChildren[i]), nextEle)
+         }
+     }
+ 
+     // 老的比新的多,刪除老節(jié)點
+     if (oldStartIndex <= oldEndIndex) {
+         for (let i = oldStartIndex; i <= oldEndIndex; i++) {
+             let child = oldChildren[i]
+             if (child !== undefined) { // 有可能是遍歷到已經(jīng)被使用過的虛擬節(jié)點,需要排除掉
+                 parent.removeChild(child.el)
+             }
+         }
+     }
+ }

// 更新屬性,注意這里class與style無法處理表達式,因為從前面解析的時候就沒處理,還是那句,重點不在完全實現(xiàn),而是學(xué)習(xí)核心思路
function updateProperties (vnode, oldProps = {}) {
    const newProps = vnode.data || {}
    const el = vnode.el

+   // 1. 老的屬性,新的沒有,刪除屬性
+   // 前面提到過一次,以前vue1需要考慮重繪,現(xiàn)在新版瀏覽器已經(jīng)會做合并,所以不用再去考慮使用documentFlagment來優(yōu)化了
+   for (let key in oldProps) {
+       if (!newProps[key]) {
+           el.removeAttribute(key)
+       }
+   }

+   let newStyle = newProps.style || {}
+   let oldStyle = oldProps.style || {}
+   for (let key in oldStyle) { // 新老樣式先進行比對,刪除新vnode中沒有的樣式
+       if (!newStyle[key]) {
+           el.style[key] = ''
+       }
+   }

+   // 2. 新的屬性,老的沒有,直接用新的覆蓋,不用考慮有沒有
    for (let key in newProps) {
        if (key === 'style') {
            for (let styleName in newProps.style) {
                el.style[styleName] = newProps.style[styleName]
            }
        } else if (typeof tag === 'class') { // 靜態(tài)的class可以沒有這段,但還是寫上,假裝如果是class可以處理簡單的表達式
            vnode.className = newProps.class
        } else {
            el.setAttribute(key, newProps[key])
        }
    }
}

vue更新時引入diff算法

diff算法是在渲染更新時才需要使用的,前面實現(xiàn)時,_update使用了一個虛擬節(jié)點,所以現(xiàn)在要在實例上再掛一個_vnode,用于保存上一次的虛擬節(jié)點(patch需要老虛擬節(jié)點與新虛擬節(jié)點做對比)

// lifecycle.js
export function lifecycleMixin (Vue) {
    // 視圖更新方法,用于渲染真實DOM
    Vue.prototype._update = function (vnode) {
        const vm = this

+       const preVnode = vm._vnode // 初始化時必然為undefind
+       vm._vnode = vnode
+
+       if (!preVnode) { // 初渲染
+           // 首次渲染,需要用虛擬節(jié)點,來更新真實的dom元素(vm._render())
+           // 第一次渲染完畢后 拿到新的節(jié)點,下次再次渲染時替換上次渲染的結(jié)果
            vm.$el = patch(vm.$el, vnode) // 組件調(diào)用patch方法后會產(chǎn)生$el屬性
+       } else { // 視圖更新渲染
+           vm.$el = patch(preVnode, vnode)
+       }
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容