理解虛擬 DOM 原理以及簡單實現

前言

會寫這篇的原因還是因為遭受毒打hhh,當時投了騰訊前端的實習生崗位,在一面時被問到了瞭不瞭解 vdom,結果只能說不太清楚,雖然後來一面是通過了,但還是覺得自己太菜了。後來問了一個前端大老同學,他表示這是很基本的東西啊。於是趕緊寫了這篇,希望能通過這篇的累積,把這部分的洞給補起來。話不多說,進入正文吧。

參考

本篇很多代碼是參可以下博客,做了修改以及個人理解。

參考鏈接
浅析虚拟dom原理并实现https://segmentfault.com/a/1190000016647776
vue核心之虚拟DOM(vdom)https://www.jianshu.com/p/af0b398602bc

正文

什麼是 VDOM

VDOM 其實是 Virtual DOM,也就是虛擬 DOM,如果連 DOM 是什麼都還不知道的,先去了解下再回來看這邊8。

虛擬 dom 其實是相對於瀏覽器渲染說的 dom 的,在 vue, react 等框架出現之前,我們如果要要改變頁面的佈局或是內容,那我們只能通過遍歷查詢 dom 樹的方式找到目標 dom 節點,然後才能修改其樣式或是結構等等,來達到更新視圖的目的。

但其實這是一種非常消耗資源的方式,因為每次幾乎都要遍歷整顆 dom 樹。試想,如果我們能建立一個與 dom 樹相對應的虛擬 dom 對象(js 對象),以對象嵌套的方式來表示 dom 樹,那麼每次修改 dom 樹的動作就可以變成修改 js 對象的屬性,這樣一來完全提升了修改 dom 的效率,也節省了許多寶貴的系統資源和計算能力。

為什麼操作 DOM 開銷這麼大?

這邊題外話一下,其實嚴格來說並不是查詢 dom 樹性能開銷大,而是因為 dom 樹的實現模塊和 js 模塊的分開的,而跨模塊的動作會帶來巨大的開銷,加上操作 dom 所引起的瀏覽器的重繪和回流,才是影響性能的主要原因。而且隨著移動端和 PC 端設備越來越多樣,我們就很難保證不同系統下的效率,因此理解 vdom 是很重要的。

DOM 解析過程

在真實進入 vdom 之前,我認為還是先來真正的搞清楚 dom 的解析過程。其實無論是哪種瀏覽器,渲染的機制都差不多,大致上就是經過 5 步驟。

  1. 創建 dom 樹
  2. 創建 cssom 樹
  3. 創建 render 樹
  4. 佈局(layout)
  5. 繪製(painting)
  • Step1:由 html 解析器,解析 html 文檔,構建出所謂的 dom 樹。

  • Step2:由 css 解析器,解析 css 文檔的和對應 dom 的樣式,構建出一個 cssom 樹。

  • Step3:將 dom 和 cssom 關聯合併,構建出一個 render 樹,這個過程又叫做 attachment。更深入點說,其實每一個 dom 節點都有一個 attach() 方法,接受樣式(style)信息,而返回的是一個 render 對象,又稱做 renderer。最終由所有的 renderer 構建出完整的 render 樹。

  • Step4:有了 render 樹之後,瀏覽器就會開始佈局,為每個 render 樹上的對象確定在頁面上的一個座標。

  • Step5:render 樹和座標都有了之後,就調用每個節點的 paint() 方法把它們實際繪製到頁面上呈現。

這邊額外提兩個點:

  • dom 樹的構建是在整個 html 文檔都加載完才開始嗎?

其實不是的。dom 的構建其實是一個漸進式的過程,為了達到更好的用戶體驗,渲染引擎會盡快將內容顯示在頁面上,而不會等到整個 html 都加載完才開始後續的 render、佈局等等。

  • render 樹是在 dom 跟 cssom 都構建好之後才開始的嗎?

其實這三個樹的構建也不是完全獨立的,而是會出現交叉的。會造成一遍加載一邊解析,一邊渲染的情況。

js 操作 DOM 的真實代價

如果今天我們就用 js 來操作真實的 dom,那假設在一次操作中要更新 10 個 dom 節點,那但是對於瀏覽器來說,當收到第一個 dom 請求是不會知道後面還有 9 次請求的,因此就會馬上做馬上執行,這樣重複 10 次。但是這樣就帶來問題,假設後面的請求修改了前端請求的結果,或是說覆蓋也行,那其實也就相當於有一些請求是直接浪費的,根本沒有執行的必要。這樣就會造成很多的資源浪費,可能造成頁面卡頓,掉幀等等影響用戶體驗的情況。

VDOM 的好處

vdom 就是為了解決瀏覽器性能問題所設計出來的,如上面的場景,若一次操作中要更新 10 個 dom 節點,vdom 不會立即執行操作 dom,而是會將這 10 次更新的 diff 保存到一個 js 對象中,最終才將這個 js 對象一次性 attach() 到真實的 dom 樹上,以次避免了大量無謂的計算。

所以,用 js 對象模擬 dom 節點的好處就是,所有的更新可以先反映在這個 js 對象(vdom)上,而操作 js 對象顯然比直接操作 dom 來得快,因為就在內存中嘛,等更新都完成後才將最終的 js 對象映射到真實的 dom 上,由瀏覽器來繪製。

總而言之,虛擬 dom 可以說是放在 js 和 html 中間的一個層。它可以通過新舊 dom 的對比,來獲取對比之後的差異對象,然後有針對性的把差異部分真正地渲染到頁面上,從而減少實際 dom 操作,最終達到性能優化的目的。

VDOM 原理與流程

簡單來說可以大致分為三步:

  1. 用 js 模擬 dom 樹,並渲染 dom 樹
  2. 比較新老 dom 以得到比較的差異對象
  3. 將該差異對象應用到渲染的 dom 樹

下面我們用代碼一步一步看怎麼實現的。

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

其實虛擬 dom 就是用 js 對象去模擬。我們用一個函數叫做 createEl(tag, props, children) 來實現。

  • tag:標籤名
  • props:屬性對象
  • children:子節點

以下為我們測試用,希望生成模擬出來的 html 模板:

<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>

我的思路是,構建一個類用於模擬 dom 樹,而這個類裡面添加一個 render() 方法用於將模擬好的 js 對象(vdom)渲染到瀏覽器上。代碼如下:

<!-- vdom.html -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="vdom.js"></script>
  </body>
</html>
// vdom.js

class CreateEl {
    constructor(tag, props, children) {
        if(Array.isArray(props)) {
            // 當只有兩個參數時,例如 createEl('li', ['#list1'])
            children = props
            props = {}
        }

        this.tag = tag
        this.props = props || {}
        this.children = children || []
    }

    // 構建 dom 樹
    render() {
        const el = document.createElement(this.tag)
        const props = this.props
        for(let [key, val] of Object.entries(props)) {
            el.setAttribute(key, val)
        }
        this.children.forEach(child => {
            let childEl = (child instanceof CreateEl) ? child.render() : document.createTextNode(child)
            el.appendChild(childEl)
        })

        return el
    }
}

const createEl = (tag, props, children) => {
    // 具體構建 vdom 類
    return new CreateEl(tag, 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'])
])

console.log('vdom', vdom)

const root = vdom.render() // 渲染
document.body.appendChild(root)

先來展示下渲染在頁面上的效果:

也可以看看代碼中打印出 vdom 的結構:

最後檢查控制台中的 Elements 也是沒問題的,符合預期:

diff 算法比較新老 DOM 得到差異對象

經過上一步,我們已經創建好了一個 dom 樹,現在要創建一個不一樣的 dom 樹,並且比較後得到差異對象。

比較兩棵 dom 樹的差異是 vdom 最核心的部分,也就是人們常常在說的 dom 的 diff 算法。兩棵樹的比較差異其實是 O(n^3)(這我不太理解,暫時先接手吧),但是在 web 中很少用到跨層級的 dom 樹比較,所以只要一層級跟一層級比,這樣複雜度就可以降到 O(n)

其實在真正的代碼中,我們會從根節點開始做標誌遍歷,遍歷的時候就會把每個節點的差異,包括節點不同、文本不同、屬性不同都會記錄下來。

而兩個節點的差異總結下來,大致可以分為下面四種:

  • 節點類型不同
  • 移動、刪除節點
  • 節點屬性不同
  • 節點文本不同

如下就是兩棵樹的 diff 比較:

以下為具體代碼實現:

// 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 樹

最後一步其實就是要把差異對象反映到渲染的 DOM 樹,使用的方法跟上面也很像,一樣就是深度遍歷,如果節點有差異的話,判斷是哪一種差異,根據差異對象就直接修改節點就好了。

具體代碼如下:

// patch.js

import { REPLACE, REORDER, PROPS, TEXT } from "./diff"

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 {
            setAttribute(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)
    }
  });
}

結語

本篇介紹 vdom 大概就到這邊,應該還算完整,且也附上了簡單的代碼實現。其實現今主流框架像是 vue, react 都採用這樣的 vdom,所以即使無法自己手動實現,我覺得還是需要稍微了解下 vdom 的概念,以及解決的問題和優勢等等。希望看完本篇對你有些幫助,若有誤也歡迎各位大佬指出,畢竟前端方面我也還是挺菜的。

  • 5
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 8
    评论
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值