虛擬 DOM 究竟是什麼?

是什麼?

虛擬 DOM (Virtual DOM )這個概念相信你們都不陌生,從 React 到 Vue ,虛擬 DOM 爲這兩個框架都帶來了跨平臺的能力(React-Native 和 Weex)。由於不少人是在學習 React 的過程當中接觸到的虛擬 DOM ,因此爲先入爲主,認爲虛擬 DOM 和 JSX 密不可分。其實否則,虛擬 DOM 和 JSX 當然契合,但 JSX 只是虛擬 DOM 的充分沒必要要條件,Vue 即便使用模版,也能把虛擬 DOM 玩得風生水起,同時也有不少人經過 babel 在 Vue 中使用 JSX。javascript

不少人認爲虛擬 DOM 最大的優點是 diff 算法,減小 JavaScript 操做真實 DOM 的帶來的性能消耗。雖然這一個虛擬 DOM 帶來的一個優點,但並非所有。虛擬 DOM 最大的優點在於抽象了本來的渲染過程,實現了跨平臺的能力,而不只僅侷限於瀏覽器的 DOM,能夠是安卓和 IOS 的原生組件,能夠是近期很火熱的小程序,也能夠是各類GUI。html

回到最開始的問題,虛擬 DOM 究竟是什麼,說簡單點,就是一個普通的 JavaScript 對象,包含了 tagpropschildren 三個屬性。前端

<div id="app">
  <p class="text">hello world!!!</p>
</div>
複製代碼

上面的 HTML 轉換爲虛擬 DOM 以下:vue

{
  tag: 'div',
  props: {
    id: 'app'
  },
  chidren: [
    {
      tag: 'p',
      props: {
        className: 'text'
      },
      chidren: [
        'hello world!!!'
      ]
    }
  ]
}
複製代碼

該對象就是咱們常說的虛擬 DOM 了,由於 DOM 是樹形結構,因此使用 JavaScript 對象就能很簡單的表示。而原生 DOM 由於瀏覽器廠商須要實現衆多的規範(各類 HTML5 屬性、DOM事件),即便建立一個空的 div 也要付出昂貴的代價。虛擬 DOM 提高性能的點在於 DOM 發生變化的時候,經過 diff 算法比對 JavaScript 原生對象,計算出須要變動的 DOM,而後只對變化的 DOM 進行操做,而不是更新整個視圖。java

那麼咱們到底該如何將一段 HTML 轉換爲虛擬 DOM 呢?node

從 h 函數提及

觀察主流的虛擬 DOM 庫(snabbdomvirtual-dom),一般都有一個 h 函數,也就是 React 中的 React.createElement,以及 Vue 中的 render 方法中的 createElement,另外 React 是經過 babel 將 jsx 轉換爲 h 函數渲染的形式,而 Vue 是使用 vue-loader 將模版轉爲 h 函數渲染的形式(也能夠經過 babel-plugin-transform-vue-jsx 插件在 vue 中使用 jsx,本質仍是轉換爲 h 函數渲染形式)。react

咱們先使用 babel,將一段 jsx 代碼,轉換爲一段 js 代碼:git

安裝 babel 依賴

npm i -D @babel/cli @babel/core @babel/plugin-transform-react-jsx
複製代碼

配置 .babelrc

{
    "plugins": [
        [
            "@babel/plugin-transform-react-jsx",
            {
                "pragma": "h", // default pragma is React.createElement
            }
        ]
    ]
}
複製代碼

轉譯 jsx

在目錄下新建一個 main.jsxgithub

function getVDOM() {
  return (
    <div id="app"> <p className="text">hello world!!!</p> </div>
  )
}
複製代碼

使用以下命令進行轉譯:算法

npx babel main.jsx --out-file main-compiled.js
複製代碼

jsx 轉譯

能夠看到,最終 HTML 代碼會被轉譯成 h 函數的渲染形式。h 函數接受是三個參數,分別表明是 DOM 元素的標籤名、屬性、子節點,最終返回一個虛擬 DOM 的對象。

function h(tag, props, ...children) {
  return {
    tag,
    props: props || {},
    children: children.flat()
  }
}
複製代碼

渲染虛擬 DOM

雖然虛擬 DOM 能夠渲染到多個平臺,可是這裏講一下在瀏覽器環境下如何渲染虛擬 DOM。

function render(vdom) {
  // 若是是字符串或者數字,建立一個文本節點
  if (typeof vdom === 'string' || typeof vdom === 'number') {
    return document.createTextNode(vdom)
  }
  const { tag, props, children } = vdom
  // 建立真實DOM
  const element = document.createElement(tag)
  // 設置屬性
  setProps(element, props)
  // 遍歷子節點,並獲取建立真實DOM,插入到當前節點
  children
    .map(render)
    .forEach(element.appendChild.bind(element))

  // 虛擬 DOM 中緩存真實 DOM 節點
  vdom.dom = element
  
  // 返回 DOM 節點
  return element
}

function setProps (element, props) {
  Object.entries(props).forEach(([key, value]) => {
    setProp(element, key, value)
  })
}

function setProp (element, key, vlaue) {
  element.setAttribute(
    // className使用class代替
    key === 'className' ? 'class' : key,
    vlaue
  )
}
複製代碼

將虛擬 DOM 渲染成真實 DOM 後,只須要插入到對應的根節點便可。

const vdom = <div>hello world!!!</div> // h('div', {}, 'hello world!!!')
const app = document.getElementById('app')
const ele = render(vdom)
app.appendChild(ele)
複製代碼

固然在現代化的框架中,通常會有一個組件文件專門用來構造虛擬 DOM,咱們模仿 React 使用 class 的方式編寫組件,而後渲染到頁面中。

class Component {
  vdom = null // 組件的虛擬DOM表示
  $el  = null // 虛擬DOM生成的真實節點

  state = {
    text: 'Initialize the Component'
  }
  
  render() {
    const { text } = this.state
    return (
      <div>{ text }</div>
    )
  }
}

function createElement (app, component) {
  const vdom = component.render()
  component.vdom = vdom
  component.$el = render(vdom) // 將虛擬 DOM 轉換爲真實 DOM
  app.appendChild(component.$el)
}

const app = document.getElementById('app')
const component = new Component
createElement(app, component)
複製代碼

diff 算法

diff 算法,顧名思義,就是比對新老 VDOM 的變化,而後將變化的部分更新到視圖上。對應到代碼上,就是一個 diff 函數,返回一個 patches (補丁)。

const before  = h('div', {}, 'before text')
const after   = h('div', {}, 'after text')

const patches = diff(before, after)
複製代碼

修改咱們以前的組件,增長 setState 方法,用於修改組件的內部狀態。

class Component {
  vdom = null // 組件的虛擬DOM表示
  $el = null // 虛擬DOM生成的真實節點
  
  state = {
    text: 'Initialize the Component'
  }
  
  // 手動修改組件state
  setState(newState) {
    this.state = {
      ...this.state,
      ...newState
    }
    const newVdom = this.render()
    const patches = diff(this.vdom, newVdom)
    patch(this.$el, patches)
  }

  changeText(text) {
    this.setState({
      text
    })
  }
  
  render() {
    const { text } = this.state
    return (
      <div>{ text }</div>
    )
  }
}
複製代碼

當咱們調用 setState 時,state 內部狀態發生變更,再次調用 render 方法就會生成一個新的虛擬 DOM 樹,這樣咱們就能使用 diff 方法計算出新老虛擬 DOM 發送變化的部分,最後使用 patch 方法,將變更渲染到視圖中。

const app = document.getElementById('app')
const component = new Component
createElement(app, component)

// 將文本更改成數字,每秒 +1
let count = 0
setInterval(() => {
  component.changeText(++count)
}, 1000);
複製代碼

change text

diff 算法的進化

關於 diff 算法的最經典的就是 Matt Esch 的 virtual-dom,以及 snabbdom(被整合進 vue 2.0中)。

Virtual DOM 的歷史

最開始出現的是 virtual-dom 這個庫,是你們好奇 React 爲何這麼快而搞鼓出來的。它的實現是很是學院風格,經過深度優先搜索與 in-order tree 來實現高效的 diff 。它與 React 後來公開出來的算法是很不同。

而後是 cito.js 的橫空出世,它對從此全部虛擬 DOM 的算法都有重大影響。它採用兩端同時進行比較的算法,將 diff 速度拉高到幾個層次。 緊隨其後的是 kivi.js,在 cito.js 的基出提出兩項優化方案,使用 key 實現移動追蹤以及及基於 key 的最長自增子序列算法應用(算法複雜度 爲O(n^2))。 但這樣的 diff 算法太過複雜了,因而後來者 snabbdom 將 kivi.js 進行簡化,去掉編輯長度矩離算法,調整兩端比較算法。速度略有損失,但可讀性大大提升。再以後,就是著名的vue2.0 把sanbbdom整個庫整合掉了。

引用自司徒正美的文章 去哪兒網迷你React的研發心得

下面咱們就來說講這幾個虛擬 DOM 庫 diff 算法的具體實現:

1️⃣ virtual-dom

virtual-dom 做爲虛擬 DOM 開天闢地的做品,採用了對 DOM 樹進行了深度優先的遍歷的方法。

DOM 樹的遍歷

image

體現到代碼上:

function diff (oldNode, newNode) {
  const patches = []
  walk(oldNode, newNode, patches, 0) // 進行深度優先遍歷
  return patches
}

function walk(oldNode, newNode, patches, index) {
  if (newNode === oldNode) {
    return
  }
  
  const patch = { type: 'update', vNode: newNode }
  
  const oldChildren = oldNode.children
  const newChildren = newNode.children
  const oldLen = oldChildren.length
  const newLen = newChildren.length
  const len = oldLen > newLen ? oldLen : newLen
  // 找到對應位置的子節點進行比對
  for (let i = 0; i < len; i++) {
    const oldChild = oldChildren[i]
    const newChild = newChildren[i]
    index++
    // 相同節點進行比對
    walk(oldChild, newChild, patches, index)
    if (isArray(oldChild.children)) {
      index += oldChild.children.length
    }
  }
  
  if (patch) {
    patches[index] = patch
  }
}
複製代碼

VDOM 節點的對比

上面代碼只是對 VDOM 進行了簡單的深度優先遍歷,在遍歷中,還須要對每一個 VDOM 進行一些對比,具體分爲如下幾種狀況:

  1. 舊節點不存在,插入新節點;新節點不存在,刪除舊節點
  2. 新舊節點若是都是 VNode,且新舊節點 tag 相同
    • 對比新舊節點的屬性
    • 對比新舊節點的子節點差別,經過 key 值進行重排序,key 值相同節點繼續向下遍歷
  3. 新舊節點若是都是 VText,判斷二者文本是否發生變化
  4. 其餘狀況直接用新節點替代舊節點
import { isVNode, isVText, isArray } from '../utils/type'

function walk(oldNode, newNode, patches, index) {
  if (newNode === oldNode) {
    return
  }

  let patch = patches[index]

  if (!oldNode) {
    // 舊節點不存在,直接插入
    patch = appendPatch(patch, {
      type: PATCH.INSERT,
      vNode: newNode,
    })
  } else if (!newNode) {
    // 新節點不存在,刪除舊節點
    patch = appendPatch(patch, {
      type: PATCH.REMOVE,
      vNode: null,
    })
  } else if (isVNode(newNode)) {
    if (isVNode(oldNode)) {
      // 相同類型節點的 diff
      if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
        // 新老節點屬性的對比
        const propsPatch = diffProps(newNode.props, oldNode.props)
        if (propsPatch && propsPatch.length > 0) {
          patch = appendPatch(patch, {
            type: PATCH.PROPS,
            patches: propsPatch,
          })
        }
        // 新老節點子節點的對比
        patch = diffChildren(oldNode, newNode, patches, patch, index)
      }
    } else {
      // 新節點替換舊節點
      patch = appendPatch(patch, {
        type: PATCH.REPLACE,
        vNode: newNode,
      })
    }
  } else if (isVText(newNode)) {
    if (!isVText(oldNode)) {
      // 將舊節點替換成文本節點
      patch = appendPatch(patch, {
        type: PATCH.VTEXT,
        vNode: newNode,
      })
    } else if (newNode.text !== oldNode.text) {
      // 替換文本
      patch = appendPatch(patch, {
        type: PATCH.VTEXT,
        vNode: newNode,
      })
    }
  }

  if (patch) {
    // 將補丁放入對應位置
    patches[index] = patch
  }
}

// 一個節點可能有多個 patch
// 多個patch時,使用數組進行存儲
function appendPatch(patch, apply) {
  if (patch) {
    if (isArray(patch)) {
      patch.push(apply)
    } else {
      patch = [patch, apply]
    }

    return patch
  } else {
    return apply
  }
}
複製代碼

屬性的對比

function diffProps(newProps, oldProps) {
  const patches = []
  const props = Object.assign({}, newProps, oldProps)

  Object.keys(props).forEach(key => {
    const newVal = newProps[key]
    const oldVal = oldProps[key]
    if (!newVal) {
      patches.push({
        type: PATCH.REMOVE_PROP,
        key,
        value: oldVal,
      })
    }

    if (oldVal === undefined || newVal !== oldVal) {
      patches.push({
        type: PATCH.SET_PROP,
        key,
        value: newVal,
      })
    }
  })

  return patches
}
複製代碼

子節點的對比

這一部分能夠說是 diff 算法中,變更最多的部分,由於前面的部分,各個庫對比的方向基本一致,而關於子節點的對比,各個倉庫都在前者基礎上不斷得進行改進。

首先須要明白,爲何須要改進子節點的對比方式。若是咱們直接按照深度優先遍歷的方式,一個個去對比子節點,子節點的順序發生改變,那麼就會致使 diff 算法認爲全部子節點都須要進行 replace,從新將全部子節點的虛擬 DOM 轉換成真實 DOM,這種操做是十分消耗性能的。

image

可是,若是咱們可以找到新舊虛擬 DOM 對應的位置,而後進行移動,那麼就可以儘可能減小 DOM 的操做。

image

virtual-dom 在一開始就進行了這方面的嘗試,對子節點添加 key 值,經過 key 值的對比,來判斷子節點是否進行了移動。經過 key 值對比子節點是否移動的模式,被各個庫沿用,這也就是爲何主流的視圖庫中,子節點若是缺失 key 值,會有 warning 的緣由。

react warning

具體是怎麼對比的,咱們先看代碼:

function diffChildren(oldNode, newNode, patches, patch, index) {
  const oldChildren = oldNode.children
  // 新節點按舊節點的順序從新排序
  const sortedSet = sortChildren(oldChildren, newNode.children)
  const newChildren = sortedSet.children
  const oldLen = oldChildren.length
  const newLen = newChildren.length
  const len = oldLen > newLen ? oldLen : newLen
  for (let i = 0; i < len; i++) {
    var leftNode = oldChildren[i]
    var rightNode = newChildren[i]
    index++

    if (!leftNode) {
      if (rightNode) {
        // 舊節點不存在,新節點存在,進行插入操做
        patch = appendPatch(patch, {
          type: PATCH.INSERT,
          vNode: rightNode,
        })
      }
    } else {
      // 相同節點進行比對
      walk(leftNode, rightNode, patches, index)
    }
    if (isVNode(leftNode) && isArray(leftNode.children)) {
      index += leftNode.children.length
    }
  }

  if (sortedSet.moves) {
    // 最後進行從新排序
    patch = appendPatch(patch, {
      type: PATCH.ORDER,
      moves: sortedSet.moves,
    })
  }

  return patch
}
複製代碼

這裏首先須要對新的子節點進行重排序,先進行相同節點的 diff ,最後把子節點按照新的子節點順序從新排列。

children diff

這裏有個較複雜的部分,就是對子節點的從新排序。

function sortChildren(oldChildren, newChildren) {
  // 找出變化後的子節點中帶 key 的 vdom (keys),和不帶 key 的 vdom (free)
  const newChildIndex = keyIndex(newChildren)
  const newKeys = newChildIndex.keys
  const newFree = newChildIndex.free

  // 全部子節點無 key 不進行對比
  if (newFree.length === newChildren.length) {
    return {
      children: newChildren,
      moves: null,
    }
  }

  // 找出變化前的子節點中帶 key 的 vdom (keys),和不帶 key 的 vdom (free)
  const oldChildIndex = keyIndex(oldChildren)
  const oldKeys = oldChildIndex.keys
  const oldFree = oldChildIndex.free

  // 全部子節點無 key 不進行對比
  if (oldFree.length === oldChildren.length) {
    return {
      children: newChildren,
      moves: null,
    }
  }

  // O(MAX(N, M)) memory
  const shuffle = []

  const freeCount = newFree.length
  let freeIndex = 0
  let deletedItems = 0

  // 遍歷變化前的子節點,對比變化後子節點的 key 值
  // 並按照對應順序將變化後子節點的索引放入 shuffle 數組中
  for (let i = 0; i < oldChildren.length; i++) {
    const oldItem = oldChildren[i]
    let itemIndex

    if (oldItem.key) {
      if (newKeys.hasOwnProperty(oldItem.key)) {
        // 匹配到變化前節點中存在的 key
        itemIndex = newKeys[oldItem.key]
        shuffle.push(newChildren[itemIndex])
      } else {
        // 移除變化後節點不存在的 key 值
        deletedItems++
        shuffle.push(null)
      }
    } else {
      if (freeIndex < freeCount) {
        // 匹配變化先後的無 key 子節點
        itemIndex = newFree[freeIndex++]
        shuffle.push(newChildren[itemIndex])
      } else {
        // 若是變化後子節點中已經不存在無 key 項
        // 變化前的無 key 項也是多餘項,故刪除
        deletedItems++
        shuffle.push(null)
      }
    }
  }

  const lastFreeIndex =
    freeIndex >= newFree.length ? newChildren.length : newFree[freeIndex]

  // 遍歷變化後的子節點,將全部以前不存在的 key 對應的子節點放入 shuffle 數組中
  for (let j = 0; j < newChildren.length; j++) {
    const newItem = newChildren[j]
    if (newItem.key) {
      if (!oldKeys.hasOwnProperty(newItem.key)) {
        // 添加全部新的 key 值對應的子節點
        // 以後還會從新排序,咱們會在適當的地方插入新增節點
        shuffle.push(newItem)
      }
    } else if (j >= lastFreeIndex) {
      // 添加剩餘的無 key 子節點
      shuffle.push(newItem)
    }
  }

  const simulate = shuffle.slice()
  const removes = []
  const inserts = []
  let simulateIndex = 0
  let simulateItem
  let wantedItem

  for (let k = 0; k < newChildren.length; ) {
    wantedItem = newChildren[k] // 期待元素: 表示變化後 k 的子節點
    simulateItem = simulate[simulateIndex] // 模擬元素: 表示變化前 k 位置的子節點

    // 刪除在變化後不存在的子節點
    while (simulateItem === null && simulate.length) {
      removes.push(remove(simulate, simulateIndex, null))
      simulateItem = simulate[simulateIndex]
    }

    if (!simulateItem || simulateItem.key !== wantedItem.key) {
      // 期待元素的 key 值存在
      if (wantedItem.key) {
        if (simulateItem && simulateItem.key) {
          // 若是一個帶 key 的子元素沒有在合適的位置,則進行移動
          if (newKeys[simulateItem.key] !== k + 1) {
            removes.push(remove(simulate, simulateIndex, simulateItem.key))
            simulateItem = simulate[simulateIndex]
            // if the remove didn't put the wanted item in place, we need to insert it
            if (!simulateItem || simulateItem.key !== wantedItem.key) {
              inserts.push({ key: wantedItem.key, to: k })
            }
            // items are matching, so skip ahead
            else {
              simulateIndex++
            }
          } else {
            inserts.push({ key: wantedItem.key, to: k })
          }
        } else {
          inserts.push({ key: wantedItem.key, to: k })
        }
        k++
      }
      // 該位置期待元素的 key 值不存在,且模擬元素存在 key 值
      else if (simulateItem && simulateItem.key) {
        // 變化前該位置的元素
        removes.push(remove(simulate, simulateIndex, simulateItem.key))
      }
    } else {
      // 若是期待元素和模擬元素 key 值相等,跳到下一個子節點比對
      simulateIndex++
      k++
    }
  }

  // 移除全部的模擬元素
  while (simulateIndex < simulate.length) {
    simulateItem = simulate[simulateIndex]
    removes.push(
      remove(simulate, simulateIndex, simulateItem && simulateItem.key)
    )
  }

  // 若是隻有刪除選項中有值
  // 將操做直接交個 delete patch
  if (removes.length === deletedItems && !inserts.length) {
    return {
      children: shuffle,
      moves: null,
    }
  }

  return {
    children: shuffle,
    moves: {
      removes: removes,
      inserts: inserts,
    },
  }
}


function keyIndex(children) {
  const keys = {}
  const free = []
  const length = children.length

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

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

  return {
    keys: keys, // 子節點中全部存在的 key 對應的索引
    free: free, // 子節點中不存在 key 值的索引
  }
}

function remove(arr, index, key) {
  arr.splice(index, 1) // 移除數組中指定元素

  return {
    from: index,
    key: key,
  }
}
複製代碼

這一部分比較複雜,具體能夠查看 virtual-dom 的兩個 pr ,這兩個 pr 裏面討論了關於 diff 子節點從新排序的優化邏輯。

更新 DOM

在拿到了 VDOM 的 diff 結果後,須要將獲得的 patches 更新到視圖上。

function patch(rootNode, patches) {
  if (!patches || patches.length === 0) return
  // 取得對應 index 的真實 DOM
  const nodes = domIndex(rootNode)
  patches.forEach((patch, index) => {
    patch && applyPatch(nodes[index], patch)
  })
}

function domIndex(rootNode) {
  const nodes = [rootNode]
  const children = rootNode.childNodes
  if (children.length) {
    for (let child of children) {
      if (child.nodeType === 1 || child.nodeType === 3) {
        if (child.nodeType === 1) {
          nodes.push(...domIndex(child))
        } else if (child.nodeType === 3) {
          nodes.push(child)
        }
      }
    }
  }
  return nodes
}
複製代碼

遍歷patches,而後獲得每一個真實 DOM 和其對應的 patch,而後在真實 DOM 上進行更新:

function applyPatch(node, patchList) {
  for (let patch of patchList) {
    patchOp(node, patch)
  }
}
function patchOp(node, patch) {
  const { type, vNode } = patch
  const parentNode = node.parentNode
  let newNode = null
  switch (type) {
    case PATCH.INSERT:
      // 插入新節點
      break
    case PATCH.REMOVE:
      // 刪除舊新節點
      break
    case PATCH.REPLACE:
      // 替換節點
      break
    case PATCH.ORDER:
      // 子節點從新排序
      break
    case PATCH.VTEXT:
      // 替換文本節點
      break
    case PATCH.PROPS:
      // 更新節點屬性
      break
    default:
      break
  }
}
複製代碼

這裏每一步操做,不進行具體展開,感興趣的話能夠在個人 github 查看完整代碼

2️⃣ cito.js

cito 其餘步驟與 virtual-dom 相似,最大的差別點就在子節點的對比上,並且 cito 移除了 patch 更新,在 diff 的過程當中,直接更新真實 DOM ,這樣省去了 patch 的存儲,必定程度上節省了內存,後面其餘的 VDOM 庫基本使用這種方式。

咱們再來看看 cito 在子節點的對比上,到底有何優化?

其實前面咱們已經介紹過了,cito 主要變化就是引入了兩端對比,將 diff 算法的速度提高了幾個量級。

兩端對比

/** * 子節點對比 * @param {Element} domNode 父節點的真實DOM * @param {Array} oldChildren 舊的子節點 * @param {Array} children 新的子節點 */
function updateChildren(domNode, oldChildren, children) {
  const oldChildrenLength = oldChildren.length
  const childrenLength = children.length
  
  let oldEndIndex = oldChildrenLength - 1
  let endIndex = childrenLength - 1
  let oldStartIndex = 0
  let startIndex = 0
  let successful = true
  let nextChild
  
  // 兩端對比算法
  outer: while (
    successful &&
    oldStartIndex <= oldEndIndex &&
    startIndex <= endIndex
  ) {
    successful = false
    let oldStartChild = oldChildren[oldStartIndex]
    let startChild = children[startIndex]
    while (oldStartChild.key === startChild.key) {
      // 子節點對比
      updateNode(oldStartChild, startChild, domNode)
      oldStartIndex++
      startIndex++
      if (oldStartIndex > oldEndIndex || startIndex > endIndex) {
        break outer
      }
      oldStartChild = oldChildren[oldStartIndex]
      startChild = children[startIndex]
      successful = true
    }
    let oldEndChild = oldChildren[oldEndIndex]
    let endChild = children[endIndex]
    while (oldEndChild.key === endChild.key) {
      // 子節點對比
      updateNode(oldEndChild, endChild, domNode)
      oldEndIndex--
      endIndex--
      if (oldStartIndex > oldEndIndex || startIndex > endIndex) {
        break outer
      }
      oldEndChild = oldChildren[oldEndIndex]
      endChild = children[endIndex]
      successful = true
    }

    while (oldStartChild.key === endChild.key) {
      nextChild = endIndex + 1 < childrenLength ? children[endIndex + 1] : null
      // 子節點對比
      updateNode(oldStartChild, endChild, domNode)
      // 移動子節點
      moveChild(domNode, endChild, nextChild)
      oldStartIndex++
      endIndex--
      if (oldStartIndex > oldEndIndex || startIndex > endIndex) {
        break outer
      }
      oldStartChild = oldChildren[oldStartIndex]
      endChild = children[endIndex]
      successful = true
    }
    while (oldEndChild.key === startChild.key) {
      nextChild = oldStartIndex < oldChildrenLength ? oldChildren[oldStartIndex] : null
      // 子節點對比
      updateNode(oldEndChild, startChild, domNode)
      // 移動子節點
      moveChild(domNode, startChild, nextChild)
      oldEndIndex--
      startIndex++
      if (oldStartIndex > oldEndIndex || startIndex > endIndex) {
        break outer
      }
      oldEndChild = oldChildren[oldEndIndex]
      startChild = children[startIndex]
      successful = true
    }
  }
}
複製代碼

子節點對比:

function updateNode(oldNode, node, domParent) {
  if (node === oldNode) {
    return
  }

  const tag = node.tag

  if (oldNode.tag !== tag) {
    // 標籤不一致,建立新節點
    createNode(node, domParent, oldNode, true)
  } else {
    const oldChildren = oldNode.children
    const children = node.children
    const domNode = oldNode.dom
    node.dom = domNode // 真實 DOM 掛在到 虛擬 DOM 上
    // 子節點對比
    if (children !== oldChildren) {
      updateChildren(domNode, node, oldChildren, children)
    }

    const oldProps = oldNode.props
    const props = node.props
    // 屬性對比
    if (props !== oldProps) {
      updateAttributes(domNode, props, oldProps)
    }
  }
}
複製代碼

移動子節點:

function moveChild(domNode, child, nextChild) {
  const domRefChild = nextChild && nextChild.dom
  let domChild = child.dom
  if (domChild !== domRefChild) {
    if (domRefChild) {
      domNode.insertBefore(domChild, domRefChild)
    } else {
      domNode.appendChild(domChild)
    }
  }
}
複製代碼

3️⃣ kivi.js

kivi 的 diff 算法在 cito 的基礎上,引入了最長增加子序列,經過子序列找到最小的 DOM 操做數。

算法思想

翻譯自 kivi/lib/reconciler.ts

該算法用於找到最小的 DOM 操做數,能夠分爲如下幾步:

1. 找到數組中首部和尾部公共的節點,並在兩端移動

該方法經過比對兩端的 key 值,找到舊節點(A) 和新節點(B)中索引相同的節點。

A: -> [a b c d e f g] <-
  B:    [a b f d c g]
複製代碼

這裏咱們能夠跳過首部的 ab,以及尾部的 g

A: -> [c d e f] <-
  B:    [f d c]
複製代碼

此時,將嘗試對邊進行比較,若是在對邊有一個 key 值相同的節點,將執行簡單的移動操做,將 c 節點移動到 右邊緣,將 f 節點移動到左邊緣。

A: -> [d e] <-
  B:    [d]
複製代碼

如今將再次嘗試查找公共的首部與尾部,發現 d 節點是相同的,咱們跳過它。

A: -> [e] <-
  B:    [ ]
複製代碼

而後檢查各個列表的長度是否爲0,若是舊節點列表長度爲0,將插入新節點列表的剩餘節點,或者新節點列表長度爲0,將刪除全部舊節點列表中的元素。

這個簡單的算法適用於大多數的實際案例,好比僅僅反轉了列表。

當列表沒法利用該算法找到解的時候,會使用下一個算法,例如:

A: -> [a b c d e f g] <-
  B:    [a c b h f e g]
複製代碼

邊緣的 ag 節點相同,跳過他們。

A: -> [b c d e f] <-
  B:    [c b h f e]
複製代碼

而後上面的算法行不通了,咱們須要進入下一步。

2. 查找須要刪除或者插入的節點,而且某個節點是否須要移動

咱們先建立一個數組 P,長度爲新子節點列表的長度,併爲數組每一個元素賦值 -1 ,它表示新子節點應該插入的位置。稍後,咱們將把舊子節點中的節點位置分配給這個數組。

A: [b c d e f]
  B: [c b h f e]
  P: [. . . . .] // . == -1
複製代碼

而後,咱們構建一個對象 I,它的鍵表示新子節點的 key 值,值爲子節點在剩餘節點數組中的位置。

A: [b c d e f]
  B: [c b h f e]
  P: [. . . . .] // . == -1
  I: {
    c: 0,
    b: 1,
    h: 2,
    f: 3,
    e: 4,
  }
  last = 0
複製代碼

咱們開始遍歷舊子節點列表的剩餘節點,並檢查是否能夠在 I 對象的索引中找到具備相同 key 值的節點。若是找不到任何節點,則將它刪除,不然,咱們將節點在舊節點列表位置分配給數組 P

A: [b c d e f]
      ^
  B: [c b h f e]
  P: [. 0 . . .] // . == -1
  I: {
    c: 0,
    b: 1, <-
    h: 2,
    f: 3,
    e: 4,
  }
  last = 1
複製代碼

當咱們爲數組 P 分配節點位置時,咱們會保留上一個節點在新子節點列表中的位置,若是當一個節點的位置大於當前節點的位置,那麼咱們將 moved 變量置爲 true

A: [b c d e f]
        ^
  B: [c b h f e]
  P: [1 0 . . .] // . == -1
  I: {
    c: 0, <-
    b: 1,
    h: 2,
    f: 3,
    e: 4,
  }
  last = 1 // last > 0; moved = true
複製代碼

上一個節點 b位置爲 「1」,當前節點 c 的位置 「0」,因此將 moved 變量置爲 true

A: [b c d e f]
          ^
  B: [c b h f e]
  P: [1 0 . . .] // . == -1
  I: {
    c: 0,
    b: 1,
    h: 2,
    f: 3,
    e: 4,
  }
  moved = true
複製代碼

對象 I 索引中不存在 d,則刪除該節點

A: [b c d e f]
            ^
  B: [c b h f e]
  P: [1 0 . . 3] // . == -1
  I: {
    c: 0,
    b: 1,
    h: 2,
    f: 3,
    e: 4, <-
  }
  moved = true
複製代碼

爲節點 e 分配位置。

A: [b c d e f]
              ^
  B: [c b h f e]
  P: [1 0 . 4 3] // . == -1
  I: {
    c: 0,
    b: 1,
    h: 2,
    f: 3, <-
    e: 4,
  }
  moved = true
複製代碼

爲節點 f 分配位置。

此時,咱們檢查 moved 標誌是否被打開,或者舊子節點列表的長度減去已刪除節點的數量不等於新子節點列表的長度。若是其中任何一個條件爲真,咱們則進入下一步。

3. 若是 moved 爲真,查找最小移動數,若是長度發送變化,則插入新節點。

若是 moved 爲真,咱們須要在 P 數組中找到 最長自增子序列,並移動不屬於這個子序列的全部節點。

A: [b c d e f]
  B: [c b h f e]
  P: [1 0 . 4 3] // . == -1
  LIS:     [1 4]
  moved = true
複製代碼

如今咱們須要同時從尾端遍歷新的子節點列表以及最長自增子序列(後面簡稱 LIS),並檢查當前位置是否等於 LIS 的值。

A: [b c d e f]
  B: [c b h f e]
              ^  // new_pos == 4
  P: [1 0 . 4 3] // . == -1
  LIS:     [1 4]
              ^  // new_pos == 4
  moved = true
複製代碼

節點 e 保持當前位置

A: [b c d e f]
  B: [c b h f e]
            ^    // new_pos == 3
  P: [1 0 . 4 3] // . == -1
  LIS:     [1 4]
            ^    // new_pos != 1
  moved = true
複製代碼

移動節點 f,移動到下一個節點 e 前面它。

A: [b c d e f]
  B: [c b h f e]
          ^      // new_pos == 2
  P: [1 0 . 4 3] // . == -1
          ^      // old_pos == -1
  LIS:     [1 4]
            ^
  moved = true
複製代碼

節點 h 在數組 P 中爲 -1 ,則表示插入新節點 h

A: [b c d e f]
  B: [c b h f e]
        ^        // new_pos == 1
  P: [1 0 . 4 3] // . == -1
  LIS:     [1 4]
            ^    // new_pos == 1
  moved = true
複製代碼

節點 b 保持當前位置

A: [b c d e f]
  B: [c b h f e]
      ^          // new_pos == 0
  P: [1 0 . 4 3] // . == -1
  LIS:     [1 4]
          ^      // new_pos != undefined
  moved = true
複製代碼

移動節點 c ,移動到下一個節點 b 前面它。

若是 movedfalse 時,咱們不須要查找LIS,咱們只需遍歷新子節點列表,並檢查它在數組 P 中的位置,若是是 -1 ,則插入新節點。

關於 kivi

kivi 是做者對虛擬 DOM 性能提高的一些猜測,一開始它就向着性能出發,全部它在實現上代碼可能並不優雅,並且它的 api 也十分不友好。而接下來的 snabbdom 就在 kivi 的基礎上,大大提高了代碼的可讀性,不少講述虛擬 DOM 的文章也將 snabbdom 做爲案例。

另外,kivi 的做者也建立了另外一個 源碼以及 api 更友好的倉庫:ivi,感興趣能夠了解一下。

4️⃣ snabbdom

snabbdom 的優點就是代碼的可讀性大大提高,而且也引入了兩端對比,diff 速度也不慢。

咱們能夠簡單看下 snabbdom 的兩端對比算法的核心代碼:

/** * 子節點對比 * @param {Element} parentElm 父節點的真實DOM * @param {Array} oldCh 舊的子節點 * @param {Array} newCh 新的子節點 */
function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0
  let newStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let oldStartVnode = oldCh[0]
  let oldEndVnode = oldCh[oldEndIdx]
  let newEndIdx = newCh.length - 1
  let newStartVnode = newCh[0]
  let newEndVnode = newCh[newEndIdx]
  let oldKeyToIdx
  let idxInOld
  let elmToMove
  let before

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 跳過兩端不存在的舊節點
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx]
    }
    // 跳過兩端不存在的新節點
    else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx]
    }
    /* ** 進行兩端對比,分爲四種情況: ** 1. oldStart <=> newStart ** 2. oldEnd <=> newEnd ** 3. oldStart <=> newEnd ** 4. oldEnd <=> newStart */
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode)
      insertBefore(parentElm, oldStartVnode.dom, oldEndVnode.dom.nextSibling)
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      // Vnode moved left
      patchVnode(oldEndVnode, newStartVnode)
      insertBefore(parentElm, oldEndVnode.dom, oldStartVnode.dom)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } 
    // 上面四種狀況都不存在,經過 key 值查找對應 VDOM 進行對比
    else {
      // 構造舊子節點的 map 表 (key => vdom)
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      }
      idxInOld = oldKeyToIdx[newStartVnode.key]
      // 若是新的子節點在舊子節點不存在,進行插入操做
      if (idxInOld === undefined) {
        insertBefore(parentElm, render(newStartVnode), oldStartVnode.dom)
        newStartVnode = newCh[++newStartIdx]
      }
      // 若是新的子節點在舊子節點存在,進行對比
      else {
        elmToMove = oldCh[idxInOld]
        if (elmToMove.sel !== newStartVnode.sel) {
          // key 值相同,可是 tag 不一樣,從新生成節點並替換
          insertBefore(parentElm, render(newStartVnode), oldStartVnode.dom)
        } else {
          patchVnode(elmToMove, newStartVnode)
          oldCh[idxInOld] = undefined // 該位置已經對比,進行置空
          insertBefore(parentElm, elmToMove.dom, oldStartVnode.dom)
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
  }
  // 處理一些未處理到的節點
  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    if (oldStartIdx > oldEndIdx) {
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].dom
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
    } else {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }
}
複製代碼

關於 snabbdom ,網上有太多教程來分析它的 diff 過程了,無論是虛擬 DOM 的教程,仍是 Vue 的源碼分析,這裏就再也不詳細講述了。可是能夠明顯的看到,snabbdom 的 diff 算法是有 cito 和 kivi 的影子在的。

總結

毋庸置疑虛擬 DOM 帶給前端的意義是非凡的,虛擬 DOM 在現現在還有更多新鮮的玩法。 好比 omi 將虛擬 DOM 與 Web Component 的結合,還有 TaroChameleon 帶來的多端統一的能力。

另外,文中相關的代碼均可以在個人 github 查看,這篇文章更可能是對本身學習的一個記錄,若是有什麼錯誤的觀點,歡迎進行指正。

相關文章
相關標籤/搜索