淺入淺出圖解domDIff

寒東設計師發表於2019-03-04

虛擬DOM/domDiff

      我們常說的虛擬DOM是通過JS物件模擬出來的DOM節點,domDiff是通過特定演算法計算出來一次操作所帶來的DOM變化。
      react和vue中都使用了虛擬DOM,vue我只停留在使用層面就不多說了,react瞭解多一些,就藉著react聊聊虛擬DOM。
      react中涉及到虛擬DOM的程式碼主要分為以下三部分,其中核心是第二步的domDiff演算法:

  • 把render中的JSX(或者createElement這個API)轉化成虛擬DOM
  • 狀態或屬性改變後重新計算虛擬DOM並生成一個補丁物件(domDiff)
  • 通過這個補丁物件更新檢視中的DOM節點

虛擬DOM不一定更快

      幹前端的都知道DOM操作是效能殺手,因為操作DOM會引起頁面的迴流或者重繪。相比起來,通過多一些預先計算來減少DOM的操作要划算的多。
      但是,“使用虛擬DOM會更快”這句話並不一定適用於所有場景。例如:一個頁面就有一個按鈕,點選一下,數字加一,那肯定是直接操作DOM更快。使用虛擬DOM無非白白增加了計算量和程式碼量。即使是複雜情況,瀏覽器也會對我們的DOM操作進行優化,大部分瀏覽器會根據我們操作的時間和次數進行批量處理,所以直接操作DOM也未必很慢。
      那麼為什麼現在的框架都使用虛擬DOM呢?因為使用虛擬DOM可以提高程式碼的效能下限,並極大的優化大量操作DOM時產生的效能損耗。同時這些框架也保證了,即使在少數虛擬DOM不太給力的場景下,效能也在我們接受的範圍內。
      而且,我們之所以喜歡react、vue等使用了虛擬DOM框架,不光是因為他們快,還有很多其他更重要的原因。例如react對函數語言程式設計的友好,vue優秀的開發體驗等,目前社群也有好多比較這兩個框架並打口水戰的,我覺著還是在兩個都懂的情況下多探究一下原理更有意義一些。

實現domDiff的思路

      實現domDiff分為以下四步:

  1. 用JS模擬真實DOM節點
  2. 把虛擬DOM轉換成真實DOM插入頁面中
  3. 發生變化時,比較兩棵樹的差異,生成差異物件
  4. 根據差異物件更新真實DOM

      設計師的老本行不能忘,看我畫張圖:

淺入淺出圖解domDIff

      解釋一下這張圖:
      首先看第一個紅色色塊,這裡說的是把真實DOM對映為虛擬DOM,其實在react中沒有這個過程,我們直接寫的就是虛擬DOM(JSX),只是這個虛擬DOM代表著真實DOM
      當虛擬DOM變化時,例如上圖,它的第三個p和第二個p中的son2被刪除了。這個時候我們會根據前後的變化計算出一個差異物件patches
      這個差異物件的key值就是老的DOM節點遍歷時的索引,用這個索引我們可以找到那個節點。屬性值是記錄的變化,這裡是remove,代表刪除。
      最後,根據patches中每一項的索引去對應的位置修改老的DOM節點。

程式碼如何實現呢?

通過虛擬DOM建立真實DOM

      下面這段程式碼是入口檔案,我們模擬了一個虛擬DOM叫oldEle,我們這裡是寫死的。而在react中,是通過babel解析JSX語法得到一個抽象語法樹(AST),進而生成虛擬DOM。如果對babel轉換感興趣,可以看看另一篇文章入門babel--實現一個es6的class轉換器

import { createElement } from './createElement'

let oldEle = createElement('div', { class: 'father' }, [
    createElement('h1', { style:'color:red' }, ['son1']),
    createElement('h2', { style:'color:blue' }, ['son2']),
    createElement('h3', { style:'color:red' }, ['son3'])
])
document.body.appendChild(oldEle.render())
複製程式碼

      下面這個檔案匯出了createElement方法。它其實就是new了一個Element類,呼叫這個類的render方法可以把虛擬DOM轉換為真實DOM

class Element {
    constructor(tagName, attrs, childs) {
        this.tagName = tagName
        this.attrs = attrs
        this.childs = childs
    }
    render() {
        let element = document.createElement(this.tagName)
        let attrs = this.attrs
        let childs = this.childs
        //設定屬性
        for (let attr in attrs) {
            setAttr(element, attr, attrs[attr])
        }
        //先序深度優先遍歷子建立並插入子節點
        for (let i = 0; i < childs.length; i++) {
            let child = childs[i]
            console.log(111, child instanceof Element)
            let childElement = child instanceof Element ? child.render() : document.createTextNode(child)
            element.appendChild(childElement)
        }
        return element
    }
}
function setAttr(ele, attr, value) {
    switch (attr) {
        case 'style':
            ele.style.cssText = value
            break;
        case 'value':
            let tageName = ele.tagName.toLowerCase()
            if (tagName == 'input' || tagName == 'textarea') {
                ele.value = value
            } else {
                ele.setAttribute(attr, value)
            }
            break;
        default:
            ele.setAttribute(attr, value)
            break;
    }
}
function createElement(tagName, props, child) {
    return new Element(tagName, props, child)
}
module.exports = { createElement }
複製程式碼

      現在這段程式碼已經可以跑起來了,執行以後的結果如下圖:

淺入淺出圖解domDIff

繼續看domDIff演算法

//keyIndex記錄遍歷順序
let keyIndex = 0
function diff(oldEle, newEle) {
    let patches = {}
    keyIndex = 0
    walk(patches, 0, oldEle, newEle)
    return patches
}
//分析變化
function walk(patches, index, oldEle, newEle) {
    let currentPatches = []
    //這裡應該有很多的判斷型別,這裡只處理了刪除的情況...
    if (!newEle) {
        currentPatches.push({ type: 'remove' })
    }
    else if (oldEle.tagName == newEle.tagName) {
        //比較兒子們
        walkChild(patches, currentPatches, oldEle.childs, newEle.childs)
    }
    //判斷當前節點是否有改變,有的話把補丁放入補丁集合中
    if (currentPatches.length) {
        patches[index] = currentPatches
    }
}
function walkChild(patches, currentPatches, oldChilds, newChilds) {
    if (oldChilds) {
        for (let i = 0; i < oldChilds.length; i++) {
            let oldChild = oldChilds[i]
            let newChild = newChilds[i]
            walk(patches, ++keyIndex, oldChild, newChild)
        }
    }
}
module.exports = { diff }
複製程式碼

      上面這段程式碼就是domDiff演算法的超級簡化版本:

  • 首先宣告一個變數記錄遍歷的順序
  • 執行walk方法分析變化,如果兩個元素tagName相同,遞迴遍歷子節點

      其實walk中應該有大量的邏輯,我只處理了一種情況,就是元素被刪除。其實還應該有新增、替換等各種情況,同時涉及到大量的邊界檢查。真正的domDiff演算法很複雜,它的複雜度應該是O(n3),react為了把複雜度降低到線性而做了一系列的妥協。
      我這裡只是選取一種情況做了演示,有興趣的可以看看原始碼或者搜尋一些相關的文章。這篇文章畢竟叫“淺入淺出”,非常淺……

      好,那我們執行這個演算法看看效果:

import { createElement } from './createElement'
import { diff } from './diff'

let oldEle = createElement('div', { class: 'father' }, [
    createElement('h1', { style: 'color:red' }, ['son1']),
    createElement('h2', { style: 'color:blue' }, ['son2']),
    createElement('h3', { style: 'color:red' }, ['son3'])
])
let newEle = createElement('div', { class: 'father' }, [
    createElement('h1', { style: 'color:red' }, ['son1']),
    createElement('h2', { style: 'color:blue' }, [])
])
console.log(diff(oldEle, newEle))
複製程式碼

      我在入口檔案中新建立了一個元素,用來代表被更改之後的虛擬DOM,它有兩個元素被刪除了,一個h3、一個文字節點son2,理論上應該有兩條記錄,執行程式碼我們看下:

淺入淺出圖解domDIff

      我們看到,輸出的patches物件裡有兩個屬性,屬性名是這個元素的遍歷序號、屬性值是記錄的資訊,我們就是通過序號去遍歷找到老的DOM節點,通過屬性值裡的資訊來做相應的更新。

更新檢視

      下面我們看如何通過得到的patches物件更新檢視:

let index = 0;
let allPatches;
function patch(root, patches) {
    allPatches = patches
    walk(root)
}
function walk(root) {
    let currentPatches = allPatches[index]
    index++
    (root.childNodes || []) && root.childNodes.forEach(child => {
        walk(child)
    })
    if (currentPatches) {
        doPatch(root, currentPatches)
    }
}
function doPatch(ele, currentPatches) {
    currentPatches.forEach(currentPatch => {
        if (currentPatch.type == 'remove') {
            ele.parentNode.removeChild(ele)
        }
    })
}
module.exports = { patch }
複製程式碼

      檔案匯出的patch方法有兩個引數,root是真實的DOM節點,patches是補丁物件,我們用和遍歷虛擬DOM同樣的手段(先序深度優先)去遍歷真實的節點,這很重要,因為我們是通過patches物件的key屬性記錄哪個節點發生了變化,相同的遍歷手段可以保證我們的對應關係是正確的。
      doPatch方法很簡單,判斷如果type是“remove”,直接刪掉該DOM節點。其實這個方法也不應該這麼簡單,它也應該處理很多事情,比如說刪除、互換等,其實還應該判斷屬性的變化並做相應的處理。
      淺入淺出嘛,所以這些都沒處理,我當然不會說我根本寫不出來……
      現在我們應用一下這個patch方法:

import { createElement } from './createElement'
import { diff } from './diff'
import { patch } from './patch'

let oldEle = createElement('div', { class: 'father' }, [
    createElement('h1', { style: 'color:red' }, ['son1']),
    createElement('h2', { style: 'color:blue' }, ['son2']),
    createElement('h3', { style: 'color:green' }, ['son3'])
])
let newEle = createElement('div', { class: 'father' }, [
    createElement('h1', { style: 'color:red' }, ['son1']),
    createElement('h2', { style: 'color:green' }, [])
])
//這裡應用了patch方法,給原始的root節點打了補丁,更新成了新的節點
let root = oldEle.render()
let patches = diff(oldEle, newEle)
patch(root, patches)
document.body.appendChild(root) 
複製程式碼

      好,我們執行程式碼,看一下檢視的變化:

淺入淺出圖解domDIff

      我們看到,h3標籤不見了,h2標籤還在,但是裡面的文字節點son2不見了,這跟我們的預期是一樣的。
      到這裡,這個演算法就已經寫完了,上面貼出來的程式碼都是按模組貼出來的,並且是完整可以執行的。

未處理的問題

      這個演算法還有很多沒有處理的問題,例如:

  • 沒有處理屬性變化
  • 只處理了刪除的情況,新增和替換都沒有處理
  • 如果你刪除了第一個元素,那麼因為索引錯位,後面的元素都會被認為是不同的而被替換掉,react中使用了key屬性解決了這個問題,同時為了效能也做了妥協。
  • 當然還有很多很多優化

最後

      上面的程式碼只是把react中的核心思路簡單實現了一下,只是供大家瞭解一下domDiff演算法的思路,如我我的描述讓你對domDiff產生了一點興趣或者對你有一點幫助,我很高興。

相關文章