1. 程式人生 > >Virtual DOM的簡單實現

Virtual DOM的簡單實現

根據 cto unknown apply ima tex 同學 方法 識別

了解React的同學都知道,React提供了一個高效的視圖更新機制:Virtual DOM,因為DOM天生就慢,所以操作DOM的時候要小心翼翼,稍微改動就會觸發重繪重排,大量消耗性能。

1.Virtual DOM


Virtual DOM是利用JS的原生對象來模擬DOM,既然DOM是對象,我們也可以用原生的對象來表示DOM。

var element = {
  tagName: ‘ul‘, // 節點標簽名
  props: {
    class: ‘list‘ // 節點的屬性,ID,class...
  },
  children: [ // 該節點的子節點
    {tagName: ‘li‘, props: {class: ‘item‘}, children: [‘item one‘]},
    {tagName: ‘li‘, props: {class: ‘item‘}, children: [‘item two‘]},
    {tagName: ‘li‘, props: {class: ‘item‘}, children: [‘item three‘]}
  ]
}

對應成相應的HTML結構為:

<ul class="list">
    <li class="item">item one</li>
    <li class="item">item two</li>
    <li class="item">item three</li>
</ul>

但是這又有什麽用呢?不還是要操作DOM嗎?

開頭我們就說過,Virtual DOM是一個高效的視圖更新機制,沒錯,主要在更新。怎麽更新呢,那就要用到了我們之前用JS對象模擬的DOM樹了,就叫它對象樹把,我們對比前後兩棵對象樹,比較出需要更新視圖的地方,對需要更新視圖的地方才進行DOM操作,不需要更新的地方自然什麽都不做,這就避免了性能的不必要浪費,變相的提升了性能。

總之Virtual DOM算法主要包括這幾步:

  • 初始化視圖的時候,用原生JS對象表示DOM樹,生成一個對象樹,然後根據這個對象樹來生成一個真正的DOM樹,插入到文檔中。

  • 當狀態更新的時候,重新生成一個對象樹,將新舊兩個對象樹做對比,記錄差異。

  • 把記錄的差異應用到第一步生成的真正的DOM樹上,視圖跟新完成

其實就是一個雙緩沖的原理,既然CPU這麽快,讀取硬盤又這麽慢,我們就在中間加一個Cache。那麽,既然DOM操作也慢,我們們就可以在JS和DOM之間也加一個Cache,這個Cache就是我們的Virtual DOM了。

其實說白了Virtual DOM的原理就是只更新需要更新的地方,其他的一概不管。

2.用對象樹表示DOM樹


用JS對象表示DOM節點還是比較容易的,我們這需要記錄DOM節點的節點類型、屬性、還有子節點就好了。

class objectTree {
  constructor (tagName, props, children) {
    this.tagName = tagName
    this.props = props
    this.children = children
  }
}

我們可以通過這種方式創建一個對象樹:

var ul = new objectTree(‘ul‘, {id: ‘list‘}, [
  createObjectTree(‘li‘, {class: ‘item‘}, [‘Item 1‘]),
  createObjectTree(‘li‘, {class: ‘item‘}, [‘Item 2‘]),
  createObjectTree(‘li‘, {class: ‘item‘}, [‘Item 3‘])
])

對象樹存在一個render方法來將對象樹轉換成真正的DOM樹:

objectTree.prototype.render = function () {
  var elm = document.createElement(this.tagName)

  var props = this.props
  // 設置DOM節點的屬性
  for (var key in props) {
    elm.setAttribute(key, props[key])
  }

  var children = this.children || []
  children.forEach((child) => {
    // 如果子節點也是對象樹,則遞歸渲染,否則就是文本節點
    var childElm = (child instanceof objectTree) ? child.render() : document.createTextNode(child)
    elm.appendChild(childElm)
  })

  return elm
}

我們就可以將生成好的DOM樹插入到文檔裏了

var ul = new objectTree(‘ul‘, {id: ‘list‘}, [
  new objectTree(‘li‘, {class: ‘item‘}, [‘Item 1‘]),
  new objectTree(‘li‘, {class: ‘item‘}, [‘Item 2‘]),
  new objectTree(‘li‘, {class: ‘item‘}, [‘Item 3‘])
])

console.log(ul)

document.body.appendChild(ul.render())

我們生成的DOM已經添加到文檔裏了

技術分享圖片

3.比較兩個對象樹的差異


所謂Virtual DOM的diff算法,就是比較兩個對象樹的差異,也正是Virtual DOM的核心。

傳統的比較兩棵樹差異的算法,時間復雜度是O(n^3),大量操作DOM的時候肯定是接受不了的。所以React做了妥協,React結合WEB界面的特點,做了兩個簡單的假設,使得算法的復雜度降低到了O(n)。

  1. 相同的組件產生相似的DOM樹,不同的組件產生不同的DOM樹。

  2. 對於同一層次的一組子節點,它們可以通過唯一的id進行區分。

不同節點類型的比較

不同節點類型分為兩種情況:

  1. 節點類型不同。

  2. 節點類型相同,但是屬性不同。

先看第一種情況,如果是我們會怎麽做呢?肯定是直接刪除老的節點,然後在老節點的位置上將新節點插入。React也和我們的想法一樣,也符合我們對真實DOM操作的理解。

如果將老節點刪除,那麽老節點的子節點也同時被刪除,並且子節點也不會參與後續的比較。這也是算法復雜度能降低到O(n)的原因之一。

既然節點類型不同是這樣操作的,那麽組件也是一樣的邏輯了。應用第一個假設,不同組件之間有不同的DOM樹,與其花時間比較它們的DOM結構,還不如創建一個新的組件加到原來的組件上。

從不同節點的操作上我們可以推斷,React的diff算法是只對對象樹逐層比較。

逐層進行節點比較

在React中對樹的算法非常簡單,那就是對兩棵樹同一層次的節點進行比較。

有一張非常經典的圖:

技術分享圖片

React只會對相同顏色方框內的DOM節點進行比較,即同一個父節點下的所有子節點。當發現節點已經不存在,則該節點及其子節點會被完全刪除掉,不會用於進一步的比較。這樣只需要對樹進行一次遍歷,便能完成整個DOM樹的比較。

考慮下如果有這樣的DOM結構的變化:

技術分享圖片

我們想的操作是:R.remove(A), D.append(A)

但是因為React只會對同一層次的節點進行比較,當發現新的對象樹中沒有A節點時,就會完全刪除A,同理,會新創建一個A節點作為D的子節點。實際React的操作是:A.destroy(), A = new A(), A.append(new B()), A.append(new C()), D.append(A)

由此我們可以根據React只對同一層次的節點比較可以作出的優化是:盡量不要跨層級的修改DOM

相同節點類型的比較

剛才我們說過,相通節點類型屬性可能不同,React會對屬性進行重設,但要註意:Virtual DOM中style必須是個對象。

renderA: <div style={{color: ‘red‘}} />
renderB: <div style={{fontWeight: ‘bold‘}} />
=> [removeStyle color], [addStyle font-weight ‘bold‘]

key值的使用

我們經常在遍歷一個數組或列表需要一個標識一個唯一的key,這個key是幹什麽的呢?

這是初始視圖:

技術分享圖片

我們現在想在它們中間加一個F,也就是一個insert操作。

技術分享圖片

如果每個節點沒有一個唯一的key,React不能識別每個節點,那React就會將C更新成F,將D更新成C,最後在末尾插入一個D。

技術分享圖片

如果每個節點有一個唯一的key做標識,React會找到正確的位置去插入新的節點,從而提高了視圖更新的效率。

技術分享圖片

對於key我們可以給出的優化是:給每個列表元素加上一個唯一的key

4.diff算法的簡單實現


我們先對兩棵對象樹做一個深度優先的遍歷,這樣每一個節點都有一個唯一的標記:

技術分享圖片

在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的的樹進行對比。如果有差異的話就記錄到一個對象裏面。

// diff 函數,對比兩棵樹
function diff (oldTree, newTree) {
  var index = 0 // 當前節點的標誌
  var patches = {} // 用來記錄每個節點差異的對象
  dfsWalk(oldTree, newTree, index, patches)
  return patches
}

// 對兩棵樹進行深度優先遍歷
function dfsWalk (oldNode, newNode, index, patches) {
  // 對比oldNode和newNode的不同,記錄下來
  patches[index] = [...]

  diffChildren(oldNode.children, newNode.children, index, patches)
}

// 遍歷子節點
function diffChildren (oldChildren, newChildren, index, patches) {
  var leftNode = null
  var currentNodeIndex = index
  oldChildren.forEach(function (child, i) {
    var newChild = newChildren[i]
    currentNodeIndex = (leftNode && leftNode.count) // 計算節點的標識
      ? currentNodeIndex + leftNode.count + 1
      : currentNodeIndex + 1
    dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍歷子節點
    leftNode = child
  })
}

例如,上面的div和新的div有差異,當前的標記是0,那麽

patches[0] = [{difference}, {difference}, ...] // 用數組存儲新舊節點的不同

那我們所說的差異是什麽呢?

  1. 節點被替換

  2. 增加、刪除、移動子節點

  3. 修改了節點的屬性

  4. 若是文本節點,則文本內容可能會被改變

所以我們定義了幾種類型:

var REPLACE = 0
var REORDER = 1
var PROPS = 2
var TEXT = 3

舉個例子,如果最外層的div被換成了section,則相應的記錄如下:

patches[0] = [{
  type: REPALCE,
  node: newNode // el(‘section‘, props, children)
}]

其他變化同理。

5.patch方法的實現


我們比較完了兩棵對象樹的差異,接下來就是將差異應用到DOM上了。這個過程有點像打補丁,所以我們叫它patch。

我們第一步構建出來的對象樹和真正的DOM樹的屬性、結構是一樣的,所以我們可以對DOM樹進行一次深度優先遍歷,遍歷的時候按著diff生成的patch對象進行patch操作,修改需要patch的地方。

我們還要根據不同的差異進行不同的DOM操作。

function patch (node, patches) {
  var walker = {index: 0}
  dfsWalk(node, walker, patches)
}

function dfsWalk (node, walker, patches) {
  var currentPatches = patches[walker.index] // 從patches拿出當前節點的差異

  var len = node.childNodes
    ? node.childNodes.length
    : 0
  for (var i = 0; i < len; i++) { // 深度遍歷子節點
    var child = node.childNodes[i]
    walker.index++
    dfsWalk(child, walker, patches)
  }

  if (currentPatches) {
    applyPatches(node, currentPatches) // 對當前節點進行DOM操作
  }
}

function applyPatches (node, currentPatches) {
  currentPatches.forEach(function (currentPatch) {
    switch (currentPatch.type) {
      case REPLACE:
        node.parentNode.replaceChild(currentPatch.node.render(), node)
        break
      case REORDER:
        reorderChildren(node, currentPatch.moves)
        break
      case PROPS:
        setProps(node, currentPatch.props)
        break
      case TEXT:
        node.textContent = currentPatch.content
        break
      default:
        throw new Error(‘Unknown patch type ‘ + currentPatch.type)
    }
  })
}

看過了別人的文章,也借鑒了別人的思想,加上自己的總結,代碼正在整理中。

Virtual DOM的簡單實現