淺入淺出圖解domDIff

虛擬DOM/domDiff

      咱們常說的虛擬DOM是經過JS對象模擬出來的DOM節點,domDiff是經過特定算法計算出來一次操做所帶來的DOM變化。
      react和vue中都使用了虛擬DOM,vue我只停留在使用層面就很少說了,react瞭解多一些,就藉着react聊聊虛擬DOM。
      react中涉及到虛擬DOM的代碼主要分爲如下三部分,其中核心是第二步的domDiff算法:css

  • 把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分爲如下四步:vue

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

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

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

代碼如何實現呢?

經過虛擬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算法

//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算法的超級簡化版本:bash

  • 首先聲明一個變量記錄遍歷的順序
  • 執行walk方法分析變化,若是兩個元素tagName相同,遞歸遍歷子節點

      其實walk中應該有大量的邏輯,我只處理了一種狀況,就是元素被刪除。其實還應該有添加、替換等各類狀況,同時涉及到大量的邊界檢查。真正的domDiff算法很複雜,它的複雜度應該是O(n3),react爲了把複雜度下降到線性而作了一系列的妥協。
      我這裏只是選取一種狀況作了演示,有興趣的能夠看看源碼或者搜索一些相關的文章。這篇文章畢竟叫「淺入淺出」,很是淺……babel

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

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,理論上應該有兩條記錄,執行代碼咱們看下:

      咱們看到,輸出的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) 
複製代碼

      好,咱們執行代碼,看一下視圖的變化:

      咱們看到,h3標籤不見了,h2標籤還在,可是裏面的文本節點son2不見了,這跟咱們的預期是同樣的。
      到這裏,這個算法就已經寫完了,上面貼出來的代碼都是按模塊貼出來的,而且是完整能夠運行的。

未處理的問題

      這個算法還有不少沒有處理的問題,例如:

  • 沒有處理屬性變化
  • 只處理了刪除的狀況,添加和替換都沒有處理
  • 若是你刪除了第一個元素,那麼由於索引錯位,後面的元素都會被認爲是不一樣的而被替換掉,react中使用了key屬性解決了這個問題,同時爲了性能也作了妥協。
  • 固然還有不少不少優化

最後

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

相關文章
相關標籤/搜索