淺析虛擬dom原理並實現

背景

你們都知道,在網頁中瀏覽器資源開銷最大即是DOM節點了,DOM很慢而且很是龐大,網頁性能問題大多數都是有JavaScript修改DOM所引發的。咱們使用Javascript來操縱DOM,操做效率每每很低,因爲DOM被表示爲樹結構,每次DOM中的某些內容都會發生變化,所以對DOM的更改很是快,但更改後的元素,而且它的子項必須通過Reflow / Layout階段,而後瀏覽器必須從新繪製更改,這很慢的。所以,迴流/重繪的次數越多,您的應用程序就越卡頓。可是,Javascript運行速度很快,虛擬DOM是放在JS 和 HTML中間的一個層。它能夠經過新舊DOM的對比,來獲取對比以後的差別對象,而後有針對性的把差別部分真正地渲染到頁面上,從而減小實際DOM操做,最終達到性能優化的目的。node

虛擬dom原理流程

簡單歸納有三點:react

  1. 用JavaScript模擬DOM樹,並渲染這個DOM樹
  2. 比較新老DOM樹,獲得比較的差別對象
  3. 把差別對象應用到渲染的DOM樹。

下面是流程圖:web

 

下面咱們用代碼一步步去實現一個流程圖算法

用JavaScript模擬DOM樹並渲染到頁面上

其實虛擬DOM,就是用JS對象結構的一種映射,下面咱們一步步實現這個過程。npm

咱們用JS很容易模擬一個DOM樹的結構,例如用這樣的一個函數createEl(tagName, props, children)來建立DOM結構。瀏覽器

tagName標籤名、 props是屬性的對象、 children是子節點。

而後渲染到頁面上,代碼以下:性能優化

const createEl = (tagName, props, children) => new CreactEl(tagName, props, children)

const vdom = createEl('div', { 'id': 'box' }, [
  createEl('h1', { style: 'color: pink' }, ['I am H1']),
  createEl('ul', {class: 'list'}, [createEl('li', ['#list1']), createEl('li', ['#list2'])]),
  createEl('p', ['I am p'])
])

const rootnode = vdom.render()
document.body.appendChild(rootnode)

經過上面的函數,調用vdom.render()這樣子咱們就很好的構建了以下所示的一個DOM樹,而後渲染到頁面上app

<div id="box">
  <h1 style="color: pink;">I am H1</h1>
  <ul class="list">
    <li>#list1</li>
    <li>#list2</li>
  </ul>
  <p>I am p</p>
</div>

下面咱們看看CreactEl.js代碼流程:dom

import { setAttr } from './utils'
class CreateEl {
  constructor (tagName, props, children) {
    // 當只有兩個參數的時候 例如 celement(el, [123])
    if (Array.isArray(props)) {
      children = props
      props = {}
    }
    // tagName, props, children數據保存到this對象上
    this.tagName = tagName
    this.props = props || {}
    this.children = children || []
    this.key = props ? props.key : undefined

    let count = 0
    this.children.forEach(child => {
      if (child instanceof CreateEl) {
        count += child.count
      } else {
        child = '' + child
      }
      count++
    })
    // 給每個節點設置一個count
    this.count = count
  }
  // 構建一個 dom 樹
  render () {
    // 建立dom
    const el = document.createElement(this.tagName)
    const props = this.props
    // 循環全部屬性,而後設置屬性
    for (let [key, val] of Object.entries(props)) {
      setAttr(el, key, val)
    }
    this.children.forEach(child => {
      // 遞歸循環 構建tree
      let childEl = (child instanceof CreateEl) ? child.render() : document.createTextNode(child)
      el.appendChild(childEl)
    })
    return el
  }
}

上面render函數的功能是把節點建立好,而後設置節點屬性,最後遞歸建立。這樣子咱們就獲得一個DOM樹,而後插入(appendChild)到頁面上。ide

比較新老dom樹,獲得比較的差別對象

上面,咱們已經建立了一個DOM樹,而後在建立一個不一樣的DOM樹,而後作比較,獲得比較的差別對象。

比較兩棵DOM樹的差別,是虛擬DOM的最核心部分,這也是人們常說的虛擬DOM的diff算法,兩顆徹底的樹差別比較一個時間複雜度爲 O(n^3)。可是在咱們的web中不多用到跨層級DOM樹的比較,因此一個層級跟一個層級對比,這樣算法複雜度就能夠達到 O(n)。以下圖

 

其實在代碼中,咱們會從根節點開始標誌遍歷,遍歷的時候把每一個節點的差別(包括文本不一樣,屬性不一樣,節點不一樣)記錄保存起來。以下圖:

 

兩個節點之間的差別有總結起來有下面4種

0 直接替換原有節點
1 調整子節點,包括移動、刪除等
2 修改節點屬性
3 修改節點文本內容

以下面兩棵樹比較,把差別記錄下來。

 

主要是簡歷一個遍歷index(看圖3),而後從根節點開始比較,比較萬以後記錄差別對象,繼續從左子樹比較,記錄差別,一直遍歷下去。主要流程以下

// 這是比較兩個樹找到最小移動量的算法是Levenshtein距離,即O(n * m)
// 具體請看 https://www.npmjs.com/package/list-diff2
import listDiff from 'list-diff2'
// 比較兩棵樹
function diff (oldTree, newTree) {
  // 節點的遍歷順序
  let index = 0
  // 在遍歷過程當中記錄節點的差別
  let patches = {}
  // 深度優先遍歷兩棵樹
  deepTraversal(oldTree, newTree, index, patches)
  // 獲得的差別對象返回出去
  return patches
}

function deepTraversal(oldNode, newNode, index, patches) {
  let currentPatch = []
  // ...中間有不少對patches的處理
  // 遞歸比較子節點是否相同
  diffChildren(oldNode.children, newNode.children, index, patches, currentPatch)
  if (currentPatch.length) {
    // 那個index節點的差別記錄下來
    patches[index] = currentPatch
  }
}

// 子數的diff
function diffChildren (oldChildren, newChildren, index, patches, currentPatch) {
  const diffs = listDiff(oldChildren, newChildren)
  newChildren = diffs.children
  // ...省略記錄差別對象
  let leftNode = null
  let currentNodeIndex = index
  oldChildren.forEach((child, i) => {
    const newChild = newChildren[i]
    // index相加
    currentNodeIndex = (leftNode && leftNode.count) ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1
    // 深度遍歷,遞歸
    deepTraversal(child, newChild, currentNodeIndex, patches)
    // 從左樹開始
    leftNode = child
  })
}

而後咱們調用完diff(tree, newTree)等到最後的差別對象是這樣子的。

{
  "1": [
    {
      "type": 0,
      "node": {
        "tagName": "h3",
        "props": {
          "style": "color: green"
        },
        "children": [
          "I am H1"
        ],
        "count": 1
      }
    }
  ]
  ...
}

key是表明那個節點,這裏咱們是第二個,也就是h1會改變成h3,還有省略的兩個差別對象代碼沒有貼出來~~

而後看下diff.js的完整代碼,以下

import listDiff from 'list-diff2'
// 每一個節點有四種變更
export const REPLACE = 0 // 替換原有節點
export const REORDER = 1 // 調整子節點,包括移動、刪除等
export const PROPS = 2 // 修改節點屬性
export const TEXT = 3 // 修改節點文本內容

export function diff (oldTree, newTree) {
  // 節點的遍歷順序
  let index = 0
  // 在遍歷過程當中記錄節點的差別
  let patches = {}
  // 深度優先遍歷兩棵樹
  deepTraversal(oldTree, newTree, index, patches)
  // 獲得的差別對象返回出去
  return patches
}

function deepTraversal(oldNode, newNode, index, patches) {
  let currentPatch = []
  if (newNode === null) { // 若是新節點沒有的話直接不用比較了
    return
  }
  if (typeof oldNode === 'string' && typeof newNode === 'string') {
    // 比較文本節點
    if (oldNode !== newNode) {
      currentPatch.push({
        type: TEXT,
        content: newNode
      })
    }
  } else if (oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) {
    // 節點類型相同
    // 比較節點的屬性是否相同
    let propasPatches = diffProps(oldNode, newNode)
    if (propasPatches) {
      currentPatch.push({
        type: PROPS,
        props: propsPatches
      })
    }
    // 遞歸比較子節點是否相同
    diffChildren(oldNode.children, newNode.children, index, patches, currentPatch)
  } else {
    // 節點不同,直接替換
    currentPatch.push({ type: REPLACE, node: newNode })
  }

  if (currentPatch.length) {
    // 那個index節點的差別記錄下來
    patches[index] = currentPatch
  }

}

// 子數的diff
function diffChildren (oldChildren, newChildren, index, patches, currentPatch) {
  var diffs = listDiff(oldChildren, newChildren)
  newChildren = diffs.children
  // 若是調整子節點,包括移動、刪除等的話
  if (diffs.moves.length) {
    var reorderPatch = {
      type: REORDER,
      moves: diffs.moves
    }
    currentPatch.push(reorderPatch)
  }

  var leftNode = null
  var currentNodeIndex = index
  oldChildren.forEach((child, i) => {
    var newChild = newChildren[i]
    // index相加
    currentNodeIndex = (leftNode && leftNode.count) ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1
    // 深度遍歷,從左樹開始
    deepTraversal(child, newChild, currentNodeIndex, patches)
    // 從左樹開始
    leftNode = child
  })
}

// 記錄屬性的差別
function diffProps (oldNode, newNode) {
  let count = 0 // 聲明一個有沒沒有屬性變動的標誌
  const oldProps = oldNode.props
  const newProps = newNode.props
  const propsPatches = {}

  // 找出不一樣的屬性
  for (let [key, val] of Object.entries(oldProps)) {
    // 新的不等於舊的
    if (newProps[key] !== val) {
      count++
      propsPatches[key] = newProps[key]
    }
  }
  // 找出新增的屬性
  for (let [key, val] of Object.entries(newProps)) {
    if (!oldProps.hasOwnProperty(key)) {
      count++
      propsPatches[key] = val
    }
  }
  // 沒有新增 也沒有不一樣的屬性 直接返回null
  if (count === 0) {
    return null
  }

  return propsPatches
}

獲得差別對象以後,剩下就是把差別對象應用到咱們的dom節點上面了。

把差別對象應用到渲染的dom樹

到了這裏其實就簡單多了。咱們上面獲得的差別對象以後,而後選擇一樣的深度遍歷,若是那個節點有差別的話,判斷是上面4種中的哪種,根據差別對象直接修改這個節點就能夠了。

function patch (node, patches) {
  // 也是從0開始
  const step = {
    index: 0
  }
  // 深度遍歷
  deepTraversal(node, step, patches)
}

// 深度優先遍歷dom結構
function deepTraversal(node, step, patches) {
  // 拿到當前差別對象
  const currentPatches = patches[step.index]
  const len = node.childNodes ? node.childNodes.length : 0
  for (let i = 0; i < len; i++) {
    const child = node.childNodes[i]
    step.index++
    deepTraversal(child, step, patches)
  }
  //若是當前節點存在差別
  if (currentPatches) {
    // 把差別對象應用到當前節點上
    applyPatches(node, currentPatches)
  }
}

這樣子,調用patch(rootnode, patches)就直接有針對性的改變有差別的節點了。

path.js完整代碼以下:

import {REPLACE, REORDER, PROPS, TEXT} from './diff'
import { setAttr } from './utils'

export function patch (node, patches) {
  // 也是從0開始
  const step = {
    index: 0
  }
  // 深度遍歷
  deepTraversal(node, step, patches)
}

// 深度優先遍歷dom結構
function deepTraversal(node, step, patches) {
  // 拿到當前差別對象
  const currentPatches = patches[step.index]
  const len = node.childNodes ? node.childNodes.length : 0
  for (let i = 0; i < len; i++) {
    const child = node.childNodes[i]
    step.index++
    deepTraversal(child, step, patches)
  }
  //若是當前節點存在差別
  if (currentPatches) {
    // 把差別對象應用到當前節點上
    applyPatches(node, currentPatches)
  }
}

// 把差別對象應用到當前節點上
function applyPatches(node, currentPatches) {
  currentPatches.forEach(currentPatch => {
    switch (currentPatch.type) {
      // 0: 替換原有節點
      case REPLACE:
        var newNode = (typeof currentPatch.node === 'string') ?  document.createTextNode(currentPatch.node) : currentPatch.node.render()
        node.parentNode.replaceChild(newNode, node)
        break
      // 1: 調整子節點,包括移動、刪除等
      case REORDER: 
        moveChildren(node, currentPatch.moves)
        break
      // 2: 修改節點屬性
      case PROPS:
        for (let [key, val] of Object.entries(currentPatch.props)) {
          if (val === undefined) {
            node.removeAttribute(key)
          } else {
            setAttr(node, key, val)
          }
        }
        break;
      // 3:修改節點文本內容
      case TEXT:
        if (node.textContent) {
          node.textContent = currentPatch.content
        } else {
          node.nodeValue = currentPatch.content
        }
        break;
      default: 
        throw new Error('Unknow patch type ' + currentPatch.type);
    }
  })
}

// 調整子節點,包括移動、刪除等
function moveChildren (node, moves) {
  let staticNodelist = Array.from(node.childNodes)
  const maps = {}
  staticNodelist.forEach(node => {
    if (node.nodeType === 1) {
      const key = node.getAttribute('key')
      if (key) {
        maps[key] = node
      }
    }
  })
  moves.forEach(move => {
    const index = move.index
    if (move.type === 0) { // 變更類型爲刪除的節點
      if (staticNodeList[index] === node.childNodes[index]) {
        node.removeChild(node.childNodes[index]);
      }
      staticNodeList.splice(index, 1);
    } else {
      let insertNode = maps[move.item.key] 
          ? maps[move.item.key] : (typeof move.item === 'object') 
          ? move.item.render() : document.createTextNode(move.item)
      staticNodelist.splice(index, 0, insertNode);
      node.insertBefore(insertNode, node.childNodes[index] || null)
    }
  })
}

到這裏,最基本的虛擬DOM原理已經講完了,也簡單了實現了一個虛擬DOM,若是本文有什麼不對的地方請指正。

相關文章
相關標籤/搜索