50行代碼實(shí)現(xiàn)Virtual DOM
在你創(chuàng)造出自己的Virtual DOM之前,你只需要知道兩件事情。你甚至不需要深入了解React的源代碼,或者其他Virtual DOM的實(shí)現(xiàn)。它們都太龐大和復(fù)雜了,但實(shí)際上Virtual DOM的部分只需要不超過(guò)50行的代碼!(當(dāng)然,你千萬(wàn)不要把它放在生產(chǎn)環(huán)境)
這里有2個(gè)概念:
- Virtual DOM是真實(shí)DOM的映射。
- 當(dāng)我們?cè)赩irtual DOM樹(shù)改變一些東西的時(shí)候,我們得到了一個(gè)新的Virtual DOM樹(shù),通過(guò)算法比較新樹(shù)和舊樹(shù),找到不同的地方,然后只需要在真實(shí)的DOM上做出相應(yīng)的改變。
僅此而已,讓我們來(lái)深入這兩個(gè)概念。
構(gòu)建我們的Virtual DOM樹(shù)
首先,我們要在內(nèi)存中存儲(chǔ)我們的DOM樹(shù),我們能夠用純JS對(duì)象來(lái)表示它,假設(shè)我們有這樣的一個(gè)結(jié)構(gòu):
<ul class="list">
<li>item 1</li>
<li>item 2</li>
</ul>
看起來(lái)非常簡(jiǎn)單,那我們?cè)趺从肑S對(duì)象來(lái)構(gòu)造它呢?
{ type: 'ul', props: { 'class': 'list' }, children: [
{ type: 'li', props: {}, children: ['item 1'] },
{ type: 'li', props: {}, children: ['item 2'] }
] }
這里有兩個(gè)點(diǎn)需要注意下:
- 我們用JS對(duì)象表示DOM的元素:
{ type: '...', props: {...}, children: [...] }
- 我們用JS字符串表示DOM的文本節(jié)點(diǎn)。
但是用這樣的方式寫一個(gè)更大的樹(shù)的結(jié)構(gòu)是非常復(fù)雜的,所以讓我們先寫一個(gè)幫助函數(shù),它能讓我們更容易的理解結(jié)構(gòu)。
function h(type, props, ...children) {
return {
type,
props,
children
}
}
現(xiàn)在我們能這樣去寫我們的DOM樹(shù):
h('ul', { 'class': 'list' },
h('li', {}, 'item 1'),
h('li', {}, 'item 2'),
)
這樣看起來(lái)清晰多了吧?但是我們還可以讓它變得更好,你應(yīng)該聽(tīng)過(guò)JSX,對(duì)吧?它是怎么工作的呢?
如果你看過(guò)Babel JSX的官方文檔,你就會(huì)知道,Babel會(huì)把下面的代碼:
<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'),
)
是不是看起來(lái)有點(diǎn)熟悉?如果我們能夠用我們的h(...)函數(shù)代替React.createElement(…),那么我們也能使用JSX語(yǔ)法。其實(shí),我們只需要在源文件頭部加上這么一句注釋:
/** @jsx h */
它實(shí)際上是告訴Babel:'哥們, 幫我編譯JSX語(yǔ)法,用h(...)函數(shù)代替React.createElement(…),然后Babel就開(kāi)始編譯。
因此,總結(jié)我之前說(shuō)的,我們將用這樣的方式去寫我們的DOM樹(shù):
/** @jsx h */
const a = (
<ul class="list">
<li>item 1</li>
<li>item 2</li>
</ul>
)
Babel會(huì)幫我們編譯成這樣的代碼:
const a = h( 'ul',{ 'class': 'list' },
h( 'li', null, 'item 1' ),
h( 'li', null, 'item 2' )
);
當(dāng)h(...)執(zhí)行的之后,它將會(huì)返回純的JS對(duì)象,即我們的虛擬DOM。
運(yùn)用Virtual DOM構(gòu)建真實(shí)的DOM
現(xiàn)在我們使用JS對(duì)象來(lái)表示DOM的結(jié)構(gòu),這非??幔俏覀冃枰盟鼊?chuàng)建一個(gè)真實(shí)的DOM。
首先,讓我們做一些假設(shè)并設(shè)置一些術(shù)語(yǔ)。
- 我會(huì)用帶
$的變量名來(lái)表示真實(shí)的DOM樹(shù),?—?因此$parent將會(huì)是一個(gè)真實(shí)的DOM節(jié)點(diǎn)。 - Virtual DOM在變量中使用
node命名。 - 就像在React中,你僅僅只有一個(gè)root節(jié)點(diǎn),其他所有的節(jié)點(diǎn)都將會(huì)在它里面。
如上所述,讓我們來(lái)寫一個(gè)createElement(…)函數(shù)把Virtual DOM轉(zhuǎn)換成真實(shí)的DOM。
因?yàn)槲覀冇袃煞N節(jié)點(diǎn),text和element。因此我們的createElement函數(shù)需要處理這兩種情況。
讓我們想一下,其實(shí)子節(jié)點(diǎn)要么是一個(gè)element,要么是一個(gè)text節(jié)點(diǎn),是text節(jié)點(diǎn)的話,我們直接渲染:
document.createTextNode(node)
是element節(jié)點(diǎn)的話 需要遞歸地把它的子節(jié)點(diǎn)也構(gòu)建起來(lái):
const $el = document.createElement(node.type)
node
.children
.map(createElement)
.forEach($el.appendChild.bind($el))
createElement代碼如下:
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
}
現(xiàn)在的完整代碼如下:
<div id="root"></div>
/** @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))
WOW,是不是看起來(lái)很不錯(cuò),讓我們暫時(shí)先拋開(kāi)props,我們稍后會(huì)談到它。
比較兩棵虛擬DOM樹(shù)的差異
現(xiàn)在我們已經(jīng)把virtual DOM轉(zhuǎn)換成一棵真實(shí)的DOM樹(shù),是時(shí)候考慮下怎么比較兩棵虛擬DOM樹(shù)的差異了。最基本的,我們需要一個(gè)算法來(lái)比較新的樹(shù)和舊的樹(shù),它能夠讓我們知道什么地方改變了,然后相應(yīng)的去改變真實(shí)的DOM。
怎么比較DOM樹(shù)呢?我們需要處理下面的情況:
- 添加新節(jié)點(diǎn),我們需要用
appendChild方法添加節(jié)點(diǎn)

- 移除老節(jié)點(diǎn),我們需要用
removeChild方法移除老的節(jié)點(diǎn)

- 節(jié)點(diǎn)的替換,我們需要用
replaceChild方法

- 節(jié)點(diǎn)相同,因此我們需要深度比較子節(jié)點(diǎn)

讓我們開(kāi)始寫updateElement方法,它需要傳遞3個(gè)參數(shù):$parent, newNode和oldNode。$parent是我們虛擬節(jié)點(diǎn)的真實(shí)的父級(jí)DOM元素?,F(xiàn)在我們來(lái)看看怎么處理上面描述的所有的情況。
添加新節(jié)點(diǎn)
非常直接,我甚至都不需要寫注釋。
function updateElement($parent, newNode, oldNode) {
if (!oldNode) {
$parent.appendChild(
createElement(newNode)
)
}
}
移除老節(jié)點(diǎn)
這里我們遇到一個(gè)問(wèn)題?—?如果在新的Virtual DOM樹(shù)里面沒(méi)有某個(gè)節(jié)點(diǎn),那我們應(yīng)該在真實(shí)的DOM樹(shù)移除它。但我們應(yīng)該怎么做呢?
如果我們已知父元素(通過(guò)參數(shù)傳遞),我們就能調(diào)用$parent.removeChild(…)方法把變化映射到真實(shí)的DOM上。但前提是我們得知道我們的節(jié)點(diǎn)在父元素上的索引,我們才能通過(guò)$parent.childNodes[index]得到該節(jié)點(diǎn)的引用。
OK,讓我們假設(shè)index將會(huì)通過(guò)參數(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ù)比較舊樹(shù)和新樹(shù)的不同,告訴我們node真的改變了。我們需要考慮文本和元素這兩種情況:
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ǎn)單的用新節(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)然后比較它們。實(shí)際上是對(duì)每一個(gè)節(jié)點(diǎn)調(diào)用updateElement方法,同樣需要用到遞歸。
但是在寫代碼之前我們需要先考慮幾點(diǎn):
- 只有當(dāng)節(jié)點(diǎn)是元素的時(shí)候,我們才需要比較子節(jié)點(diǎn)(文本節(jié)點(diǎn)沒(méi)有子元素)
- 我們需要傳遞當(dāng)前的節(jié)點(diǎn)的引用作為父節(jié)點(diǎn)
- 我們應(yīng)該一個(gè)一個(gè)的比較所有的子節(jié)點(diǎn),即使它是
undefined也沒(méi)有關(guān)系,我們的函數(shù)會(huì)處理它。 -
index?— 它只是子節(jié)點(diǎn)數(shù)組的索引。
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
)
}
}
}
到此就基本完成了,當(dāng)你點(diǎn)擊Reload按鈕的時(shí)候,你可以打開(kāi)開(kāi)發(fā)者工具觀察元素的變化。
你可以在這里找到所有的代碼,github。