經過編寫簡易虛擬DOM,來學習虛擬DOM 的原理

做者:deathmood
譯者:前端小智
來源:medium

1024程序員節,160就能買到400的書,紅寶書 5 折前端

爲了保證的可讀性,本文采用意譯而非直譯。node

要構建本身的虛擬DOM,須要知道兩件事。你甚至不須要深刻 React 的源代碼或者深刻任何其餘虛擬DOM實現的源代碼,由於它們是如此龐大和複雜——但實際上,虛擬DOM的主要部分只需不到50行代碼。react

有兩個概念:git

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

用JS對象模擬DOM樹

首先,咱們須要以某種方式將 DOM 樹存儲在內存中。可使用普通的 JS 對象來作。假設咱們有這樣一棵樹:程序員

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

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

{ 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/qq449245884/xiaozhi 已經收錄,整理了不少個人文檔,歡迎Star和完善,你們面試能夠參照考點複習,另外關注公衆號,後臺回覆福利,便可看到福利,你懂的。

相關文章
相關標籤/搜索