1.為什么需要虛擬DOM
DOM是很慢的卡骂,其元素非常龐大粘姜,頁面的性能問題由JS引起的,大部分都是由DOM操作引起的乞榨。如果對前端工作進行抽象的話秽之,主要就是維護狀態(tài)和更新視圖;而更新視圖和維護狀態(tài)都需要DOM操作吃既。其實近年來考榨,前端的框架主要發(fā)展方向就是解放DOM操作的復雜性。
在jQuery出現(xiàn)以前鹦倚,我們直接操作DOM結(jié)構(gòu)河质,這種方法復雜度高,兼容性也較差;有了jQuery強大的選擇器以及高度封裝的API掀鹅,我們可以更方便的操作DOM散休,jQuery幫我們處理兼容性問題,同時也使DOM操作變得簡單乐尊;但是聰明的程序員不可能滿足于此戚丸,各種MVVM框架應(yīng)運而生,有angularJS扔嵌、avalon限府、vue.js等,MVVM使用數(shù)據(jù)雙向綁定痢缎,使得我們完全不需要操作DOM了胁勺,更新了狀態(tài)視圖會自動更新,更新了視圖數(shù)據(jù)狀態(tài)也會自動更新独旷,可以說MMVM使得前端的開發(fā)效率大幅提升姻几,但是其大量的事件綁定使得其在復雜場景下的執(zhí)行性能堪憂;有沒有一種兼顧開發(fā)效率和執(zhí)行效率的方案呢势告?ReactJS就是一種不錯的方案蛇捌,雖然其將JS代碼和HTML代碼混合在一起的設(shè)計有不少爭議,但是其引入的Virtual DOM(虛擬DOM)卻是得到大家的一致認同的咱台。
2.理解虛擬DOM
虛擬的DOM的核心思想是:對復雜的文檔DOM結(jié)構(gòu)络拌,提供一種方便的工具,進行最小化地DOM操作回溺。這句話春贸,也許過于抽象,卻基本概況了虛擬DOM的設(shè)計思想
(1) 提供一種方便的工具遗遵,使得開發(fā)效率得到保證萍恕。
(2) 保證最小化的DOM操作,使得執(zhí)行效率得到保證车要。
(1).用JS表示DOM結(jié)構(gòu)
DOM很慢允粤,而javascript很快,用javascript對象可以很容易地表示DOM節(jié)點翼岁。DOM節(jié)點包括標簽类垫、屬性和子節(jié)點,通過VElement表示如下琅坡。
//虛擬dom悉患,參數(shù)分別為標簽名、屬性對象榆俺、子DOM列表
var VElement = function(tagName, props, children) {
//保證只能通過如下方式調(diào)用:new VElement
if (!(this instanceof VElement)) {
return new VElement(tagName, props, children);
}
//可以通過只傳遞tagName和children參數(shù)
if (util.isArray(props)) {
children = props;
props = {};
}
//設(shè)置虛擬dom的相關(guān)屬性
this.tagName = tagName;
this.props = props || {};
this.children = children || [];
this.key = props ? props.key : void 666;
var count = 0;
util.each(this.children, function(child, i) {
if (child instanceof VElement) {
count += child.count;
} else {
children[i] = '' + child;
}
count++;
});
this.count = count;
}
通過VElement售躁,我們可以很簡單地用javascript表示DOM結(jié)構(gòu)坞淮。比如
var vdom = velement('div', { 'id': 'container' }, [
velement('h1', { style: 'color:red' }, ['simple virtual dom']),
velement('p', ['hello world']),
velement('ul', [velement('li', ['item #1']), velement('li', ['item #2'])]),
]);
上面的javascript代碼可以表示如下DOM結(jié)構(gòu):
simple virtual dom
hello world
同樣我們可以很方便地根據(jù)虛擬DOM樹構(gòu)建出真實的DOM樹。具體思路:根據(jù)虛擬DOM節(jié)點的屬性和子節(jié)點遞歸地構(gòu)建出真實的DOM樹陪捷。見如下代碼:
VElement.prototype.render = function() {
//創(chuàng)建標簽
var el = document.createElement(this.tagName);
//設(shè)置標簽的屬性
var props = this.props;
for (var propName in props) {
var propValue = props[propName]
util.setAttr(el, propName, propValue);
}
//依次創(chuàng)建子節(jié)點的標簽
util.each(this.children, function(child) {
//如果子節(jié)點仍然為velement碾盐,則遞歸的創(chuàng)建子節(jié)點,否則直接創(chuàng)建文本類型節(jié)點
var childEl = (child instanceof VElement) ? child.render() : document.createTextNode(child);
el.appendChild(childEl);
});
return el;
}
對一個虛擬的DOM對象VElement揩局,調(diào)用其原型的render方法毫玖,就可以產(chǎn)生一顆真實的DOM樹。
vdom.render();
既然我們可以用JS對象表示DOM結(jié)構(gòu)凌盯,那么當數(shù)據(jù)狀態(tài)發(fā)生變化而需要改變DOM結(jié)構(gòu)時付枫,我們先通過JS對象表示的虛擬DOM計算出實際DOM需要做的最小變動,然后再操作實際DOM驰怎,從而避免了粗放式的DOM操作帶來的性能問題阐滩。
(2).比較兩棵虛擬DOM樹的差異
在用JS對象表示DOM結(jié)構(gòu)后,當頁面狀態(tài)發(fā)生變化而需要操作DOM時县忌,我們可以先通過虛擬DOM計算出對真實DOM的最小修改量掂榔,然后再修改真實DOM結(jié)構(gòu)(因為真實DOM的操作代價太大)。
如下圖所示症杏,兩個虛擬DOM之間的差異已經(jīng)標紅:
為了便于說明問題装获,我當然選取了最簡單的DOM結(jié)構(gòu),兩個簡單DOM之間的差異似乎是顯而易見的厉颤,但是真實場景下的DOM結(jié)構(gòu)很復雜穴豫,我們必須借助于一個有效的DOM樹比較算法。
設(shè)計一個diff算法有兩個要點:
如何比較兩個兩棵DOM樹
如何記錄節(jié)點之間的差異
<1> 如何比較兩個兩棵DOM樹
計算兩棵樹之間差異的常規(guī)算法復雜度為O(n3)逼友,一個文檔的DOM結(jié)構(gòu)有上百個節(jié)點是很正常的情況精肃,這種復雜度無法應(yīng)用于實際項目。針對前端的具體情況:我們很少跨級別的修改DOM節(jié)點帜乞,通常是修改節(jié)點的屬性司抱、調(diào)整子節(jié)點的順序、添加子節(jié)點等黎烈。因此习柠,我們只需要對同級別節(jié)點進行比較,避免了diff算法的復雜性怨喘。對同級別節(jié)點進行比較的常用方法是深度優(yōu)先遍歷:
function diff(oldTree, newTree) {
//節(jié)點的遍歷順序
var index = 0;
//在遍歷過程中記錄節(jié)點的差異
var patches = {};
//深度優(yōu)先遍歷兩棵樹
dfsWalk(oldTree, newTree, index, patches);
return patches;
}
<2>如何記錄節(jié)點之間的差異
由于我們對DOM樹采取的是同級比較津畸,因此節(jié)點之間的差異可以歸結(jié)為4種類型:
修改節(jié)點屬性, 用PROPS表示
修改節(jié)點文本內(nèi)容, 用TEXT表示
替換原有節(jié)點, 用REPLACE表示
調(diào)整子節(jié)點,包括移動必怜、刪除等,用REORDER表示
對于節(jié)點之間的差異后频,我們可以很方便地使用上述四種方式進行記錄梳庆,比如當舊節(jié)點被替換時:
{type:REPLACE,node:newNode}
而當舊節(jié)點的屬性被修改時:
{type:PROPS,props: newProps}
在深度優(yōu)先遍歷的過程中暖途,每個節(jié)點都有一個編號,如果對應(yīng)的節(jié)點有變化膏执,只需要把相應(yīng)變化的類別記錄下來即可驻售。下面是具體實現(xiàn):
function dfsWalk(oldNode, newNode, index, patches) {
var currentPatch = [];
if (newNode === null) {
//依賴listdiff算法進行標記為刪除
} else if (util.isString(oldNode) && util.isString(newNode)) {
if (oldNode !== newNode) {
//如果是文本節(jié)點則直接替換文本
currentPatch.push({
type: patch.TEXT,
content: newNode
});
}
} else if (oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) {
//節(jié)點類型相同
//比較節(jié)點的屬性是否相同
var propsPatches = diffProps(oldNode, newNode);
if (propsPatches) {
currentPatch.push({
type: patch.PROPS,
props: propsPatches
});
}
//比較子節(jié)點是否相同
diffChildren(oldNode.children, newNode.children, index, patches, currentPatch);
} else {
//節(jié)點的類型不同,直接替換
currentPatch.push({ type: patch.REPLACE, node: newNode });
}
if (currentPatch.length) {
patches[index] = currentPatch;
}
}
比如對上文圖中的兩顆虛擬DOM樹更米,可以用如下數(shù)據(jù)結(jié)構(gòu)記錄它們之間的變化:
var patches = {
1:{type:REPLACE,node:newNode}, //h1節(jié)點變成h5
5:{type:REORDER,moves:changObj} //ul新增了子節(jié)點li
}
(3).對真實DOM進行最小化修改
通過虛擬DOM計算出兩顆真實DOM樹之間的差異后欺栗,我們就可以修改真實的DOM結(jié)構(gòu)了。上文深度優(yōu)先遍歷過程產(chǎn)生了用于記錄兩棵樹之間差異的數(shù)據(jù)結(jié)構(gòu)patches, 通過使用patches我們可以方便對真實DOM做最小化的修改征峦。
//將差異應(yīng)用到真實DOM
function applyPatches(node, currentPatches) {
util.each(currentPatches, function(currentPatch) {
switch (currentPatch.type) {
//當修改類型為REPLACE時
case REPLACE:
var newNode = (typeof currentPatch.node === 'String')
? document.createTextNode(currentPatch.node)
: currentPatch.node.render();
node.parentNode.replaceChild(newNode, node);
break;
//當修改類型為REORDER時
case REORDER:
reoderChildren(node, currentPatch.moves);
break;
//當修改類型為PROPS時
case PROPS:
setProps(node, currentPatch.props);
break;
//當修改類型為TEXT時
case TEXT:
if (node.textContent) {
node.textContent = currentPatch.content;
} else {
node.nodeValue = currentPatch.content;
}
break;
default:
throw new Error('Unknow patch type ' + currentPatch.type);
}
});
}