手寫一個虛擬DOM庫,徹底讓你理解diff算法


所謂虛擬DOM就是用js對象來描述真實DOM,它相對於原生DOM更加輕量,因為真正的DOM對象附帶有非常多的屬性,另外配合虛擬DOMdiff算法,能以最少的操作來更新DOM,除此之外,也能讓VueReact之類的框架支持除瀏覽器之外的其他平台,本文會參考知名的snabbdom庫來手寫一個簡易版的,配合圖片示例一步步完成代碼,一定讓你徹底理解虛擬DOMpatchdiff算法。

創建虛擬DOM對象

虛擬DOM(下文稱VNode)就是使用js的普通對象來描述DOM的類型、屬性、子元素等信息,一般通過名為h的函數來創建,為了純粹的理解VNodepatch過程,我們先不考慮元素的屬性、樣式、事件等,只考慮節點類型及節點內容,看一下此時的VNode結構:

{
    tag: '',// 元素標簽
    children: [],// 子元素
    text: '',// 子元素是文本節點的話,保存文本
    el: null// 對應的真實dom
}

h函數根據接收的參數返回該對象即可:

export const h = (tag, children) => {
    let text = ''
    let el
    // 子元素是文本節點
    if (typeof children === 'string' || typeof children === 'number') {
        text = children
        children = undefined
    } else if (!Array.isArray(children)) {
        children = undefined
    }
    return {
        tag, // 元素標簽
        children, // 子元素
        text, // 文本子節點的文本
        el// 真實dom
    }
}

比如我們要創建一個divVNode可以這樣使用:

h('div', '我是文本')
h('div', [h('span')])

詳解patch過程

patch函數是我們的主函數,主要用來進行新舊VNode的對比,找到差異來更新實際DOM,它接收兩個參數,第一個參數可以是DOM元素或者是VNode,表示舊的VNode,第二參數表示新的VNode,一般只有第一次調用時才會傳DOM元素,如果第一個參數為DOM元素的話我們直接忽略它的子元素把它轉為一個VNode

export const patch = (oldVNode, newVNode) => {
    // dom元素
    if (!oldVNode.tag) {
        let el = oldVNode
        el.innerHTML = ''
        oldVNode = h(oldVNode.tagName.toLowerCase())
        oldVNode.el = el
    }
}

接下來新舊兩個VNode就可以進行比較了:

export const patch = (oldNode, newNode) => {
    // ...
    patchVNode(oldVNode, newVNode)
    // 返回新的vnode
  	return newVNode
}

patchVNode方法里我們對新舊VNode進行比較及更新DOM

首先如果兩個VNode的類型不同,那么不用比較,直接使用新的VNode替換舊的:

const patchVNode = (oldNode, newNode) => {
    if (oldVNode === newVNode) {
        return
    }
    // 元素標簽相同,進行patch
    if (oldVNode.tag === newVNode.tag) {
        // ...
    } else { // 類型不同那么根據新的VNode創建新的dom節點,然后插入新節點,移除舊節點
        let newEl = createEl(newVNode)
        let parent = oldVNode.el.parentNode
        parent.insertBefore(newEl, oldVNode.el)
        parent.removeChild(oldVNode.el)
    }
}

createEl方法用來遞歸的把VNode轉換成真實的DOM節點:

const createEl = (vnode) => {
    let el = document.createElement(vnode.tag)
    vnode.el = el
    // 創建子節點
    if (vnode.children && vnode.children.length > 0) {
        vnode.children.forEach((item) => {
            el.appendChild(createEl(item))
        })
    }
    // 創建文本節點
    if (vnode.text) {
        el.appendChild(document.createTextNode(vnode.text))
    }
    return el
}

如果類型相同,那么就要根據其子節點的情況來判斷進行哪種操作。

如果新節點只有一個文本子節點,那么移除舊節點的所有子節點(如果有的話),創建一個文本子節點:

const patchVNode = (oldVNode, newVNode) => {
    // 元素標簽相同,進行patch
    if (oldVNode.tag === newVNode.tag) {
        // 元素類型相同,那么舊元素肯定是進行復用的
        let el = newVNode.el = oldVNode.el
        // 新節點的子節點是文本節點
        if (newVNode.text) {
            // 移除舊節點的子節點
            if (oldVNode.children) {
                oldVNode.children.forEach((item) => {
                    el.removeChild(item.el)
                })
            }
            // 文本內容不相同則更新文本
            if (oldVNode.text !== newVNode.text) {
                el.textContent = newVNode.text
            }
        } else {
            // ...
        }
    } else { // 不同使用newNode替換oldNode
        // ...
    }
}

如果新節點的子節點非文本節點,那也有幾種情況:

1.新節點不存在子節點,而舊節點存在,那么移除舊節點的子節點;

2.新節點不存在子節點,舊節點存在文本節點,那么移除該文本節點;

3.新節點存在子節點,舊節點存在文本節點,那么移除該文本節點,然后插入新節點;

4.新舊節點都有子節點的話那么就需要進入到diff階段;

const patchVNode = (oldVNode, newVNode) => {
    // 元素標簽相同,進行patch
    if (oldVNode.tag === newVNode.tag) {
        // ...
        // 新節點的子節點是文本節點
        if (newVNode.text) {
            // ...
        } else {// 新節點不存在文本節點
            // 新舊節點都存在子節點,那么就要進行diff
            if (oldVNode.children && newVNode.children) {
                diff(el, oldVNode.children, newVNode.children)
            } else if (oldVNode.children) {// 新節點不存在子節點,那么移除舊節點的所有子節點
                oldVNode.children.forEach((item) => {
                    el.removeChild(item.el)
                })
            } else if (newVNode.children) {// 新節點存在子節點
                // 舊節點存在文本節點則移除
                if (oldVNode.text) {
                    el.textContent = ''
                }
                // 添加新節點的子節點
                newVNode.children.forEach((item) => {
                    el.appendChild(createEl(item))
                })
            } else if (oldVNode.text) {// 新節點啥也沒有,舊節點存在文本節點
                el.textContent = ''
            }
        }
    } else { // 不同使用newNode替換oldNode
        // ...
    }
}

如果當新舊節點都存在非文本的子節點的話,那么就要進入到著名的diff階段了,diff算法的目的主要是用來盡可能復用舊的節點,以減小DOM操作的開銷。

圖解diff算法

首先最簡單的diff顯然是同位置的新舊節點兩兩比較,但是在WEB場景下,倒序、排序、換位都是經常有可能發生的,所以同位置比較很多時候都很低效,無法滿足這種常見場景,各種所謂的diff算法就是用來盡量能檢查出這些情況,然后進行復用,snabbdom里的diff算法是一種雙端比較的策略,同時從新舊節點的兩端向中間開始比較,每一輪都會進行四次比較,所以需要四個指針,如下圖:

image-20210629144314119.png

即上述四個位置的排列組合:oldStartIdxnewStartIdxoldStartIdxnewEndIdxoldEndIdxnewStartIdxoldEndIdxnewEndIdx,每當發現所比較的兩個節點可能可以復用的話,那么就對這兩個節點進行patch和相應操作,並更新指針進入下一輪比較,那怎么判斷兩個節點是否能復用呢?這就需要使用到key了,因為光看是否是同類型的節點是遠遠不夠的,因為同一個列表基本上類型都是一樣的,那就跟從頭開始的兩兩比較沒有區別了,先修改一下我們的h函數:

export const h = (tag, data = {}, children) => {
    // ...
    let key
    // 文本節點
    // ...
    if (data && data.key) {
        key = data.key
    }
    return {
        // ...
        key
    }
}

現在創建VNode的時候可以傳入key

h('div', {key: 1}, '我是文本')

比較的終止條件也很明顯,其中一個列表已經比較完了,也就是oldStartIdx>oldEndIdxnewStartIdx>newEndIdx,先把算法基本框架寫一下:

// 判斷兩個節點是否可進行復用
const isSameNode = (a, b) => {
    return a.key === b.key && a.tag === b.tag
}

// 進行diff
const diff = (el, oldChildren, newChildren) => {
    // 位置指針
    let oldStartIdx = 0
    let oldEndIdx = oldChildren.length - 1
    let newStartIdx = 0
    let newEndIdx = newChildren.length - 1
    // 節點指針
    let oldStartVNode = oldChildren[oldStartIdx]
    let oldEndVNode = oldChildren[oldEndIdx]
    let newStartVNode = newChildren[newStartIdx]
    let newEndVNode = newChildren[newEndIdx]
    
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {

        } else if (isSameNode(oldStartVNode, newEndVNode)) {

        } else if (isSameNode(oldEndVNode, newStartVNode)) {

        } else if (isSameNode(oldEndVNode, newEndVNode)) {

        }
    }
}

新增了四個變量用來保存四個位置的節點,接下來以上圖為例來完善代碼。

第一輪會發現oldEndVNodenewEndVNode是可復用節點,那么對它們進行patch,因為都在最后的位置,所以不需要移動DOM節點,更新指針即可:

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {} 
        else if (isSameNode(oldStartVNode, newEndVNode)) {} 
        else if (isSameNode(oldEndVNode, newStartVNode)) {} 
        else if (isSameNode(oldEndVNode, newEndVNode)) {
            patchVNode(oldEndVNode, newEndVNode)
            // 更新指針
            oldEndVNode = oldChildren[--oldEndIdx]
            newEndVNode = newChildren[--newEndIdx]
        }
    }
}

此時的位置信息如下:

image-20210629144737773.png

下一輪會發現oldStartIdxnewEndIdx是可復用節點,那么對oldStartVNodenewEndVNode兩個節點進行patch,同時該節點在新列表里的位置是當前比較區間的最后一個,所以需要把oldStartIdx的真實DOM移動到舊列表當前比較區間的最后,也就是oldEndVNode之后:

image-20210629145205542.png

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {} 
        else if (isSameNode(oldStartVNode, newEndVNode)) {
            patchVNode(oldStartVNode, newEndVNode)
            // 把節點移動到oldEndVNode之后
            el.insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling)
            // 更新指針
            oldStartVNode = oldChildren[++oldStartIdx]
            newEndVNode = newChildren[--newEndIdx]
        } 
        else if (isSameNode(oldEndVNode, newStartVNode)) {} 
        else if (isSameNode(oldEndVNode, newEndVNode)) {}
    }
}

這輪以后位置如下:

image-20210629145308700.png

下一輪比較很明顯oldStartVNodenewStartVNode是可復用節點,那么對它們進行patch,因為都在第一個位置,所以也不需要移動節點,更新指針即可:

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {
            patchVNode(oldStartVNode, newStartVNode)
            // 更新指針
            oldStartVNode = oldChildren[++oldStartIdx]
            newStartVNode = newChildren[++newStartIdx]
        } 
        else if (isSameNode(oldStartVNode, newEndVNode)) {} 
        else if (isSameNode(oldEndVNode, newStartVNode)) {} 
        else if (isSameNode(oldEndVNode, newEndVNode)) {}
    }
}

這輪過后位置如下:

image-20210629145420143.png

再下一輪會發現oldEndVNodenewStartVNode是可復用節點,在新的列表里位置變成了當前比較區間的第一個,所以patch完后需要把節點移動到oldStartVNode的前面:

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {} 
        else if (isSameNode(oldStartVNode, newEndVNode)) {} 
        else if (isSameNode(oldEndVNode, newStartVNode)) {
            patchVNode(oldEndVNode, newStartVNode)
            // 把oldEndVNode節點移動到oldStartVNode前
            el.insertBefore(oldEndVNode.el, oldStartVNode.el)
            // 更新指針
            oldEndVNode = oldChildren[--oldEndIdx]
            newStartVNode = newChildren[++newStartIdx]
        } 
        else if (isSameNode(oldEndVNode, newEndVNode)) {}
    }
}

這輪后位置如下:

image-20210629145632152.png

再下一輪會發現四次比較都沒有發現可以復用的節點,這咋辦呢,因為最終我們需要讓舊列表變成新列表,所以當前的newStartVNode如果在舊列表里沒找到可復用的,需要直接創建一個新節點插進去,但是我們一眼就看到了舊節點里有c節點,只是不在此輪比較的四個位置上,那么我們可以直接在舊的列表里搜索,找到了就進行patch,並且把該節點移動到當前比較區間的第一個,也就是oldStartIdx之前,這個位置空下來了就置為null,后續遍歷到就跳過,如果沒找到,那么說明這丫節點真的是新增的,直接創建該節點插入到oldStartIdx之前即可:

// 在列表里找到可以復用的節點
const findSameNode = (list, node) => {
    return list.findIndex((item) => {
        return item && isSameNode(item, node)
    })
}

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 某個位置的節點為null跳過此輪比較,只更新指針
        if (oldStartVNode === null) {
            oldStartVNode = oldChildren[++oldStartIdx]
        } else if (oldEndVNode === null) {
            oldEndVNode = oldChildren[--oldEndIdx]
        } else if (newStartVNode === null) {
            newStartVNode = oldChildren[++newStartIdx]
        } else if (newEndVNode === null) {
            newEndVNode = oldChildren[--newEndIdx]
        }
        else if (isSameNode(oldStartVNode, newStartVNode)) {} 
        else if (isSameNode(oldStartVNode, newEndVNode)) {} 
        else if (isSameNode(oldEndVNode, newStartVNode)) {} 
        else if (isSameNode(oldEndVNode, newEndVNode)) {}
        else {
            let findIndex = findSameNode(oldChildren, newStartVNode)
            // newStartVNode在舊列表里不存在,那么是新節點,創建並插入之
            if (findIndex === -1) {
                el.insertBefore(createEl(newStartVNode), oldStartVNode.el)
            } else {// 在舊列表里存在,那么進行patch,並且移動到oldStartVNode前
                let oldVNode = oldChildren[findIndex]
                patchVNode(oldVNode, newStartVNode)
                el.insertBefore(oldVNode.el, oldStartVNode.el)
                // 原位置空了置為null
                oldChildren[findIndex] = null
            }
            // 更新指針
            newStartVNode = newChildren[++newStartIdx]
        }
    }
}

具體到我們的示例上,在舊的列表里找到了,所以這輪過后位置信息如下:

image-20210629154956514.png

再下一輪比較和上輪一樣,會進入搜索的分支,並且找到了d,所以也是path加移動節點,本輪過后如下:

image-20210629155900787.png

因為newStartIdx大於newEndIdx,所以while循環就結束了,但是我們發現舊的列表里多了gh節點,這兩個在新列表里沒有,所以需要把它們移除,反過來,如果新的列表里多了舊列表里沒有的節點,那么就創建和插入之:

const diff = (el, oldChildren, newChildren) => {
    // ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isSameNode(oldStartVNode, newStartVNode)) {} 
        else if (isSameNode(oldStartVNode, newEndVNode)) {} 
        else if (isSameNode(oldEndVNode, newStartVNode)) {} 
        else if (isSameNode(oldEndVNode, newEndVNode)) {}
        else {}
    }
    // 舊列表里存在新列表里沒有的節點,需要刪除
    if (oldStartIdx <= oldEndIdx) {
        for(let i = oldStartIdx; i <= oldEndIdx; i++) {
            oldChildren[i] && el.removeChild(oldChildren[i].el)
        }
    } else if (newStartIdx <= newEndIdx) {// 新列表里存在舊列表沒有的節點,創建和插入
        // 在newEndVNode的下一個節點前插入,如果下一個節點不存在,那么insertBefore方法會執行appendChild的操作
        let before = newChildren[newEndIdx + 1] ? newChildren[newEndIdx + 1].el : null
        for(let i = newStartIdx; i <= newEndIdx; i++) {
            el.insertBefore(createEl(newChildren[i]), before)
        }
    }
}

以上就是雙端diff的全過程,是不是還挺簡單,畫個圖就十分容易理解了。

屬性的更新

其他屬性都通過data參數傳入,先修改一下h函數:

export const h = (tag, data = {}, children) => {
  // ...
  return {
    // ...
    data
  }
}

類名

類名通過data選項的class字段傳遞,比如:

h('div',{
    class: {
        btn: true
    }
}, '文本')

類名的更新在patchVNode方法里進行,當兩個節點的類型一樣,那么更新類名,替換的話就相當於設置類名:

// 更新節點類名
const updateClass = (el, newVNode) => {
    el.className = ''
    if (newVNode.data && newVNode.data.class) {
        let className = ''
        Object.keys(newVNode.data.class).forEach((cla) => {
            if (newVNode.data.class[cla]) {
                className += cla + ' '
            }
        })
        el.className = className
    }
}

const patchVNode = (oldVNode, newVNode) => {
    // ...
    // 元素標簽相同,進行patch
    if (oldVNode.tag === newVNode.tag) {
        let el = newVNode.el = oldVNode.el
        // 更新類名
        updateClass(el, newVNode)
        // ...
    } else { // 不同使用newNode替換oldNode
        let newEl = createEl(newVNode)
        // 更新類名
        updateClass(newEl, newVNode)
        // ...
    }
}

邏輯很簡單,直接把舊節點的類名替換成newVNode的類名。

樣式

樣式屬性使用datastyle字段傳入:

h('div',{
    style: {
        fontSize: '30px'
    }
}, '文本')

更新的時機和類名的位置一致:

// 更新節點樣式
const updateStyle = (el, oldVNode, newVNode) => {
  let oldStyle = oldVNode.data.style || {}
  let newStyle = newVNode.data.style || {}
  // 移除舊節點里存在新節點里不存在的樣式
  Object.keys(oldStyle).forEach((item) => {
    if (newStyle[item] === undefined || newStyle[item] === '') {
      el.style[item] = ''
    }
  })
  // 添加舊節點不存在的新樣式
  Object.keys(newStyle).forEach((item) => {
    if (oldStyle[item] !== newStyle[item]) {
      el.style[item] = newStyle[item]
    }
  })
}

const patchVNode = (oldVNode, newVNode) => {
    // ...
    // 元素標簽相同,進行patch
    if (oldVNode.tag === newVNode.tag) {
        let el = newVNode.el = oldVNode.el
        // 更新樣式
        updateStyle(el, oldVNode, newVNode)
        // ...
    } else {
        let newEl = createEl(newVNode)
        // 更新樣式
        updateStyle(el, null, newVNode)
        // ...
    }
}

其他屬性

其他屬性保存在dataattr字段上,更新方式及位置和樣式的完全一致:

// 更新節點屬性
const updateAttr = (el, oldVNode, newVNode) => {
    let oldAttr = oldVNode && oldVNode.data.attr ? oldVNode.data.attr : {}
    let newAttr = newVNode.data.attr || {}
    // 移除舊節點里存在新節點里不存在的屬性
    Object.keys(oldAttr).forEach((item) => {
        if (newAttr[item] === undefined || newAttr[item] === '') {
            el.removeAttribute(item)
        }
    })
    // 添加舊節點不存在的新屬性
    Object.keys(newAttr).forEach((item) => {
        if (oldAttr[item] !== newAttr[item]) {
            el.setAttribute(item, newAttr[item])
        }
    })
}

const patchVNode = (oldVNode, newVNode) => {
    // ...
    // 元素標簽相同,進行patch
    if (oldVNode.tag === newVNode.tag) {
        let el = newVNode.el = oldVNode.el
        // 更新屬性
        updateAttr(el, oldVNode, newVNode)
        // ...
    } else {
        let newEl = createEl(newVNode)
        // 更新屬性
        updateAttr(el, null, newVNode)
        // ...
    }
}

事件

最后來看一下事件的更新,事件與其他屬性不同的是如果刪除一個節點的話需要把它的事件先全部解綁,否則可能會存在內存泄漏的問題,那么就需要在各個移除節點的時機都先解綁事件:

// 移除某個VNode對應的dom的所有事件
const removeEvent = (oldVNode) => {
  if (oldVNode && oldVNode.data && oldVNode.data.event) {
    Object.keys(oldVNode.data.event).forEach((item) => {
      oldVNode.el.removeEventListener(item, oldVNode.data.event[item])
    })
  }
}

// 更新節點事件
const updateEvent = (el, oldVNode, newVNode) => {
  let oldEvent = oldVNode && oldVNode.data.event ? oldVNode.data.event : {}
  let newEvent = newVNode.data.event || {}
  // 解綁不再需要的事件
  Object.keys(oldEvent).forEach((item) => {
    if (newEvent[item] === undefined || oldEvent[item] !== newEvent[item]) {
      el.removeEventListener(item, oldEvent[item])
    }
  })
  // 綁定舊節點不存在的新事件
  Object.keys(newEvent).forEach((item) => {
    if (oldEvent[item] !== newEvent[item]) {
      el.addEventListener(item, newEvent[item])
    }
  })
}

const patchVNode = (oldVNode, newVNode) => {
    // ...
    // 元素標簽相同,進行patch
    if (oldVNode.tag === newVNode.tag) {
        // 元素類型相同,那么舊元素肯定是進行復用的
        let el = newVNode.el = oldVNode.el
        // 更新事件
        updateEvent(el, oldVNode, newVNode)
        // ...
    } else {
        let newEl = createEl(newVNode)
        // 移除舊節點的所有事件
        removeEvent(oldNode)
        // 更新事件
        updateEvent(newEl, null, newVNode)
        // ...
    }
}
// 其他還有幾處需要添加removeEvent(),有興趣請看源碼

以上屬性的更新邏輯都比較粗糙,僅用於參考,可以參考snabbdom的源碼自行完善。

總結

以上代碼實現了一個簡單的虛擬DOM庫,詳細分解了patch過程和diff的過程,如果需要用在非瀏覽器平台上,只要把DOM相關的操作抽象成接口,不同平台上使用不同的接口即可,完整代碼在https://github.com/wanglin2/VNode-Demo


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM