衆所周知,對前端而言,直接操做 DOM 是一件及其耗費性能的事情,以 React 和 Vue 爲表明的衆多框架廣泛採用 Virtual DOM 來解決現在愈發複雜 Web 應用中狀態頻繁發生變化致使的頻繁更新 DOM 的性能問題。本文爲筆者經過實際操做,實現了一個很是簡單的 Virtual DOM ,加深對現今主流前端框架中 Virtual DOM 的理解。javascript
關於 Virtual DOM ,社區已經有許多優秀的文章,而本文是筆者採用本身的方式,並有所借鑑前輩們的實現,以淺顯易懂的方式,對 Virtual DOM 進行簡單實現,但不包含snabbdom的源碼分析,在筆者的最終實現裏,參考了snabbdom的原理,將本文的Virtual DOM實現進行了改進,感興趣的讀者能夠閱讀上面幾篇文章,並參考筆者本文的最終代碼進行閱讀。html
本文閱讀時間約15~20分鐘。前端
本文分爲如下幾個方面來說述極簡版本的 Virtual DOM 核心實現:java
要理解 Virtual DOM 的含義,首先須要理解 DOM ,DOM 是針對 HTML 文檔和 XML 文檔的一個 API , DOM 描繪了一個層次化的節點樹,經過調用 DOM API,開發人員能夠任意添加,移除和修改頁面的某一部分。而 Virtual DOM 則是用 JavaScript 對象來對 Virtual DOM 進行抽象化的描述。Virtual DOM 的本質是JavaScript對象,經過 Render函數,能夠將 Virtual DOM 樹 映射爲 真實 DOM 樹。node
一旦 Virtual DOM 發生改變,會生成新的 Virtual DOM ,相關算法會對比新舊兩顆 Virtual DOM 樹,並找到他們之間的不一樣,儘量地經過最少的 DOM 操做來更新真實 DOM 樹。react
咱們能夠這麼表示 Virtual DOM 與 DOM 的關係:DOM = Render(Virtual DOM)
。webpack
Virtual DOM 是用 JavaScript 對象表示,並存儲在內存中的。主流的框架均支持使用 JSX 的寫法, JSX 最終會被 babel 編譯爲JavaScript 對象,用於來表示Virtual DOM,思考下列的 JSX:git
<div>
<span className="item">item</span>
<input disabled={true} />
</div>
複製代碼
最終會被babel編譯爲以下的 JavaScript對象:github
{
type: 'div',
props: null,
children: [{
type: 'span',
props: {
class: 'item',
},
children: ['item'],
}, {
type: 'input',
props: {
disabled: true,
},
children: [],
}],
}
複製代碼
咱們能夠注意到如下兩點:web
{ type: '...', props: { ... }, children: { ... }, on: { ... } }
複製代碼
那麼 JSX 又是如何轉化爲 JavaScript 對象的呢。幸運的是,社區有許許多多優秀的工具幫助咱們完成了這件事,因爲篇幅有限,本文對這個問題暫時不作探討。爲了方便你們更快速地理解 Virtual DOM ,對於這一個步驟,筆者使用了開源工具來完成。著名的 babel 插件babel-plugin-transform-react-jsx幫助咱們完成這項工做。
爲了更好地使用babel-plugin-transform-react-jsx,咱們須要搭建一下webpack開發環境。具體過程這裏不作闡述,有興趣本身實現的同窗能夠到simple-virtual-dom查看代碼。
對於不使用 JSX 語法的同窗,能夠不配置babel-plugin-transform-react-jsx,經過咱們的vdom
函數建立 Virtual DOM:
function vdom(type, props, ...children) {
return {
type,
props,
children,
};
}
複製代碼
而後咱們能夠經過以下代碼建立咱們的 Virtual DOM 樹:
const vNode = vdom('div', null,
vdom('span', { class: 'item' }, 'item'),
vdom('input', { disabled: true })
);
複製代碼
在控制檯輸入上述代碼,能夠看到,已經建立好了用 JavaScript對象表示的 Virtual DOM 樹:
如今咱們知道了如何用 JavaScript對象 來表明咱們的真實 DOM 樹,那麼, Virtual DOM 又是怎麼轉換爲真實 DOM 給咱們呈現的呢?
在這以前,咱們要先知道幾項注意事項:
$
開頭的變量來表示真實 DOM 對象;toRealDom
函數接受一個 Virtual DOM 對象爲參數,將返回一個真實 DOM 對象;mount
函數接受兩個參數:將掛載 Virtual DOM 對象的父節點,這是一個真實 DOM 對象,命名爲$parent
;以及被掛載的 Virtual DOM 對象vNode
;下面是toRealDom
的函數原型:
function toRealDom(vNode) {
let $dom;
// do something with vNode
return $dom;
}
複製代碼
經過toRealDom
方法,咱們能夠將一個vNode
對象轉化爲一個真實 DOM 對象,而mount
函數經過appendChild
,將真實 DOM 掛載:
function mount($parent, vNode) {
return $parent.appendChild(toRealDom(vNode));
}
複製代碼
下面,讓咱們來分別處理vNode
的type
、props
和children
。
首先,由於咱們同時具備字符類型的文本節點和對象類型的element
節點,須要對type
作單獨的處理:
if (typeof vNode === 'string') {
$dom = document.createTextNode(vNode);
} else {
$dom = document.createElement(vNode.type);
}
複製代碼
在這樣一個簡單的toRealDom
函數中,對type
的處理就完成了,接下來讓咱們看看對props
的處理。
咱們知道,若是節點有props
,那麼props
是一個對象。經過遍歷props
,調用setProp
方法,對每一類props
單獨處理。
if (vNode.props) {
Object.keys(vNode.props).forEach(key => {
setProp($dom, key, vNode.props[key]);
});
}
複製代碼
setProp
接受三個參數:
$target
,這是一個真實 DOM 對象,setProp
將對這個節點進行 DOM 操做;name
,表示屬性名;value
,表示屬性的值;讀到這裏,相信你已經大概清楚setProp
須要作什麼了,通常狀況下,對於普通的props
,咱們會經過setAttribute
給 DOM 對象附加屬性。
function setProp($target, name, value) {
return $target.setAttribute(name, value);
}
複製代碼
但這遠遠不夠,思考下列的 JSX 結構:
<div>
<span className="item" data-node="item" onClick={() => console.log('item')}>item</span>
<input disabled={true} />
</div>
複製代碼
從上面的 JSX 結構中,咱們發現如下幾點:
class
是 JavaScript 的保留字, JSX 通常使用className
來表示 DOM 節點所屬的class
;on
開頭的屬性來表示事件;disabled
,當該值爲true
時,則添加這一屬性;因此,setProp
也一樣須要考慮上述狀況:
function isEventProp(name) {
return /^on/.test(name);
}
function extractEventName(name) {
return name.slice(2).toLowerCase();
}
function setProp($target, name, value) {
if (name === 'className') { // 由於class是保留字,JSX使用className來表示節點的class
return $target.setAttribute('class', value);
} else if (isEventProp(name)) { // 針對 on 開頭的屬性,爲事件
return $target.addEventListener(extractEventName(name), value);
} else if (typeof value === 'boolean') { // 兼容屬性爲布爾值的狀況
if (value) {
$target.setAttribute(name, value);
}
return $target[name] = value;
} else {
return $target.setAttribute(name, value);
}
}
複製代碼
最後,還有一類屬性是咱們的自定義屬性,例如主流框架中的組件間的狀態傳遞,即經過props
來進行傳遞的,咱們並不但願這一類屬性顯示在 DOM 中,所以須要編寫一個函數isCustomProp
來檢查這個屬性是不是自定義屬性,由於本文只是爲了實現 Virtual DOM 的核心思想,爲了方便,在本文中,這個函數直接返回false
。
function isCustomProp(name) {
return false;
}
複製代碼
最終的setProp
函數:
function setProp($target, name, value) {
if (isCustomProp(name)) {
return;
} else if (name === 'className') { // fix react className
return $target.setAttribute('class', value);
} else if (isEventProp(name)) {
return $target.addEventListener(extractEventName(name), value);
} else if (typeof value === 'boolean') {
if (value) {
$target.setAttribute(name, value);
}
return $target[name] = value;
} else {
return $target.setAttribute(name, value);
}
}
複製代碼
對於children
裏的每一項,都是一個vNode
對象,在進行 Virtual DOM 轉化爲真實 DOM 時,子節點也須要被遞歸轉化,能夠想到,針對有子節點的狀況,須要對子節點以此遞歸調用toRealDom
,以下代碼所示:
if (vNode.children && vNode.children.length) {
vNode.children.forEach(childVdom => {
const realChildDom = toRealDom(childVdom);
$dom.appendChild(realChildDom);
});
}
複製代碼
最終完成的toRealDom
以下:
function toRealDom(vNode) {
let $dom;
if (typeof vNode === 'string') {
$dom = document.createTextNode(vNode);
} else {
$dom = document.createElement(vNode.type);
}
if (vNode.props) {
Object.keys(vNode.props).forEach(key => {
setProp($dom, key, vNode.props[key]);
});
}
if (vNode.children && vNode.children.length) {
vNode.children.forEach(childVdom => {
const realChildDom = toRealDom(childVdom);
$dom.appendChild(realChildDom);
});
}
return $dom;
}
複製代碼
Virtual DOM 之因此被創造出來,最根本的緣由是性能提高,經過 Virtual DOM ,開發者能夠減小許多沒必要要的 DOM 操做,以達到最優性能,那麼下面咱們來看看 Virtual DOM 算法 是如何經過對比更新前的 Virtual DOM 樹和更新後的 Virtual DOM 樹來實現性能優化的。
注:本文是筆者的最簡單實現,目前社區廣泛通用的算法是snabbdom,如 Vue 則是借鑑該算法實現的 Virtual DOM ,有興趣的讀者能夠查看這個庫的源代碼,基於本文的 Virtual DOM 的小示例,筆者最終也參考了該算法實現,本文demo傳送門,因爲篇幅有限,感興趣的讀者能夠自行研究。
爲了處理變化,首先聲明一個updateDom
函數,這個函數接受如下四個參數:
$parent
,表示將被掛載的父節點;oldVNode
,舊的VNode
對象;newVNode
,新的VNode
對象;index
,在更新子節點時使用,表示當前更新第幾個子節點,默認爲0;函數原型以下:
function updateDom($parent, oldVNode, newVNode, index = 0) {
}
複製代碼
首先咱們來看新增一個節點的狀況,對於本來沒有該節點,須要添加新的一個節點到 DOM 樹中,咱們須要經過appendChild
來實現:
轉化爲代碼表述爲:
// 沒有舊的節點,添加新的節點
if (!oldVNode) {
return $parent.appendChild(toRealDom(newVNode));
}
複製代碼
同理,對於刪除一箇舊節點的狀況,咱們經過removeChild
來實現,在這裏,咱們應該從真實 DOM 中將舊的節點刪掉,但問題是在這個函數中是直接取不到這一個節點的,咱們須要知道這個節點在父節點中的位置,事實上,能夠經過$parent.childNodes[index]
來取到,這即是上面提到的爲什麼須要傳入index
,它表示當前更新的節點在父節點中的索引:
轉化爲代碼表述爲:
const $currentDom = $parent.childNodes[index];
// 沒有新的節點,刪除舊的節點
if (!newVNode) {
return $parent.removeChild($currentDom);
}
複製代碼
Virtual DOM 的核心在於如何高效更新節點,下面咱們來看看更新節點的狀況。
首先,針對文本節點,咱們能夠簡單處理,對於文本節點是否發生改變,只須要經過比較其新舊字符串是否相等便可,若是是相同的文本節點,是不須要咱們更新 DOM 的,在updateDom
函數中,直接return
便可:
// 都是文本節點,都沒有發生變化
if (typeof oldVNode === 'string' && typeof newVNode === 'string' && oldVNode === newVNode) {
return;
}
複製代碼
接下來,考慮節點是否真的須要更新,如圖所示,一個節點的類型從span
換成了div
,顯而易見,這是必定須要咱們去更新DOM
的:
咱們須要編寫一個函數isNodeChanged
來幫助咱們判斷舊節點和新節點是否真的一致,若是不一致,須要咱們把節點進行替換:
function isNodeChanged(oldVNode, newVNode) {
// 一個是textNode,一個是element,必定改變
if (typeof oldVNode !== typeof newVNode) {
return true;
}
// 都是textNode,比較文本是否改變
if (typeof oldVNode === 'string' && typeof newVNode === 'string') {
return oldVNode !== newVNode;
}
// 都是element節點,比較節點類型是否改變
if (typeof oldVNode === 'object' && typeof newVNode === 'object') {
return oldVNode.type !== newVNode.type;
}
}
複製代碼
在updateDom
中,發現節點類型發生變化,則將該節點直接替換,以下代碼所示,經過調用replaceChild
,將舊的 DOM 節點移除,並將新的 DOM 節點加入:
if (isNodeChanged(oldVNode, newVNode)) {
return $parent.replaceChild(toRealDom(newVNode), $currentDom);
}
複製代碼
但這遠遠尚未結束,考慮下面這種狀況:
<!-- old -->
<div class="item" data-item="old-item"></div>
複製代碼
<!-- new -->
<div id="item" data-item="new-item"></div>
複製代碼
對比上面的新舊兩個節點,發現節點類型並無發生改變,即VNode.type
都是'div'
,可是節點的屬性卻發生了改變,除了針對節點類型的變化更新 DOM 外,針對節點的屬性的改變,也須要對應把 DOM 更新。
與上述方法相似,咱們編寫一個isPropsChanged
函數,來判斷新舊兩個節點的屬性是否有發生變化:
function isPropsChanged(oldProps, newProps) {
// 類型都不一致,props確定發生變化了
if (typeof oldProps !== typeof newProps) {
return true;
}
// props爲對象
if (typeof oldProps === 'object' && typeof newProps === 'object') {
const oldKeys = Object.keys(oldProps);
const newkeys = Object.keys(newProps);
// props的個數都不同,必定發生了變化
if (oldKeys.length !== newkeys.length) {
return true;
}
// props的個數相同的狀況,遍歷props,看是否有不一致的props
for (let i = 0; i < oldKeys.length; i++) {
const key = oldKeys[i]
if (oldProps[key] !== newProps[key]) {
return true;
}
}
// 默認未改變
return false;
}
return false;
}
複製代碼
由於當節點沒有任何屬性時,props
爲null
,isPropsChanged
首先判斷新舊兩個節點的props
是不是同一類型,便是否存在舊節點的props
爲null
,新節點有新的屬性,或者反之:新節點的props
爲null
,舊節點的屬性被刪除了。若是類型不一致,那麼屬性必定是被更新的。
接下來,考慮到節點在更新先後都有props
的狀況,咱們須要判斷更新先後的props
是否一致,即兩個對象是否全等,遍歷便可。若是有不相等的屬性,則認爲props
發生改變,須要處理props
的變化。
如今,讓咱們回到咱們的updateDom
函數,看看是把Virtual DOM 節點props
的更新應用到真實 DOM 上的。
// 虛擬DOM的type未改變,對比節點的props是否改變
const oldProps = oldVNode.props || {};
const newProps = newVNode.props || {};
if (isPropsChanged(oldProps, newProps)) {
const oldPropsKeys = Object.keys(oldProps);
const newPropsKeys = Object.keys(newProps);
// 若是新節點沒有屬性,把舊的節點的屬性清除掉
if (newPropsKeys.length === 0) {
oldPropsKeys.forEach(propKey => {
removeProp($currentDom, propKey, oldProps[propKey]);
});
} else {
// 拿到全部的props,以此遍歷,增長/刪除/修改對應屬性
const allPropsKeys = new Set([...oldPropsKeys, ... newPropsKeys]);
allPropsKeys.forEach(propKey => {
// 屬性被去除了
if (!newProps[propKey]) {
return removeProp($currentDom, propKey, oldProps[propKey]);
}
// 屬性改變了/增長了
if (newProps[propKey] !== oldProps[propKey]) {
return setProp($currentDom, propKey, newProps[propKey]);
}
});
}
}
複製代碼
上面的代碼也很是好理解,若是發現props
改變了,那麼對舊的props
的每項去作遍歷。把不存在的屬性清除,再把新增長的屬性加入到更新後的 DOM 樹中:
removeProp
刪除。removeProp
與setProp
相對應,因爲本文篇幅有限,筆者在這裏就不作過多闡述;function removeProp($target, name, value) {
if (isCustomProp(name)) {
return;
} else if (name === 'className') { // fix react className
return $target.removeAttribute('class');
} else if (isEventProp(name)) {
return $target.removeEventListener(extractEventName(name), value);
} else if (typeof value === 'boolean') {
$target.removeAttribute(name);
$target[name] = false;
} else {
$target.removeAttribute(name);
}
}
複製代碼
setProp
給真實 DOM 節點添加新的屬性。在最後,與toRealDom
相似的是,在updateDom
中,咱們也應當處理全部子節點,對子節點進行遞歸調用updateDom
,一個一個對比全部子節點的VNode
是否有更新,一旦VNode
有更新,則真實 DOM 也須要從新渲染:
// 根節點相同,但子節點不一樣,要遞歸對比子節點
if (
(oldNode.children && oldNode.children.length) ||
(newNode.children && newNode.children.length)
) {
for (let i = 0; i < oldNode.children.length || i < newNode.children.length; i++) {
updateDom($currentDom, oldNode.children[i], newNode.children[i], i);
}
}
複製代碼
以上是筆者實現的最簡單的 Virtual DOM 代碼,但這與社區咱們所用到 Virtual DOM 算法是有天壤之別的,筆者在這裏舉個最簡單的例子:
<!-- old -->
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
複製代碼
<!-- new -->
<ul>
<li>5</li>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
</ul>
複製代碼
對於上述代碼中實現的updateDom
函數而言,更新先後的 DOM 結構如上所示,則會觸發五個li
節點所有從新渲染,這顯然是一種性能的浪費。而snabbdom則經過移動節點的方式較好地解決了上述問題,因爲本文篇幅有限,而且社區也有許多對該 Virtual DOM 算法的分析文章,筆者就不在本文作過多闡述了,有興趣的讀者能夠到自行研究。筆者也基於本文實例,參考snabbdom算法實現了最終的版本,有興趣的讀者能夠查看本文示例最終版