1. 程式人生 > 其它 >React Virtual Dom 與 Diff

React Virtual Dom 與 Diff

歡迎關注前端早茶,與廣東靚仔攜手共同進階

前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~

React 核心知識點 -- Virtual Dom 與 Diff

React 最值得稱道部分就是 Virtual DOM 和 Diff ,這兩塊核心點方便我們更好的抽象化的開發元件,提高渲染效率。

Virtual Dom

Virtual DOM 是一種程式設計概念。在這個概念裡, UI 以一種理想化的,或者說“虛擬的 DOM TREE”表現形式被保存於記憶體中,並通過如 ReactDOM 等類庫使之與“真實的” DOM 同步。

Shadow DOM和 Virtual DOM 不一樣。Shadow DOM 是一種瀏覽器技術,主要用於在 web 元件中封裝變數和 CSS。Virtual DOM 則是一種由 Javascript 類庫基於瀏覽器 API 實現的概念。

建立 Virtual Dom

function createElement(tagName, props, ...children) {
  return {
    tagName, 
    props,
    children,
  }
}
// vnode { tagName: 'div', props: { className: 'one' }, children: [{tagName: 'li', props: { className: 'kk', children: [...] }}, children: [{tagName: 'li', props: { className: 'zz', children: ['kkk'] }}] }
const vnode = createElement('div', {className: 'one'}, 
  createElement('li', {className: 'kk'}, 
    createElement('span', {className: 'txt'}, 'kkk'),
    createElement('li', {className: 'zz'}, 'kkk')
  ),
  createElement('li', {className: 'one'}, 'one')
)

JSX 僅僅只是React.createElement(component, props, ...children)函式的語法糖,https://reactjs.org/docs/jsx-in-depth.html

React 中此部分 JSX 語法糖,通過plugin-transform-react-jsx轉換為React.createElement函式的語法糖:

Diff 遍歷演算法

遍歷 Tree Dom 結構,涉及常用資料演算法:廣度優先搜尋(breadth-first search,BFS),廣度優先遍歷(breadth-first traversal,BFT),深度優先遍歷(depth-first traversal,DFT)。筆者這裡只討論:廣度優先遍歷(breadth-first traversal,BFT),深度優先遍歷(depth-first traversal,DFT)。

廣度優先遍歷(breadth-first traversal,BFT)

廣度優先遍歷(BFT breadth-first traversal):廣度優先遍歷是連通圖的一種遍歷策略,它的思想是從一個頂點V0開始,輻射狀地優先遍歷其周圍較廣的區域,故得名。樹的廣度優先遍歷是指優先遍歷完某一層的所有節點,然後再依次向下層遍歷。

步驟:

  • 建立一個佇列,並將開始節點放入佇列中
  • 若佇列非空,則從佇列中取出第一個節點,並檢測它是否為目標節點
  • 若是目標節點,則結束搜尋,並返回結果。若不是,則將它所有沒有被檢測過的位元組點都加入佇列中
  • 若佇列為空,表示圖中並沒有目標節點,則結束遍歷

廣度優先演算法的的非遞迴實現:

function wideTraversal(vnode) {  
  if(!vnode) {
    throw new Error('Empty Tree')
  }
  const nodeList = []
  const queue = []
  queue.push(vnode)
  while (queue.length !== 0) {  
      const node = queue.shift()
      nodeList.push(node)
      if(node.children){
        const children = node.children
        for (let i = 0; i < children.length; i++)  
            queue.push(children[i])
      }
  }  
  return nodeList
}

深度優先遍歷(depth-first traversal,DFT)

深度優先遍歷(DFT depth-first traversal):樹的深度優先遍歷是指首先遍歷樹的某個分支上的所有節點,在遍歷另一個分支的節點,對於節點中的分支也這樣處理。React 中 Dom Diff 採用的深度優先遍歷演算法,至於React 為何不使用廣度優先遍歷得到的答案是可能會導致元件的生命週期亂套。

步驟:

  • 訪問頂點V0
  • 依次從V0的未被訪問的鄰接點出發,對樹進行深度優先遍歷;直至樹中和V0有路徑相通的頂點都被訪問
  • 若此時途中尚有頂點未被訪問,則從一個未被訪問的頂點出發,重新進行深度優先遍歷,直到所有頂點均被訪問過為止

深度優先演算法的遞迴實現:

function deepTraversal(vnode) {  
  if(!vnode) {
    throw new Error('Empty Tree')
  }
  const nodeList = []
  walk(vnode, nodeList)   
  return nodeList 
}  

function walk(vnode, nodeList = []) {
  nodeList.push(vnode)
  if(vnode.children){
    const children = vnode.children
    children.forEach(node => {
      walk(node, nodeList)   
    })
  }
}

深度優先演算法的非遞迴實現:

function deepTraversal(vnode) {  
  if(!vnode) {
    throw new Error('Empty Tree')
  }
  const nodeList = []
  const stack = []
  stack.push(vnode)
  while (stack.length !== 0) {  
    const node = stack.pop()
    nodeList.push(node)
    if(node.children){
      const children = node.children
      for (let i = children.length - 1; i >= 0; i--)  
        stack.push(children[i])
    }
  }  
  return nodeList 
}  

React dom diff 演算法

傳統 diff 演算法

計算一棵樹形結構轉換成另一棵樹形結構的最少操作,是一個複雜且值得研究的問題。傳統 diff 演算法通過迴圈遞迴對節點進行依次對比,效率低下,演算法複雜度達到O(n^3),其中 n 是樹中節點的總數。O(n^3)到底有多可怕,這意味著如果要展示 1000 個節點,就要依次執行上十億次的比較。這個開銷實在是太過高昂。現今的 CPU 每秒鐘能執行大約30億條指令,即便是最高效的實現,也不可能在一秒內計算出差異情況。

如果 React 只是單純的引入 diff 演算法而沒有任何的優化改進,那麼其效率是遠遠無法滿足前端渲染所要求的效能。

那麼,React diff 到底是如何實現的穩定高效的 diff 呢?

詳解 React diff

傳統 diff 演算法的複雜度為 O(n^3),顯然這是無法滿足效能要求的。React 通過制定大膽的策略,將 O(n^3) 複雜度的問題轉換成 O(n) 複雜度的問題。

diff 策略

  1. Web UI 中 DOM 節點跨層級的移動操作特別少,可以忽略不計。
  2. 擁有相同類的兩個元件將會生成相似的樹形結構,擁有不同類的兩個元件將會生成不同的樹形結構。
  3. 對於同一層級的一組子節點,它們可以通過唯一 id 進行區分(節點移動會導致 diff 開銷較大,通過 key 進行優化)。

基於以上三個前提策略,React 分別對 tree diff、component diff 以及 element diff 進行演算法優化,事實也證明這三個前提策略是合理且準確的,它保證了整體介面構建的效能。

  • tree diff
  • component diff
  • element diff

tree diff

基於策略一,React 對樹的演算法進行了簡潔明瞭的優化,即對樹進行分層比較,兩棵樹只會對同一層次的節點進行比較。

既然 DOM 節點跨層級的移動操作少到可以忽略不計,針對這一現象,React 通過 updateDepth 對 Virtual DOM 樹進行層級控制,只會對相同顏色方框內的 DOM 節點進行比較,即同一個父節點下的所有子節點。當發現節點已經不存在,則該節點及其子節點會被完全刪除掉,不會用於進一步的比較。這樣只需要對樹進行一次遍歷,便能完成整個 DOM 樹的比較。

component diff

React 是基於元件構建應用的,對於元件間的比較所採取的策略也是簡潔高效。

  • 如果是同一型別的元件,按照原策略繼續比較 virtual DOM tree。
  • 如果不是,則將該元件判斷為 dirty component,從而替換整個元件下的所有子節點。
  • 對於同一型別的元件,有可能其 Virtual DOM 沒有任何變化,如果能夠確切的知道這點那可以節省大量的 diff 運算時間,因此 React 允許使用者通過 shouldComponentUpdate() 來判斷該元件是否需要進行 diff。

element diff

當節點處於同一層級時,React diff 提供了三種節點操作,分別為:INSERT_MARKUP(插入)、MOVE_EXISTING(移動)、TEXT_CONTENT(文字內容)、REMOVE_NODE(刪除)。

詳情參考DOMChildrenOperations.js

型別情況
MOVE_EXISTING 新的component型別在老的集合裡也有,並且element是可以更新的型別,在generateComponentChildren我們已經呼叫了receiveComponent,這種情況下prevChild=nextChild,那我們就需要做出移動的操作,可以複用以前的dom節點。
INSERT_MARKUP 新的component型別不在老的集合裡,那麼就是全新的節點,我們需要插入新的節點
REMOVE_NODE 老的component型別,在新的集合裡也有,但是對應的element不同了不能直接複用直接更新,那我們也得刪除。
REMOVE_NODE 老的component不在新的集合裡的,我們需要刪除

演算法實現

步驟一: 建立 Virtual Dom

// virtual-dom
function createElement(tagName, props = {}, ...children) {
  let vnode = {}
  if(props.hasOwnProperty('key')){
    vnode.key = props.key
    delete props.key
  }
  return Object.assign(vnode, {
    tagName,
    props,
    children,
  })
}

步驟二:渲染 Virtual Dom

function render (vNode) {
  const element = document.createElement(vNode.tagName)
  const props = vNode.props

  for (const key in props) {
    const value = props[key]
    element.setAttribute(key, value)
  }

  vNode.children && vNode.children( child => {
    const childElement = typeof child === 'string' ? document.createTextNode(child) : render(child)
    element.appendChild(childElement)
  })

  return element
}

export default render

步驟三:Dom Diff

  • 根據節點變更型別,定義幾種變化型別
    • 節點移除
    • 節點替換
    • 文字替換
    • 節點屬性變更
    • 插入節點
    • 節點移動
const vpatch = {}
vpatch.REMOVE = 0
vpatch.REPLACE = 1  // node replace
vpatch.TEXT = 2  // text replace
vpatch.PROPS = 3
vpatch.INSERT = 4
vpatch.REORDER = 5

export default vpatch
  • diff virtual Dom

/**
 * 二叉樹 diff
 * @param lastVNode
 * @param newVNode
 */
function diff (lastVNode, newVNode) {
  let index = 0
  const patches = {}
  // patches.old = lastVNode
  dftWalk(lastVNode, newVNode, index, patches)
  return patches
}

/**
 * 深入優先遍歷演算法 (depth-first traversal,DFT)
 * @param {*} lastVNode
 * @param {*} newVNode
 * @param {*} index
 * @param {*} patches
 */
function dftWalk(lastVNode, newVNode, index, patches) {
  if (lastVNode === newVNode) {
    return
  }

  const currentPatch = []

  // Node is removed
  if (newVNode === null) {
    currentPatch.push({ type: patch.REMOVE })

  // diff text
  } else if (_.isString(lastVNode) && _.isString(newVNode)) {
    if (newVNode !== lastVNode) {
      currentPatch.push({ type: patch.TEXT, text: newVNode })
    }

  // same node  此處會出行,parentNode 先 moves 處理,childnode 再做一次處理(替換或修改屬性)
  } else if (
    newVNode.tagName === lastVNode.tagName
    // && newVNode.key === lastVNode.key
  ) {
    // newVNode.key === lastVNode.key 才會執行 diffChildren
    if (newVNode.key === lastVNode.key) {
      const propsPatches = diffProps(lastVNode.props, newVNode.props)
      if (Object.keys(propsPatches).length > 0) {
        currentPatch.push({ type: patch.PROPS, props: propsPatches })
      }

      diffChildren(
        lastVNode.children,
        newVNode.children,
        currentPatch,
        index,
        patches,
      )
    } else {
      currentPatch.push({ type: patch.REPLACE, node: newVNode })
    }

  // Nodes are not the same
  } else {
    currentPatch.push({ type: patch.REPLACE, node: newVNode })
  }

  if (currentPatch.length) {
    patches[index] = currentPatch
  }
}

function diffChildren (lastChildren, newChildren, apply, index, patches) {
  const orderedSet = reorder(lastChildren, newChildren)
  let len = lastChildren.length > newChildren.length ? lastChildren.length : newChildren.length
  for (var i = 0; i < len; i++) {
    if (!lastChildren[i]) {

      // insert node
      if (newChildren[i] && !orderedSet.moves) {
        apply.push({ type: patch.INSERT, node: newChildren[i] })
      }

    } else {
      dftWalk(lastChildren[i], newChildren[i], ++index, patches)
    }
  }
  console.error('orderedSet.moves', orderedSet.moves)
  if (orderedSet.moves) {
    apply.push(orderedSet)
  }
}

/**
 * diff vnode props
 * @param {*} lastProps
 * @param {*} newProps
 */
function diffProps (lastProps, newProps) {
  const propsPatches = {}

  // Find out diff props
  for (const key in lastProps) {
    if (newProps[key] !== lastProps[key]) {
      propsPatches[key] = newProps[key]
    }
  }


  // Find out new props
  for (const key in newProps) {
    if (!lastProps.hasOwnProperty(key)) {
      propsPatches[key] = newProps[key]
    }
  }
  return propsPatches
}

/**
 * List diff, naive left to right reordering
 * @param {*} lastChildren
 * @param {*} newChildren
 *
 * 原理利用中間陣列 simulate, remove得到子集、insert 插入操作完成
 * 例 oldList [1,4,6,8,9] newList [0,1,3,5,6]
 * 轉換拿到中間陣列按老陣列索引 [1, null, 6, null, null ]
 * remove null 得到子集 [1, 6]
 * insert 插入完成
 */
function reorder(lastChildren, newChildren) {
  const lastMap = keyIndex(lastChildren)
  const lastKeys = lastMap.keys
  const lastFree = lastMap.free

  if(lastFree.length === lastChildren.length){
    return {
      moves: null
    }
  }


  const newMap = keyIndex(newChildren)
  const newKeys = newMap.keys
  const newFree = newMap.free

  if(newFree.length === newChildren.length){
    return {
      moves: null
    }
  }

  // simulate list to manipulate
  const children = []
  let freeIndex = 0

  for (let i = 0 ; i < lastChildren.length; i++) {
    const item = lastChildren[i]
    if(item.key){
      if(newKeys.hasOwnProperty('key')){
        const itemIndex = newKeys[item.key]
        children.push(newChildren[itemIndex])
      } else {
        children.push(null)
      }
    } else {
      const itemIndex = newFree[freeIndex++]
      children.push(newChildren[itemIndex] || null)
    }
  }

  const simulate = children.slice()
  const removes = []
  const inserts = []

  let j = 0

  // remove  value is null and  no key property
  while (j < simulate.length) {
    if (simulate[j] === null || !simulate[j].hasOwnProperty('key')) {
      const patch = remove(simulate, j)
      removes.push(patch)
    } else {
      j++
    }
  }

  console.error('simulate', simulate)
  for (let i = 0 ; i < newChildren.length; i++) {
    const wantedItem = newChildren[i]
    const simulateItem = simulate[i]

    if(wantedItem.key){
      if(simulateItem && wantedItem.key !== simulateItem.key){
        // key property is not equal, insert, simulateItem add placeholder
        inserts.push({
          type: patch.INSERT,
          index: i,
          node: wantedItem,
        })
        simulateItem.splice(i, 1)
      }
    } else {
      // no key property, insert, simulateItem add placeholder
      inserts.push({
        type: patch.INSERT,
        index: i,
        node: wantedItem,
      })
      simulateItem && simulateItem.splice(i, 1)
    }
  }

  return {
    type: patch.REORDER,
    moves: {
      removes: removes,
      inserts: inserts
    }
  }
}

function remove(arr, index) {
  arr.splice(index, 1)

  return {
    type: patch.REMOVE,
    index,
  }
}


/**
 * Convert list to key-item keyIndex object.
 * @param {*} children
 * convert [{id: "a", key: 'a'}, {id: "b", key: 'b'}, {id: "a"}]
 * result { keys: { a: 0, b: 1}, free: [ 2 ] }
 */
function keyIndex(children) {
  var keys = {}
  var free = []
  var length = children.length

  for (var i = 0; i < length; i++) {
      var child = children[i]

      if (child.key) {
          keys[child.key] = i
      } else {
          free.push(i)
      }
  }

  return {
      keys: keys,     // A hash of key name to index
      free: free      // An array of unkeyed item indices
  }
}

export default diff

步驟四:收集 patchs

{
  "0": [
    {
      "type": 3,
      "props": {
        "className": "zz",
        "height": "20px"
      }
    },
    {
      "type": 5,
      "moves": {
        "removes": [
          {
            "type": 0,
            "index": 0
          },
          {
            "type": 0,
            "index": 0
          }
        ],
        "inserts": [
          {
            "type": 4,
            "index": 1,
            "node": {
              "tagName": "li",
              "props": {
                "className": "one"
              },
              "children": [
                "one"
              ]
            }
          }
        ]
      }
    }
  ],
  "1": [
    {
      "type": 5,
      "moves": {
        "removes": [
          {
            "type": 0,
            "index": 0
          },
          {
            "type": 0,
            "index": 0
          }
        ],
        "inserts": [
          {
            "type": 4,
            "index": 2,
            "node": {
              "tagName": "li",
              "props": {
                "className": "ooo"
              },
              "children": [
                "插入節點"
              ]
            }
          }
        ]
      }
    }
  ],
  "2": [
    {
      "type": 1,
      "node": {
        "key": "1",
        "tagName": "li",
        "props": {
          "className": "teext"
        },
        "children": [
          "哈咯"
        ]
      }
    }
  ],
  "3": [
    {
      "type": 1,
      "node": {
        "key": "2",
        "tagName": "li",
        "props": {
          "className": "zz"
        },
        "children": [
          "大家好"
        ]
      }
    }
  ]
}

步驟五:更新 patchs,把差異應用到真正的DOM樹上

import patch from './vpatch.js'
import render from './render.js'

/**
 * 真實的 Dom 打補釘
 * @param {*} rootNode 實際的 DOM
 * @param {*} patches
 */
function patch (rootNode, patches) {
  const walker = { index: 0 }
  dftWalk(rootNode, walker, patches)
}

/**
 * 深入優先遍歷演算法 (depth-first traversal,DFT)
 * @param {*} node
 * @param {*} walker
 * @param {*} patches
 */
function dftWalk (node, walker, patches) {
  const currentPatches = patches[walker.index] || {}
  node.childNodes && node.childNodes.forEach(child => {
    walker.index++
    dftWalk(child, walker, patches)
  })
  if (currentPatches) {
    applyPatches(node, currentPatches)
  }
}


function applyPatches (node, currentPatches) {
  currentPatches.forEach(currentPatch => {
    switch (currentPatch.type) {
      case patch.REMOVE:
        node.parentNode.removeChild(node)
        break
      case patch.REPLACE:
        const newNode = currentPatch.node
        node.parentNode.replaceChild(render(newNode), node)
        break
      case patch.TEXT:
        node.textContent = currentPatch.text
        break
      case patch.PROPS:
        const props = currentPatch.props
        setProps(node, props)
        break
      case patch.INSERT:
        // parentNode.insertBefore(newNode, referenceNode)
        const newNode = currentPatch.node
        node.appendChild(render(newNode))
        break
      case patch.REORDER:
        reorderChildren(node, currentPatch.moves)
        break
    }
  })

}

/**
 * 設定真實 Dom 屬性值
 * @param {*} node
 * @param {*} props
 */
function setProps (node, props) {
  for (const key in props) {
    // void 666 is undefined
    if (props[key] === void 666) {
      node.removeAttribute(key)
    } else {
      const value = props[key]
      node.setAttribute(key, value)
    }
  }
}

/**
 * reorderChildren 處理 list diff render
 * @param {*} domNode
 * @param {*} moves
 */
function reorderChildren(domNode, moves) {
  for (const i = 0; i < moves.removes.length; i++) {
    const { index } = moves.removes[i]
    const node = domNode.childNodes[index]
    domNode.removeChild(node)
  }

  for (const j = 0; j < moves.inserts.length; j++) {
    const { index, node } = moves.inserts[j]
    domNode.insertBefore(node, index === domNode.childNodes.length ? null : childNodes[index])
  }
}

歡迎關注前端早茶,與廣東靚仔攜手共同進階

前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~