要構(gòu)建自己的虛擬DOM,,需要知道兩件事,。你甚至不需要深入 React 的源代碼或者深入任何其他虛擬DOM實(shí)現(xiàn)的源代碼,,因?yàn)樗鼈兪侨绱她嫶蠛蛷?fù)雜——但實(shí)際上,,虛擬DOM的主要部分只需不到50行代碼,。
有兩個(gè)概念:
- Virtual DOM 是真實(shí)DOM的映射
- 當(dāng)虛擬 DOM 樹中的某些節(jié)點(diǎn)改變時(shí),,會(huì)得到一個(gè)新的虛擬樹。算法對這兩棵樹(新樹和舊樹)進(jìn)行比較,,找出差異,,然后只需要在真實(shí)的 DOM 上做出相應(yīng)的改變。
用JS對象模擬DOM樹
首先,,我們需要以某種方式將 DOM 樹存儲(chǔ)在內(nèi)存中,。可以使用普通的 JS 對象來做,。假設(shè)我們有這樣一棵樹:
<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’] }
] }
這里有兩件事需要注意:
{ type: ‘…’, props: { … }, children: [ … ] }
- 用普通 JS 字符串表示 DOM 文本節(jié)點(diǎn)
但是用這種方式表示內(nèi)容很多的 Dom 樹是相當(dāng)困難的。這里來寫一個(gè)輔助函數(shù),,這樣更容易理解:
function h(type, props, …children) {
return { type, props, children };
}
用這個(gè)方法重新整理一開始代碼:
h(‘ul’, { ‘class’: ‘list’ },
h(‘li’, {}, ‘item 1’),
h(‘li’, {}, ‘item 2’),
);
這樣看起來簡潔多了,,還可以更進(jìn)一步。這里使用 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’),
);
是不是看起來有點(diǎn)熟悉?如果能夠用我們剛定義的 h(...) 函數(shù)代替 React.createElement(…) ,,那么我們也能使用JSX 語法,。其實(shí),只需要在源文件頭部加上這么一句注釋:
/** @jsx h */
<ul className=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
它實(shí)際上告訴 Babel ' 嘿,,小老弟幫我編譯 JSX 語法,,用 h(...) 函數(shù)代替 React.createElement(…) ,然后 Babel 就開始編譯,。'
綜上所述,,我們將DOM寫成這樣:
/** @jsx h */
const a = (
<ul className=”list”>
<li>item 1</li>
<li>item 2</li>
</ul>
);
Babel 會(huì)幫我們編譯成這樣的代碼:
const a = (
h(‘ul’, { className: ‘list’ },
h(‘li’, {}, ‘item 1’),
h(‘li’, {}, ‘item 2’),
);
);
當(dāng)函數(shù) “h” 執(zhí)行時(shí),它將返回普通JS對象-即我們的虛擬DOM:
const a = (
{ type: ‘ul’, props: { className: ‘list’ }, children: [
{ type: ‘li’, props: {}, children: [‘item 1’] },
{ type: ‘li’, props: {}, children: [‘item 2’] }
] }
);
從Virtual DOM 映射到真實(shí) DOM
好了,,現(xiàn)在我們有了 DOM 樹,,用普通的 JS 對象表示,還有我們自己的結(jié)構(gòu),。這很酷,,但我們需要從它創(chuàng)建一個(gè)真正的DOM。
首先讓我們做一些假設(shè)并聲明一些術(shù)語:
- 使用以'
$ '開頭的變量表示真正的DOM節(jié)點(diǎn)(元素,,文本節(jié)點(diǎn)),,因此 $parent 將會(huì)是一個(gè)真實(shí)的DOM元素
- 虛擬 DOM 使用名為
node 的變量表示
* 就像在 React 中一樣,只能有一個(gè)根節(jié)點(diǎn)——所有其他節(jié)點(diǎn)都在其中
那么,,來編寫一個(gè)函數(shù) createElement(…) ,,它將獲取一個(gè)虛擬 DOM 節(jié)點(diǎn)并返回一個(gè)真實(shí)的 DOM 節(jié)點(diǎn)。這里先不考慮 props 和 children 屬性:
function createElement(node) {
if (typeof node === ‘string’) {
return document.createTextNode(node);
}
return document.createElement(node.type);
}
上述方法我也可以創(chuàng)建有兩種節(jié)點(diǎn)分別是文本節(jié)點(diǎn)和 Dom 元素節(jié)點(diǎn),,它們是類型為的 JS 對象:
{ type: ‘…’, props: { … }, children: [ … ] }
因此,,可以在函數(shù) createElement 傳入虛擬文本節(jié)點(diǎn)和虛擬元素節(jié)點(diǎn)——這是可行的。
現(xiàn)在讓我們考慮子節(jié)點(diǎn)——它們中的每一個(gè)都是文本節(jié)點(diǎn)或元素,。所以它們也可以用 createElement(…) 函數(shù)創(chuàng)建,。是的,這就像遞歸一樣,,所以我們可以為每個(gè)元素的子元素調(diào)用 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;
}
哇,看起來不錯(cuò),。先把節(jié)點(diǎn) props 屬性放到一邊,。待會(huì)再談。我們不需要它們來理解虛擬DOM的基本概念,,因?yàn)樗鼈儠?huì)增加復(fù)雜性,。
完整代碼如下:
/** @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樹的差異
現(xiàn)在我們可以將虛擬 DOM 轉(zhuǎn)換為真實(shí)的 DOM,這就需要考慮比較兩棵 DOM 樹的差異,?;镜?,我們需要一個(gè)算法來比較新的樹和舊的樹,它能夠讓我們知道什么地方改變了,,然后相應(yīng)的去改變真實(shí)的 DOM,。
怎么比較 DOM 樹?需要處理下面的情況:
- 添加新節(jié)點(diǎn),,使用 appendChild(…) 方法添加節(jié)點(diǎn)
- 移除老節(jié)點(diǎn),,使用 removeChild(…) 方法移除老的節(jié)點(diǎn)
- 節(jié)點(diǎn)的替換,使用 replaceChild(…) 方法
如果節(jié)點(diǎn)相同的——就需要需要深度比較子節(jié)點(diǎn)
編寫一個(gè)名為 updateElement(…) 的函數(shù),,它接受三個(gè)參數(shù)—— $parent ,、newNode 和 oldNode,其中 $parent 是虛擬節(jié)點(diǎn)的一個(gè)實(shí)際 DOM 元素的父元素?,F(xiàn)在來看看如何處理上面描述的所有情況,。
添加新節(jié)點(diǎn)
function updateElement($parent, newNode, oldNode) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
}
}
移除老節(jié)點(diǎn)
這里遇到了一個(gè)問題——如果在新虛擬樹的當(dāng)前位置沒有節(jié)點(diǎn)——我們應(yīng)該從實(shí)際的 DOM 中刪除它—— 這要如何做呢?
如果我們已知父元素(通過參數(shù)傳遞),我們就能調(diào)用 $parent.removeChild(…) 方法把變化映射到真實(shí)的 DOM 上,。但前提是我們得知道我們的節(jié)點(diǎn)在父元素上的索引,,我們才能通過 $parent.childNodes[index] 得到該節(jié)點(diǎn)的引用。
好的,,讓我們假設(shè)這個(gè)索引將被傳遞給 updateElement 函數(shù)(它確實(shí)會(huì)被傳遞——稍后將看到),。代碼如下:
function updateElement($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
);
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
);
}
}
節(jié)點(diǎn)的替換
首先,需要編寫一個(gè)函數(shù)來比較兩個(gè)節(jié)點(diǎn)(舊節(jié)點(diǎn)和新節(jié)點(diǎn)),,并告訴節(jié)點(diǎn)是否真的發(fā)生了變化,。還有需要考慮這個(gè)節(jié)點(diǎn)可以是元素或是文本節(jié)點(diǎn):
function changed(node1, node2) {
return typeof node1 !== typeof node2 ||
typeof node1 === ‘string’ && node1 !== node2 ||
node1.type !== node2.type
}
現(xiàn)在,當(dāng)前的節(jié)點(diǎn)有了 index 屬性,,就可以很簡單的用新節(jié)點(diǎn)替換它:
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]
);
}
}
比較子節(jié)點(diǎn)
最后,,但并非最不重要的是——我們應(yīng)該遍歷這兩個(gè)節(jié)點(diǎn)的每一個(gè)子節(jié)點(diǎn)并比較它們——實(shí)際上為每個(gè)節(jié)點(diǎn)調(diào)用updateElement(…)方法,同樣需要用到遞歸,。
- 當(dāng)節(jié)點(diǎn)是 DOM 元素時(shí)我們才需要比較( 文本節(jié)點(diǎn)沒有子節(jié)點(diǎn) )
- 我們需要傳遞當(dāng)前的節(jié)點(diǎn)的引用作為父節(jié)點(diǎn)
- 我們應(yīng)該一個(gè)一個(gè)的比較所有的子節(jié)點(diǎn),,即使它是
undefined 也沒有關(guān)系,我們的函數(shù)也會(huì)正確處理它,。
- 最后是 index,它是子數(shù)組中子節(jié)點(diǎn)的 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;
}
打開開發(fā)者工具,,并觀察當(dāng)按下“Reload”按鈕時(shí)應(yīng)用的更改,。
總結(jié)
現(xiàn)在我們已經(jīng)編寫了虛擬 DOM 實(shí)現(xiàn)及了解它的工作原理。作者希望,,在閱讀了本文之后,,對理解虛擬 DOM 如何工作的基本概念以及在幕后如何進(jìn)行響應(yīng)有一定的了解。
然而,,這里有一些東西沒有突出顯示(將在以后的文章中介紹它們):
- 設(shè)置元素屬性(props)并進(jìn)行 diffing/updating
- 處理事件——向元素中添加事件監(jiān)聽
- 讓虛擬 DOM 與組件一起工作,,比如React
- 獲取對實(shí)際DOM節(jié)點(diǎn)的引用
- 使用帶有庫的虛擬 DOM,,這些庫可以直接改變真實(shí)的 DOM,比如 jQuery 及其插件
|