通過編寫簡易虛擬DOM,來學習虛擬DOM 的知識!

前端小智發表於2021-12-10
作者:deathmood
譯者:前端小智
來源:medium
有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。
本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

最近開源了一個 Vue 元件,還不夠完善,歡迎大家來一起完善它,也希望大家能給個 star 支援一下,謝謝各位了。

github 地址:https://github.com/qq44924588...

要構建自己的虛擬DOM,需要知道兩件事。你甚至不需要深入 React 的原始碼或者深入任何其他虛擬DOM實現的原始碼,因為它們是如此龐大和複雜——但實際上,虛擬DOM的主要部分只需不到50行程式碼。

有兩個概念:

  • Virtual DOM 是真實DOM的對映
  • 當虛擬 DOM 樹中的某些節點改變時,會得到一個新的虛擬樹。演算法對這兩棵樹(新樹和舊樹)進行比較,找出差異,然後只需要在真實的 DOM 上做出相應的改變。

用JS物件模擬DOM樹

首先,我們需要以某種方式將 DOM 樹儲存在記憶體中。可以使用普通的 JS 物件來做。假設我們有這樣一棵樹:

<ul class=”list”>
  <li>item 1</li>
  <li>item 2</li>
</ul>

看起來很簡單,對吧? 如何用JS物件來表示呢?

{ type: ‘ul’, props: { ‘class’: ‘list’ }, children: [
  { type: ‘li’, props: {}, children: [‘item 1’] },
  { type: ‘li’, props: {}, children: [‘item 2’] }
] }

這裡有兩件事需要注意:

  • 用如下物件表示DOM元素
{ type: ‘…’, props: { … }, children: [ … ] }

  • 用普通 JS 字串表示 DOM 文字節點

但是用這種方式表示內容很多的 Dom 樹是相當困難的。這裡來寫一個輔助函式,這樣更容易理解:

function h(type, props, …children) {
  return { type, props, children };
}

用這個方法重新整理一開始程式碼:

h(‘ul’, { ‘class’: ‘list’ },
  h(‘li’, {}, ‘item 1’),
  h(‘li’, {}, ‘item 2’),
);

這樣看起來簡潔多了,還可以更進一步。這裡使用 JSX,如下:

<ul className=”list”>
  <li>item 1</li>
  <li>item 2</li>
</ul>

編譯成:

React.createElement(‘ul’, { className: ‘list’ },
  React.createElement(‘li’, {}, ‘item 1’),
  React.createElement(‘li’, {}, ‘item 2’),
);

是不是看起來有點熟悉?如果能夠用我們剛定義的 h(...) 函式代替 React.createElement(…),那麼我們也能使用JSX 語法。其實,只需要在原始檔頭部加上這麼一句註釋:

/** @jsx h */
<ul className=”list”>
  <li>item 1</li>
  <li>item 2</li>
</ul>

它實際上告訴 Babel ' 嘿,小老弟幫我編譯 JSX 語法,用 h(...) 函式代替 React.createElement(…),然後 Babel 就開始編譯。'

綜上所述,我們將DOM寫成這樣:

/** @jsx h */
const a = (
  <ul className=”list”>
    <li>item 1</li>
    <li>item 2</li>
  </ul>
);

Babel 會幫我們編譯成這樣的程式碼:

const a = (
  h(‘ul’, { className: ‘list’ },
    h(‘li’, {}, ‘item 1’),
    h(‘li’, {}, ‘item 2’),
  );
);

當函式 “h” 執行時,它將返回普通JS物件-即我們的虛擬DOM:

const a = (
  { type: ‘ul’, props: { className: ‘list’ }, children: [
    { type: ‘li’, props: {}, children: [‘item 1’] },
    { type: ‘li’, props: {}, children: [‘item 2’] }
  ] }
);

從Virtual DOM 對映到真實 DOM

好了,現在我們有了 DOM 樹,用普通的 JS 物件表示,還有我們自己的結構。這很酷,但我們需要從它建立一個真正的DOM。

首先讓我們做一些假設並宣告一些術語:

  • 使用以' $ '開頭的變數表示真正的DOM節點(元素,文字節點),因此 $parent 將會是一個真實的DOM元素
  • 虛擬 DOM 使用名為 node 的變數表示

* 就像在 React 中一樣,只能有一個根節點——所有其他節點都在其中

那麼,來編寫一個函式 createElement(…),它將獲取一個虛擬 DOM 節點並返回一個真實的 DOM 節點。這裡先不考慮 propschildren 屬性:

function createElement(node) {
  if (typeof node === ‘string’) {
    return document.createTextNode(node);
  }
  return document.createElement(node.type);
}

上述方法我也可以建立有兩種節點分別是文字節點和 Dom 元素節點,它們是型別為的 JS 物件:

{ type: ‘…’, props: { … }, children: [ … ] }

因此,可以在函式 createElement 傳入虛擬文字節點和虛擬元素節點——這是可行的。

現在讓我們考慮子節點——它們中的每一個都是文字節點或元素。所以它們也可以用 createElement(…) 函式建立。是的,這就像遞迴一樣,所以我們可以為每個元素的子元素呼叫 createElement(…),然後使用 appendChild() 新增到我們的元素中:

function createElement(node) {
  if (typeof node === ‘string’) {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}

哇,看起來不錯。先把節點 props 屬性放到一邊。待會再談。我們不需要它們來理解虛擬DOM的基本概念,因為它們會增加複雜性。

完整程式碼如下:

/** @jsx h */

function h(type, props, ...children) {
  return { type, props, children };
}

function createElement(node) {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}

const a = (
  <ul class="list">
    <li>item 1</li>
    <li>item 2</li>
  </ul>
);

const $root = document.getElementById('root');
$root.appendChild(createElement(a));

比較兩棵虛擬DOM樹的差異

現在我們可以將虛擬 DOM 轉換為真實的 DOM,這就需要考慮比較兩棵 DOM 樹的差異。基本的,我們需要一個演算法來比較新的樹和舊的樹,它能夠讓我們知道什麼地方改變了,然後相應的去改變真實的 DOM。

怎麼比較 DOM 樹?需要處理下面的情況:

  • 新增新節點,使用 appendChild(…) 方法新增節點

圖片描述

  • 移除老節點,使用 removeChild(…) 方法移除老的節點

圖片描述

  • 節點的替換,使用 replaceChild(…) 方法

圖片描述

如果節點相同的——就需要需要深度比較子節點

圖片描述

編寫一個名為 updateElement(…) 的函式,它接受三個引數—— $parentnewNodeoldNode,其中 $parent 是虛擬節點的一個實際 DOM 元素的父元素。現在來看看如何處理上面描述的所有情況。

新增新節點

function updateElement($parent, newNode, oldNode) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  }
}

移除老節點

這裡遇到了一個問題——如果在新虛擬樹的當前位置沒有節點——我們應該從實際的 DOM 中刪除它—— 這要如何做呢?

如果我們已知父元素(通過引數傳遞),我們就能呼叫 $parent.removeChild(…) 方法把變化對映到真實的 DOM 上。但前提是我們得知道我們的節點在父元素上的索引,我們才能通過 $parent.childNodes[index] 得到該節點的引用。

好的,讓我們假設這個索引將被傳遞給 updateElement 函式(它確實會被傳遞——稍後將看到)。程式碼如下:

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parent.childNodes[index]
    );
  }
}

節點的替換

首先,需要編寫一個函式來比較兩個節點(舊節點和新節點),並告訴節點是否真的發生了變化。還有需要考慮這個節點可以是元素或是文字節點:

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === ‘string’ && node1 !== node2 ||
         node1.type !== node2.type
}

現在,當前的節點有了 index 屬性,就可以很簡單的用新節點替換它:

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parent.childNodes[index]
    );
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(
      createElement(newNode),
      $parent.childNodes[index]
    );
  }
}

比較子節點

最後,但並非最不重要的是——我們應該遍歷這兩個節點的每一個子節點並比較它們——實際上為每個節點呼叫updateElement(…)方法,同樣需要用到遞迴。

  • 當節點是 DOM 元素時我們才需要比較( 文字節點沒有子節點 )
  • 我們需要傳遞當前的節點的引用作為父節點
  • 我們應該一個一個的比較所有的子節點,即使它是 undefined 也沒有關係,我們的函式也會正確處理它。
  • 最後是 index,它是子陣列中子節點的 index
function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parent.childNodes[index]
    );
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(
      createElement(newNode),
      $parent.childNodes[index]
    );
  } else if (newNode.type) {
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;
    for (let i = 0; i < newLength || i < oldLength; i++) {
      updateElement(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
        i
      );
    }
  }
}

完整的程式碼

Babel+JSX
/* @jsx h /

function h(type, props, ...children) {
  return { type, props, children };
}

function createElement(node) {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === 'string' && node1 !== node2 ||
         node1.type !== node2.type
}

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parent.childNodes[index]
    );
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(
      createElement(newNode),
      $parent.childNodes[index]
    );
  } else if (newNode.type) {
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;
    for (let i = 0; i < newLength || i < oldLength; i++) {
      updateElement(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
        i
      );
    }
  }
}

// ---------------------------------------------------------------------

const a = (
  <ul>
    <li>item 1</li>
    <li>item 2</li>
  </ul>
);

const b = (
  <ul>
    <li>item 1</li>
    <li>hello!</li>
  </ul>
);

const $root = document.getElementById('root');
const $reload = document.getElementById('reload');

updateElement($root, a);
$reload.addEventListener('click', () => {
  updateElement($root, b, a);
});

HTML

<button id="reload">RELOAD</button>
<div id="root"></div>

CSS

#root {
  border: 1px solid black;
  padding: 10px;
  margin: 30px 0 0 0;
}

開啟開發者工具,並觀察當按下“Reload”按鈕時應用的更改。

圖片描述

總結

現在我們已經編寫了虛擬 DOM 實現及瞭解它的工作原理。作者希望,在閱讀了本文之後,對理解虛擬 DOM 如何工作的基本概念以及在幕後如何進行響應有一定的瞭解。

然而,這裡有一些東西沒有突出顯示(將在以後的文章中介紹它們):

  • 設定元素屬性(props)並進行 diffing/updating
  • 處理事件——向元素中新增事件監聽
  • 讓虛擬 DOM 與元件一起工作,比如React
  • 獲取對實際DOM節點的引用
  • 使用帶有庫的虛擬 DOM,這些庫可以直接改變真實的 DOM,比如 jQuery 及其外掛

原文:
https://medium.com/@deathmood...

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug


交流

有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。

本文 GitHub https://github.com/qq44924588... 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

相關文章