虛擬DOM和Diff演算法 - 入門級

noobakong發表於2019-01-25

什麼是虛擬Dom

我們知道我們平時的頁面都是有很多Dom組成,那虛擬Dom(virtual dom)到底是什麼,簡單來講,就是將真實的dom節點用JavaScript來模擬出來,而Dom變化的對比,放到 Js 層來做。

下面是一個傳統的dom節點,大家肯定都不陌生。

虛擬DOM和Diff演算法 - 入門級
而這個dom對應的虛擬dom,可以表示成下面的樣子

虛擬DOM和Diff演算法 - 入門級
很簡單,大家都能看懂,tag表示標籤名,attrs就是dom的屬性,每個dom如果有children的話,就會在children中以陣列的形式展示,陣列的每一項就又是一個虛擬dom結構。

這裡使用 Js 來實現虛擬dom的原因是 Js 在前端領域,是唯一一門圖靈完備的語言;所謂圖靈完備語言,就是指可以進行復雜邏輯操作,實現各種邏輯演算法語言。

為何使用虛擬Dom

有人會問,dom挺好啊,我們剛學前端的時候肯定會接觸JQuery,JQuery就是典型的操作dom的一個框架工具庫,我們拿JQuery來設計一個場景,來解釋一下虛擬dom的用處及價值。

這有一個需求場景

var data = [
      {
        name: '張三',
        age: '20',
        address: '杭州'
      },
      {
        name: '李四',
        age: '22',
        address: '北京'
      },
      {
        name: '隔壁老王',
        age: '24',
        address: "西溪水岸"
      }
    ]
複製程式碼

我們現在想要將這個資料渲染成一個表格,並點選頁面上的按鈕更換我們的部分資料,我們使用Jquery來做。

  <div id="container"></div>
  <button id="btn-change">change</button>

  <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
  <script>
    var data = [
      {
        name: '張三',
        age: '20',
        address: '杭州'
      },
      {
        name: '李四',
        age: '22',
        address: '北京'
      },
      {
        name: '隔壁老王',
        age: '24',
        address: "西溪水岸"
      }
    ]

    function render(data) {
      var $container = $('#container')

      //清空現有內容
      $container.html('')

      // 拼接 table
      var $table = $('<table>')
      $table.append($('<tr><td>name</td><td>age</td><td>address</td></tr>'))
      data.forEach(function (item) {
        $table.append($('<tr><td>'+ item.name +'</td><td>'+item.age+'</td><td>'+item.address+'</td></tr>'))
      })
      
      // 渲染到頁面
      $container.append($table)
    }

    $('#btn-change').click(function () {
      data[1].age = 30
      data[2].address = '上海'
      render(data)
    })
    
    // 初始化時候渲染
    render(data)
複製程式碼

可以看到,我們將data的第2項的age和 第3項的address資料更換了,點選change按鈕:

虛擬DOM和Diff演算法 - 入門級

vdom解決的問題

我們可以從圖中看到,我們只是更改了表格的部分資料,但是整個tabel節點就全部閃爍,說明整個table都被替換了一遍。

這個合乎常理的JQuery操,及時是web頁面效能的巨大殺手。因為它更改了不需要更改的dom節點,如果你還不能事情的嚴重性,可以繼續往下看。

下面的程式碼的操作很簡單,建立一個空的div標籤,迴圈遍歷其中的屬性並將其拼列印出來

    var div = document.createElement('div')
    var item ,result = ''
    for (item in div) {
      result += ' | ' + item
    }
    console.log(result)
複製程式碼

虛擬DOM和Diff演算法 - 入門級

密密麻麻的屬性,更何況這還只是一級屬性,可想而知直接操作dom的方式是有多麼費時,dom操作是費時的,但是Js作為一門語言,執行速度是非常快的,我們如果在Js層做dom對比,儘量減少不必要的dom操作,而不是每一次都全部翻修,我們的效率就會大大增加。而vdom就可以完美解決這個問題。

如何使用虛擬dom

說了這麼多虛擬dom的好,有同學會問,如何使用虛擬dom呢?

要了解如何使用vdom,我們可以藉助現有的vdom實現庫,來了解其API,進而瞭解如何將vdom運用於開發中。

這裡我們選擇一個Vue2中使用的虛擬dom庫 snabbdom,下面圖是截得它github主頁的示範案例:

虛擬DOM和Diff演算法 - 入門級

仔細觀察後我們可以發現,這個snabbdom官方案例中,核心內容就是兩個函式 -- h函式patch函式

h函式

可以看到 h 函式,有三個引數

  • 標籤選擇器
  • 屬性
  • 子節點

比如說第一個h函式生成的vnode,就是一個div標籤,繫結了click事件為someFn,第一個child為帶有style的spansapn裡是一個文字節點This is bold,第二個child就直接是一個文字節點,第三個child就是一個帶有herfa連結

patch函式

patch 分為兩種情況

  • 第一種是第一次渲染的時候 patch將vnode丟到container空容器中
       var vnode = h('ul#list',{},[
        h('li.item',{},'大冰哥'),
        h('li.item',{},'倫哥'),
        h('li.item',{},'阿孔')
      ])
    
      patch(container, vnode) // vnode 將 container 節點替換
    複製程式碼

第一次patch渲染的時候,是將生成的vnode往空容器裡丟 可以對比之前的Jquery第一次渲染表格的時候,將table html append到容器中去

  • 第二種是更新節點的時候,newVnodeoldVnode替換
    btn.addEventListener('click',function() {
      var newVnode = h('ul#list',{},[
        h('li.item',{},'大冰哥'),
        h('li.item',{},'倫哥'),
        h('li.item',{},'孔祥宇'),
        h('li.item',{},'小老弟'),
      ])
      patch(vnode, newVnode)
    })
    複製程式碼

這裡的patch就會將的vonde和之前的vnode進行比對,只修改改動的地方,沒動的地方保持不變,這裡的核心就是涉及的diff演算法

虛擬DOM和Diff演算法 - 入門級

我們可以清楚的看到,相對於之前的JQuery整個頁面dom全部替換的情況,用vdom的pathc函式只修改了我們相對老的vnode變動的地方,沒改動的地方就沒用動(從頁面的閃爍可以看出來)

使用vdom重做之前Jq案例

vdom核心的api h函式和patch函式我們已經有個基本的瞭解了,為了鞏固對其的認識,我們接下來用snabbdom重做我們之前的JQuery案例

直接先上程式碼

<div id="container"></div>
  <button id="btn-change">change</button>
  <script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom.js"></script>
  <script src="https://cdn.bootcss.com/snabbdom/0.7.2/snabbdom-class.js"></script>
  <script src="https://cdn.bootcss.com/snabbdom/0.7.2/snabbdom-props.js"></script>
  <script src="https://cdn.bootcss.com/snabbdom/0.7.2/snabbdom-style.js"></script>
  <script src="https://cdn.bootcss.com/snabbdom/0.7.2/snabbdom-eventlisteners.js"></script>
  <script src="https://cdn.bootcss.com/snabbdom/0.7.3/h.js"></script>
  <script>
    let container = document.getElementById('container')
    let btn = document.getElementById('btn-change')
    let snabbdom = window.snabbdom
    let patch = snabbdom.init([
      snabbdom_class,
      snabbdom_props,
      snabbdom_style,
      snabbdom_eventlisteners
    ])
    let h = snabbdom.h
    let data = [
      {
        name: '張三',
        age: '20',
        address: '杭州'
      },
      {
        name: '李四',
        age: '22',
        address: '北京'
      },
      {
        name: '隔壁老王',
        age: '24',
        address: "西溪水岸"
      }
    ]
    data.unshift({
      name: '姓名',
      age: '年齡',
      address: '地址'
    })
    let vnode
    function render(data) {
      // 建立虛擬table節點 第三個引數,也就是虛擬table的孩子 應該是虛擬的 行節點
      let newVnode = h('table', {}, data.map(item => {
        let tds = [] // 列,作為虛擬行的子項
        let i
        for(i in item) {
          if (item.hasOwnProperty(i)) {
            tds.push(h('td', {}, item[i]+''))
          }
        }
        return h('tr', {}, tds) // 虛擬行節點的 孩子 應該是虛擬的 列節點
      }))

      if (vnode) {
        patch(vnode,newVnode)
      } else {
        // 初次渲染
        patch(container,newVnode)
      }
      vnode = newVnode
    }

    btn.addEventListener('click', function(){
      data[1].age = 30,
      data[3].name = '一個女孩',
      render(data)
    })
    
    // 初始化時候渲染
    render(data)
  </script>
</body>
複製程式碼

程式碼有點長,其實內容還是我們之前講的,程式碼主要乾了下面的事情

  • 引入snabbdom核心檔案,初始化h函式和patch函式
  • 第一次載入的時候render 其實本質就是patch(container,newVnode)
  • 之後點選change的時候,生成新的vnode,再patch(vnode,newVnode)

這裡的render函式重點講解一下

虛擬DOM和Diff演算法 - 入門級

  • newVnode生成的時候,第三個引數是childs
  • table的childs是行節點
  • tr行節點也是vnode,它再生成的時候也要使用h函式,第三個引數是td列vnode
  • td列vnode的第三個引數,就直接是文字節點啦,遍歷item的每一項push到tds陣列中就可以了
    虛擬DOM和Diff演算法 - 入門級

到了這裡,你對vdom應該有個大體的認識了,其實,與其說vdom快,更準確的說是相比於Jquer這種推翻dom的方式等,保證不慢而已。

總結

vdom的核心api

  • h('標籤名', '屬性', [子元素])
  • h('標籤名', '屬性', '文字')
  • patch(container, vnode)
  • patch(oldVnode,newVnode)

簡單介紹diff演算法

什麼是diff演算法

我們在平時工作中,其實很多時候都會使用到diff演算法

比如你在git提交程式碼的時候使用的 git diff 命令,再或者是網上的一些程式碼比對工具,而我們的虛擬dom,核心就是diff演算法,我們前面講過,找出有必要更新的節點更新,沒有更新的節點就不要動。這其中的核心就是如何找出哪些更新哪些不更新,這個過程就需要diff演算法來完成

通過patch簡單講diff

我們趁熱打鐵,還是使用之前的snabbdom庫來簡單的講下diff演算法的大體思路,在snabbdom中diff主要體現在patch中,我們接下來看一下patch的兩種情況 patch(container, vnode)patch(vnode, newVnode)

篇幅有限,(其實是能力有限), 這裡就簡單的講解,因為涉及到完成的diff演算法的話東西實在是太多太多,有興趣的可以去看一下snabbdom的原始碼

patch(container, vnode)

虛擬DOM和Diff演算法 - 入門級

我們知道這個patch的過程是將一個vnode(vdom)新增到空容器生成真實dom的過程,主要的程式碼流程如下:

function creatElement(vnode) {
  let tag = vnode.tag
  let attrs = vnode.attrs || {}
  let children = vnode.children || []
  // 無標籤 直接跳出
  if (!tag) {
    return null
  }
  // 建立元素
  let elem = document.createElement(tag)
  // 新增屬性
  for(let attrName in attrs) {
    if (attrs.hasOwnProperty(attrName)) {
      elem.setAttribute(arrtName, arrts[attrName])
    }
  }
  // 遞迴建立子元素
  children.forEach((childVnode) => {
    elem.appendChild(createElement(childVnode))
  })

  return elem
}
複製程式碼

簡化後的程式碼很簡單,大家也都能夠理解,其中的一個重要的點就是 自遞迴呼叫生成孩子節點,終止條件就是tagnull的情況

patch(vnode, newVnode)

這個patch過程就是比較差異的過程,我們這裡就只模擬最簡單的場景

虛擬DOM和Diff演算法 - 入門級

第三個item改變,又新增第四個item

// 簡化流程 假設跟標籤相同的兩個虛擬dom
function updateChildren (vnode, newVnode) {
  let children = vnode.children || []
  let newChildren = newVnode.children || []

  // 遍歷現有的孩子
  children.forEach((oldChild, index) => {
    let newChild = newChildren[index]
    if (newChild === null) {
      return
    }
    // 兩者tag一樣,值得比較
    if (oldChild.tag === newChild.tag) {
      // 遞迴繼續比較子項
      updateChildren(oldchild, newChild)
    } else {
      // 兩者tag不一樣
      replaceNode(oldChild, newChild)
    }
  })
}
複製程式碼

這裡面的點就也遞迴,這裡只是簡單的拿tag來判斷更新條件,其實實際的比這複雜很多很多; 而replace函式實際的操作就是將newVnode新生成的真實dom將老的dom替換掉,這裡涉及更多的是原生dom操作,就不在贅述了。

到這裡,基本的diff概念應該大家有個認識了,再次強調,這裡為了便於理解,將diff演算法的流程簡化了很多,實際的diff演算法的複雜程度遠遠高於以上這些,比如說

  • 節點的新增和刪除
  • 重新排序時以及這個過程的優化
  • 節點屬性樣式事件等的變化
  • 還有怎麼將演算法優化到極致等等。。

大家感興趣可以去深入瞭解。

總結

本文知識拋磚引玉,通過閱讀本文,讓不瞭解虛擬dom的同學對虛擬dom有一個很好的認知,對diff演算法有一個大體的認識。能達到這個效果,我覺得這篇文章就很有價值了。想要深入瞭解虛擬dom或者diff演算法的同學可以翻閱snabbdom的 patch.js的原始碼,加深學習。

番外 Vue的key

寫文章的時候碰到有vue key繫結的問題,這裡就藉助這股熱勁,結合虛擬dom和diff演算法,來了解一下Vue中的key

Vue 中的 key

首先Vue官網的解釋:

當 Vue.js 用 v-for 正在更新已渲染過的元素列表時,它預設用“就地複用”策略。如果資料項的順序被改變,Vue 將不會移動 DOM 元素來匹配資料項的順序, 而是簡單複用此處每個元素,並且確保它在特定索引下顯示已被渲染過的每個元素。

這裡的就地複用的策略複用的是沒有發生改變的元素,其他的還要依次重排。

為了給 Vue 一個提示,以便它能跟蹤每個節點的身份,從而重用和重新排序現有元素,你需要為每項提供一個唯一 key 屬性。理想的 key 值是每項都有的唯一 id。

我們在使用的使用經常會使用index(即陣列的下標)來作為key,但其實這是不推薦的一種使用方法

如何理解,我們看下面一個例子:

這裡有一個陣列資料

const list = [
    {
        id: 1,
        name: 'test1',
    },
    {
        id: 2,
        name: 'test2',
    },
    {
        id: 3,
        name: 'test3',
    },
]
複製程式碼

我們現在想要在其後面追加一條資料

const list = [
    {
        id: 1,
        name: 'test1',
    },
    {
        id: 2,
        name: 'test2',
    },
    {
        id: 3,
        name: 'test3',
    },
    {
        id: 4,
        name: '新增到最後的一條資料',
    },
]
複製程式碼

這個時候用 index 作為 key, 是沒有問題的,因為index在後面累加了1

但是如果插入的資料是插在中間而不是最後,

const list = [
    {
        id: 1,
        name: 'test1',
    },
    {
        id: 4,
        name: '不甘落後跑到第二的的一條資料',
    }
    {
        id: 2,
        name: 'test2',
    },
    {
        id: 3,
        name: 'test3',
    },
]
複製程式碼

這個時候就會會出現一個情況:

之前的資料                         之後的資料

key: 0  index: 0 name: test1     key: 0  index: 0 name: test1
key: 1  index: 1 name: test2     key: 1  index: 1 name: 不甘落後跑到第二的的一條資料
key: 2  index: 2 name: test3     key: 2  index: 2 name: test2
                                 key: 3  index: 3 name: test3
複製程式碼

這樣一來,追加資料以後,除了第一條資料能夠就地複用,後三條都要重新渲染,這顯然不是我們想要的結果。

唯一key來改善:

這次我們把每一項的key 繫結成唯一標示id

之前的資料                         之後的資料

key: 1  id: 1 index: 0 name: test1   key: 1  id: 1 index: 0  name: test1
key: 2  id: 2 index: 1 name: test2   key: 4  id: 4 index: 1  name: 不甘落後的一條資料
key: 3  id: 3 index: 2 name: test3   key: 2  id: 2 index: 2  name: test2
                                     key: 3  id: 3 index: 3  name: test3
複製程式碼

現在除了新增了id為4的不甘落後的資料是新加入的,其他的都複用了之前的dom,因為這裡通過唯一key來進行關聯,不會隨著順序的改變而重新渲染。

所以我們需要使用key來給每個節點做一個唯一標識,Vue的Diff演算法就可以正確的識別此節點,找到正確的位置區插入新的節點,所以一句話,key的作用主要是為了高效的更新虛擬DOM

靈魂畫手上線:

可以看到,當我們老的資料轉為新的資料時 [a,b,c,d] --> [a,e,b,c,d]

如果我們沒有使用一個正確的key,可能除了a資料可以複用以外,後面的四個資料都要重新渲染

而如果使用了一個正確的key的時候,就可以實現要更改的只有一處,也就是新增資料 e,其他的就會如箭頭所示,繼續對應複用。

vue中的key

相關文章