一文說清「VirtualDOM」的含義與實現

心譚發表於2019-11-17

本文來自《一文說清VirtualDOM的含義與實現》,如果覺得不錯,歡迎給Github倉庫一個star。

摘要

隨著 React 的興起,Virtual DOM 的原理和實現也開始出現在各大廠面試和社群的文章中。其實這種做法早在 d3.js 中就有實現,是 react 生態的快速建立讓它正式進入了廣大開發者的視角。

在正式開始前,丟擲幾個問題來引導思路,這些問題也會在不同的小節中,逐步解決:

  • ?️ 怎麼理解 VDom?
  • ?️ 如何表示 VDom?
  • ?️ 如何比較 VDom 樹,並且進行高效更新?

⚠️ 整理後的程式碼和效果圖均存放在github.com/dongyuanxin

如何理解 VDom?

曾經,前端常做的事情就是根據資料狀態的更新,來更新介面檢視。大家逐漸意識到,對於複雜檢視的介面,頻繁地更新 DOM,會造成迴流或者重繪,引發效能下降,頁面卡頓。

因此,我們需要方法避免頻繁地更新 DOM 樹。思路也很簡單,即:對比 DOM 的差距,只更新需要部分節點,而不是更新一棵樹。而實現這個演算法的基礎,就需要遍歷 DOM 樹的節點,來進行比較更新。

為了處理更快,不使用 DOM 物件,而是用 JS 物件來表示,它就像是 JS 和 DOM 之間的一層快取

如何表示 VDom?

藉助 ES6 的 class,表示 VDom 語義化更強。一個基礎的 VDom 需要有標籤名、標籤屬性以及子節點,如下所示:

class Element {
  constructor(tagName, props, children) {
    this.tagName = tagName;
    this.props = props;
    this.children = children;
  }
}
複製程式碼

為了更方便呼叫(不用每次都寫new),將其封裝返回例項的函式:

function el(tagName, props, children) {
  return new Element(tagName, props, children);
}
複製程式碼

此時,如果想表達下面的 DOM 結構:

<div class="test">
  <span>span1</span>
</div>
複製程式碼

用 VDom 就是:

// 子節點陣列的元素可以是文字,也可以是VDom例項
const span = el("span", {}, ["span1"]);
const div = el("div", { class: "test" }, [span]);
複製程式碼

之後在對比和更新兩棵 VDom 樹的時候,會涉及到將 VDom 渲染成真正的 Dom 節點。因此,為class Element增加render方法:

class Element {
  constructor(tagName, props, children) {
    this.tagName = tagName;
    this.props = props;
    this.children = children;
  }

  render() {
    const dom = document.createElement(this.tagName);
    // 設定標籤屬性值
    Reflect.ownKeys(this.props).forEach(name =>
      dom.setAttribute(name, this.props[name])
    );

    // 遞迴更新子節點
    this.children.forEach(child => {
      const childDom =
        child instanceof Element
          ? child.render()
          : document.createTextNode(child);
      dom.appendChild(childDom);
    });

    return dom;
  }
}
複製程式碼

如何比較 VDom 樹,並且進行高效更新?

前面已經說明了 VDom 的用法與含義,多個 VDom 就會組成一棵虛擬的 DOM 樹。剩下需要做的就是:根據不同的情況,來進行樹上節點的增刪改的操作。這個過程是分為diffpatch

  • diff:遞迴對比兩棵 VDom 樹的、對應位置的節點差異
  • patch:根據不同的差異,進行節點的更新

目前有兩種思路,一種是先 diff 一遍,記錄所有的差異,再統一進行 patch;另外一種是 diff 的同時,進行 patch。相較而言,第二種方法少了一次遞迴查詢,以及不需要構造過多的物件,下面採取的是第二種思路。

變數的含義

將 diff 和 patch 的過程,放入updateEl方法中,這個方法的定義如下:

/**
 *
 * @param {HTMLElement} $parent
 * @param {Element} newNode
 * @param {Element} oldNode
 * @param {Number} index
 */
function updateEl($parent, newNode, oldNode, index = 0) {
  // ...
}
複製程式碼

所有以$開頭的變數,代表著真實的 DOM

引數index表示oldNode$parent的所有子節點構成的陣列的下標位置。

情況 1:新增節點

如果 oldNode 為 undefined,說明 newNode 是一個新增的 DOM 節點。直接將其追加到 DOM 節點中即可:

function updateEl($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(newNode.render());
  }
}
複製程式碼

情況 2:刪除節點

如果 newNode 為 undefined,說明新的 VDom 樹中,當前位置沒有節點,因此需要將其從實際的 DOM 中刪除。刪除就呼叫$parent.removeChild(),通過index引數,可以拿到被刪除元素的引用:

function updateEl($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(newNode.render());
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  }
}
複製程式碼

情況 3:變化節點

對比 oldNode 和 newNode,有 3 種情況,均可視為改變:

  1. 節點型別發生變化:文字變成 vdom;vdom 變成文字
  2. 新舊節點都是文字,內容發生改變
  3. 節點的屬性值發生變化

首先,藉助Symbol更好地語義化宣告這三種變化:

const CHANGE_TYPE_TEXT = Symbol("text");
const CHANGE_TYPE_PROP = Symbol("props");
const CHANGE_TYPE_REPLACE = Symbol("replace");
複製程式碼

針對節點屬性發生改變,沒有現成的 api 供我們批量更新。因此封裝replaceAttribute,將新 vdom 的屬性直接對映到 dom 結構上:

function replaceAttribute($node, removedAttrs, newAttrs) {
  if (!$node) {
    return;
  }

  Reflect.ownKeys(removedAttrs).forEach(attr => $node.removeAttribute(attr));
  Reflect.ownKeys(newAttrs).forEach(attr =>
    $node.setAttribute(attr, newAttrs[attr])
  );
}
複製程式碼

編寫checkChangeType函式判斷變化的型別;如果沒有變化,則返回空:

function checkChangeType(newNode, oldNode) {
  if (
    typeof newNode !== typeof oldNode ||
    newNode.tagName !== oldNode.tagName
  ) {
    return CHANGE_TYPE_REPLACE;
  }

  if (typeof newNode === "string") {
    if (newNode !== oldNode) {
      return CHANGE_TYPE_TEXT;
    }
    return;
  }

  const propsChanged = Reflect.ownKeys(newNode.props).reduce(
    (prev, name) => prev || oldNode.props[name] !== newNode.props[name],
    false
  );

  if (propsChanged) {
    return CHANGE_TYPE_PROP;
  }
  return;
}
複製程式碼

updateEl中,根據checkChangeType返回的變化型別,做對應的處理。如果型別為空,則不進行處理。具體邏輯如下:

function updateEl($parent, newNode, oldNode, index = 0) {
  let changeType = null;

  if (!oldNode) {
    $parent.appendChild(newNode.render());
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  } else if ((changeType = checkChangeType(newNode, oldNode))) {
    if (changeType === CHANGE_TYPE_TEXT) {
      $parent.replaceChild(
        document.createTextNode(newNode),
        $parent.childNodes[index]
      );
    } else if (changeType === CHANGE_TYPE_REPLACE) {
      $parent.replaceChild(newNode.render(), $parent.childNodes[index]);
    } else if (changeType === CHANGE_TYPE_PROP) {
      replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props);
    }
  }
}
複製程式碼

情況 4:遞迴對子節點執行 Diff

如果情況 1、2、3 都沒有命中,那麼說明當前新舊節點自身沒有變化。此時,需要遍歷它們(Virtual Dom)的children陣列(Dom 子節點),遞迴進行處理。

程式碼實現非常簡單:

function updateEl($parent, newNode, oldNode, index = 0) {
  let changeType = null;

  if (!oldNode) {
    $parent.appendChild(newNode.render());
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  } else if ((changeType = checkChangeType(newNode, oldNode))) {
    if (changeType === CHANGE_TYPE_TEXT) {
      $parent.replaceChild(
        document.createTextNode(newNode),
        $parent.childNodes[index]
      );
    } else if (changeType === CHANGE_TYPE_REPLACE) {
      $parent.replaceChild(newNode.render(), $parent.childNodes[index]);
    } else if (changeType === CHANGE_TYPE_PROP) {
      replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props);
    }
  } else if (newNode.tagName) {
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;
    for (let i = 0; i < newLength || i < oldLength; ++i) {
      updateEl(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
        i
      );
    }
  }
}
複製程式碼

效果觀察

github.com/dongyuanxin…的程式碼 clone 到本地,Chrome 開啟index.html

新增 dom 節點.gif:

一文說清「VirtualDOM」的含義與實現

更新文字內容.gif:

一文說清「VirtualDOM」的含義與實現

更改節點屬性.gif:

一文說清「VirtualDOM」的含義與實現

⚠️ 網速較慢的同學請移步 github 倉庫

參考連結

相關文章