作者: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 節點。這裡先不考慮 props
和 children
屬性:
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(…) 的函式,它接受三個引數—— $parent
、newNode 和 oldNode,其中 $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... 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。