如何實現 virtual-dom

如何實現 virtual-dom

0. 什麼是 vnode

相信大部分前端同窗以前早已無數次聽過或瞭解過 vnode(虛擬節點),那麼什麼是 vnode? vnode 應該是什麼樣的?
若是不使用前端框架,咱們可能會寫出這樣的頁面:javascript

<html>
  <head>
    <title></title>
  </head>
  <body>
    <div></div>
    <script></script>
  </body>
</html>

不難發現,整個文檔樹的根節點只有一個 html,而後嵌套各類子標籤,若是使用某種數據結構來表示這棵樹,那麼它多是這樣。html

{
  tagName: 'html',
  children: [
    {
      tagName: 'head',
      children: [
        {
          tagName: 'title'
        }
      ]
    },

    {
      tagName: 'body',
      children: [
        {
          tagName: 'div'
        },

        {
          tagName: 'script'
        }
      ]
    }
  ]
}

可是實際開發中,整個文檔樹中headscript 標籤基本不會有太大的改動。頻繁交互可能改動的應當是 body 裏面的除 script 的部分,因此構建 虛擬節點樹 應當是整個 HTML 文檔樹的一個子樹,而這個子樹應當保持和 HTML 文檔樹一致的數據結構。它多是這樣。前端

<html>
  <head>
    <title></title>
  </head>
  <body>
    <div id="root">
      <div class="header"></div>
      <div class="main"></div>
      <div class="footer"></div>
    </div>
    <script></script>
  </body>
</html>

這裏應當構建的 虛擬節點樹 應當是 div#root 這棵子樹:java

{
  tagName: 'div',
  children: [
    {
      tagName: 'div',
    },
    {
      tagName: 'div',
    },
    {
      tagName: 'div',
    },
  ]
}

到這裏,vnode 的概念應當很清晰了,vnode 是用來表示實際 dom 節點的一種數據結構,其結構大概長這樣。node

{
  tagName: 'div',
  attrs: {
    class: 'header'
  },
  children: []
}

通常,咱們可能會這樣定義 vnodereact

// vnode.js
export const vnode = function vnode() {}

1. 從 JSX 到 vnode

使用 React 會常常寫 JSX,那麼如何將 JSX 表示成 vnode?這裏能夠藉助 @babel/plugin-transform-react-jsx 這個插件來自定義轉換函數,
只須要在 .babelrc 中配置:git

{
  "plugins": [
    [
      "@babel/plugin-transform-react-jsx",
      {
        "pragma": "window.h"
      }
    ]
  ]
}

而後在 window 對象上掛載一個 h 函數:github

// h.js
const flattern = arr => [].concat.apply([], arr)

window.h = function h(tagName, attrs, ...children) {
  const node = new vnode()
  node.tagName = tagName
  node.attrs = attrs || {}
  node.children = flattern(children)

  return node
}

測試一下:算法

jsx-&gt;vnode

2. 渲染 vnode

如今咱們已經知道了如何構建 vnode,接下來就是將其渲染成真正的 dom 節點並掛載。前端框架

// 將 vnode 建立爲真正的 dom 節點
export function createElement(vnode) {
  if (typeof vnode !== 'object') {
    // 文本節點
    return document.createTextNode(vnode)
  }

  const el = document.createElement(vnode.tagName)
  setAttributes(el, vnode.attrs)
  vnode.children.map(createElement).forEach(el.appendChild.bind(el))
  return el
}

// render.js
export default function render(vnode, parent) {
  parent = typeof parent === 'string' ? document.querySelector(parent) : parent
  return parent.appendChild(createElement(vnode))
}

這裏的邏輯主要爲:

  1. 根據 vnode.tagName 建立元素
  2. 根據 vnode.attrs 設置元素的 attributes
  3. 遍歷 vnode.children 並將其建立爲真正的元素,而後將真實子元素節點 append 到第 1 步建立的元素

3. diff vnode

第 2 步已經實現了 vnodedom 節點的轉換與掛載,那麼接下來某一個時刻 dom 節點發生了變化,如何更新 dom樹?顯然不能無腦卸載整棵樹,而後掛載新的樹,最好的辦法仍是找出兩棵樹之間的差別,而後應用這些差別。

diff-2-vnode

在寫 diff 以前,首先要定義好,要 diff 什麼,明確 diff 的返回值。比較上圖兩個 vnode,能夠得出:

  1. 更換第 一、二、3li 的內容
  2. ul 下建立兩個 li,這兩個 li 爲 第 4 個第 5 個子節點

那麼可能得返回值爲:

{
  "type": "UPDATE",
  "children": [
    {
      "type": "UPDATE",
      "children": [
        {
          "type": "REPLACE",
          "newVNode": 0
        }
      ],
      "attrs": []
    },
    {
      "type": "UPDATE",
      "children": [
        {
          "type": "REPLACE",
          "newVNode": 1
        }
      ],
      "attrs": []
    },
    {
      "type": "UPDATE",
      "children": [
        {
          "type": "REPLACE",
          "newVNode": 2
        }
      ],
      "attrs": []
    },
    {
      "type": "CREATE",
      "newVNode": {
        "tagName": "li",
        "attrs": {},
        "children": [
          3
        ]
      }
    },
    {
      "type": "CREATE",
      "newVNode": {
        "tagName": "li",
        "attrs": {},
        "children": [
          4
        ]
      }
    }
  ],
  "attrs": []
}

diff 的過程當中,要保證節點的父節點正確,並要保證該節點在父節點 的子節點中的索引正確(保證節點內容正確,位置正確)。diff 的核心流程:

  • case CREATE: 舊節點不存在,則應當新建新節點
  • case REMOVE: 新節點不存在,則移出舊節點
  • case REPLACE: 只比較新舊節點,不比較其子元素,新舊節點標籤名或文本內容不一致,則應當替換舊節點
  • case UPDATE: 到這裏,新舊節點可能只剩下 attrs 和 子節點未進行 diff,因此直接循環 diffAttrs 和 diffChildren 便可
/**
 * diff 新舊節點差別
 * @param {*} oldVNode
 * @param {*} newVNode
 */
export default function diff(oldVNode, newVNode) {
  if (isNull(oldVNode)) {
    return { type: CREATE, newVNode }
  }

  if (isNull(newVNode)) {
    return { type: REMOVE }
  }

  if (isDiffrentVNode(oldVNode, newVNode)) {
    return { type: REPLACE, newVNode }
  }

  if (newVNode.tagName) {
    return {
      type: UPDATE,
      children: diffVNodeChildren(oldVNode, newVNode),
      attrs: diffVNodeAttrs(oldVNode, newVNode)
    }
  }
}

4. patch 應用更新

知道了兩棵樹以前的差別,接下來如何應用這些更新?在文章開頭部分咱們提到 dom 節點樹應當只有一個根節點,同時 diff 算法是保證了虛擬節點的位置和父節點是與 dom 樹保持一致的,那麼 patch 的入口也就很簡單了,從 虛擬節點的掛載點開始遞歸應用更新便可。

/**
 * 根據 diff 結果更新 dom 樹
 * 這裏爲何從 index = 0 開始?
 * 由於咱們是使用樹去表示整個 dom 樹的,傳入的 parent 即爲 dom 掛載點
 * 從根節點的第一個節點開始應用更新,這是與整個dom樹的結構保持一致的
 * @param {*} parent
 * @param {*} patches
 * @param {*} index
 */
export default function patch(parent, patches, index = 0) {
  if (!patches) {
    return
  }

  parent = typeof parent === 'string' ? document.querySelector(parent) : parent
  const el = parent.childNodes[index]

  /* eslint-disable indent */
  switch (patches.type) {
    case CREATE: {
      const { newVNode } = patches
      const newEl = createElement(newVNode)
      parent.appendChild(newEl)
      break
    }

    case REPLACE: {
      const { newVNode } = patches
      const newEl = createElement(newVNode)
      parent.replaceChild(newEl, el)
      break
    }

    case REMOVE: {
      parent.removeChild(el)
      break
    }

    case UPDATE: {
      const { attrs, children } = patches

      patchAttrs(el, attrs)

      for (let i = 0, len = children.length; i < len; i++) {
        patch(el, children[i], i)
      }

      break
    }
  }
}

總結

至此,vdom 的核心 diffpatch 都已基本實現。在測試 demo 中,不難發現 diff 其實已經很快了,可是 patch 速度會比較慢,因此這裏留下了一個待優化的點就是 patch

本文完整代碼均在這個倉庫

相關文章
相關標籤/搜索