最近一兩年前端最火的技術莫過於ReactJS,即便你沒用過也該聽過,ReactJS由業界頂尖的互聯網公司facebook提出,其本身有很多先進的設計思路,比如頁面UI組件化、虛擬DOM等。本文將帶你解開虛擬DOM的神秘面紗,不僅要理解其原理,而且要實現一個基本可用的虛擬DOM。
1.為什么需要虛擬DOM
DOM是很慢的,其元素非常龐大,頁面的性能問題鮮有由JS引起的,大部分都是由DOM操作引起的。如果對前端工作進行抽象的話,主要就是維護狀態和更新視圖;而更新視圖和維護狀態都需要DOM操作。其實近年來,前端的框架主要發展方向就是解放DOM操作的復雜性。
在jQuery出現以前,我們直接操作DOM結構,這種方法復雜度高,兼容性也較差;有了jQuery強大的選擇器以及高度封裝的API,我們可以更方便的操作DOM,jQuery幫我們處理兼容性問題,同時也使DOM操作變得簡單;但是聰明的程序員不可能滿足於此,各種MVVM框架應運而生,有angularJS、avalon、vue.js等,MVVM使用數據雙向綁定,使得我們完全不需要操作DOM了,更新了狀態視圖會自動更新,更新了視圖數據狀態也會自動更新,可以說MMVM使得前端的開發效率大幅提升,但是其大量的事件綁定使得其在復雜場景下的執行性能堪憂;有沒有一種兼顧開發效率和執行效率的方案呢?ReactJS就是一種不錯的方案,雖然其將JS代碼和HTML代碼混合在一起的設計有不少爭議,但是其引入的Virtual DOM(虛擬DOM)卻是得到大家的一致認同的。
2.理解虛擬DOM
虛擬的DOM的核心思想是:對復雜的文檔DOM結構,提供一種方便的工具,進行最小化地DOM操作。這句話,也許過於抽象,卻基本概況了虛擬DOM的設計思想
(1) 提供一種方便的工具,使得開發效率得到保證
(2) 保證最小化的DOM操作,使得執行效率得到保證
(1).用JS表示DOM結構
DOM很慢,而javascript很快,用javascript對象可以很容易地表示DOM節點。DOM節點包括標簽、屬性和子節點,通過VElement表示如下。
1 //虛擬dom,參數分別為標簽名、屬性對象、子DOM列表 2 var VElement = function(tagName, props, children) { 3 //保證只能通過如下方式調用:new VElement 4 if (!(this instanceof VElement)) { 5 return new VElement(tagName, props, children); 6 } 7 8 //可以通過只傳遞tagName和children參數 9 if (util.isArray(props)) { 10 children = props; 11 props = {}; 12 }
1 //設置虛擬dom的相關屬性 2 this.tagName = tagName; 3 this.props = props || {}; 4 this.children = children || []; 5 this.key = props ? props.key : void 666; 6 var count = 0; 7 util.each(this.children, function(child, i) { 8 if (child instanceof VElement) { 9 count += child.count; 10 } else { 11 children[i] = '' + child; 12 } 13 count++; 14 }); 15 this.count = count; 16 }
通過VElement,我們可以很簡單地用javascript表示DOM結構。比如
1 var vdom = velement('div', { 'id': 'container' }, [ 2 velement('h1', { style: 'color:red' }, ['simple virtual dom']), 3 velement('p', ['hello world']), 4 velement('ul', [velement('li', ['item #1']), velement('li', ['item #2'])]), 5 ]);
上面的javascript代碼可以表示如下DOM結構:
1 <div id="container"> 2 <h1 style="color:red">simple virtual dom</h1> 3 <p>hello world</p> 4 <ul> 5 <li>item #1</li> 6 <li>item #2</li> 7 </ul> 8 </div>
同樣我們可以很方便地根據虛擬DOM樹構建出真實的DOM樹。具體思路:根據虛擬DOM節點的屬性和子節點遞歸地構建出真實的DOM樹。見如下代碼:
1 VElement.prototype.render = function() { 2 //創建標簽 3 var el = document.createElement(this.tagName); 4 //設置標簽的屬性 5 var props = this.props; 6 for (var propName in props) { 7 var propValue = props[propName] 8 util.setAttr(el, propName, propValue); 9 } 10 11 //依次創建子節點的標簽 12 util.each(this.children, function(child) { 13 //如果子節點仍然為velement,則遞歸的創建子節點,否則直接創建文本類型節點 14 var childEl = (child instanceof VElement) ? child.render() : document.createTextNode(child); 15 el.appendChild(childEl); 16 }); 17 18 return el; 19 }
對一個虛擬的DOM對象VElement,調用其原型的render方法,就可以產生一顆真實的DOM樹。
vdom.render();
既然我們可以用JS對象表示DOM結構,那么當數據狀態發生變化而需要改變DOM結構時,我們先通過JS對象表示的虛擬DOM計算出實際DOM需要做的最小變動,然后再操作實際DOM,從而避免了粗放式的DOM操作帶來的性能問題。
(2).比較兩棵虛擬DOM樹的差異
在用JS對象表示DOM結構后,當頁面狀態發生變化而需要操作DOM時,我們可以先通過虛擬DOM計算出對真實DOM的最小修改量,然后再修改真實DOM結構(因為真實DOM的操作代價太大)。
如下圖所示,兩個虛擬DOM之間的差異已經標紅:

為了便於說明問題,我當然選取了最簡單的DOM結構,兩個簡單DOM之間的差異似乎是顯而易見的,但是真實場景下的DOM結構很復雜,我們必須借助於一個有效的DOM樹比較算法。
設計一個diff算法有兩個要點:
如何比較兩個兩棵DOM樹
如何記錄節點之間的差異
<1> 如何比較兩個兩棵DOM樹
計算兩棵樹之間差異的常規算法復雜度為O(n3),一個文檔的DOM結構有上百個節點是很正常的情況,這種復雜度無法應用於實際項目。針對前端的具體情況:我們很少跨級別的修改DOM節點,通常是修改節點的屬性、調整子節點的順序、添加子節點等。因此,我們只需要對同級別節點進行比較,避免了diff算法的復雜性。對同級別節點進行比較的常用方法是深度優先遍歷:
1 function diff(oldTree, newTree) { 2 //節點的遍歷順序 3 var index = 0; 4 //在遍歷過程中記錄節點的差異 5 var patches = {}; 6 //深度優先遍歷兩棵樹 7 dfsWalk(oldTree, newTree, index, patches); 8 return patches; 9 }
<2>如何記錄節點之間的差異
由於我們對DOM樹采取的是同級比較,因此節點之間的差異可以歸結為4種類型:
修改節點屬性, 用PROPS表示
修改節點文本內容, 用TEXT表示
替換原有節點, 用REPLACE表示
調整子節點,包括移動、刪除等,用REORDER表示
對於節點之間的差異,我們可以很方便地使用上述四種方式進行記錄,比如當舊節點被替換時:
{type:REPLACE,node:newNode}
而當舊節點的屬性被修改時:
{type:PROPS,props: newProps}
在深度優先遍歷的過程中,每個節點都有一個編號,如果對應的節點有變化,只需要把相應變化的類別記錄下來即可。下面是具體實現:
1 function dfsWalk(oldNode, newNode, index, patches) { 2 var currentPatch = []; 3 if (newNode === null) { 4 //依賴listdiff算法進行標記為刪除 5 } else if (util.isString(oldNode) && util.isString(newNode)) { 6 if (oldNode !== newNode) { 7 //如果是文本節點則直接替換文本 8 currentPatch.push({ 9 type: patch.TEXT, 10 content: newNode 11 }); 12 } 13 } else if (oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) { 14 //節點類型相同 15 //比較節點的屬性是否相同 16 var propsPatches = diffProps(oldNode, newNode); 17 if (propsPatches) { 18 currentPatch.push({ 19 type: patch.PROPS, 20 props: propsPatches 21 }); 22 } 23 //比較子節點是否相同 24 diffChildren(oldNode.children, newNode.children, index, patches, currentPatch); 25 } else { 26 //節點的類型不同,直接替換 27 currentPatch.push({ type: patch.REPLACE, node: newNode }); 28 } 29 30 if (currentPatch.length) { 31 patches[index] = currentPatch; 32 } 33 }
比如對上文圖中的兩顆虛擬DOM樹,可以用如下數據結構記錄它們之間的變化:
1 var patches = { 2 1:{type:REPLACE,node:newNode}, //h1節點變成h5 3 5:{type:REORDER,moves:changObj} //ul新增了子節點li 4 }
(3).對真實DOM進行最小化修改
通過虛擬DOM計算出兩顆真實DOM樹之間的差異后,我們就可以修改真實的DOM結構了。上文深度優先遍歷過程產生了用於記錄兩棵樹之間差異的數據結構patches, 通過使用patches我們可以方便對真實DOM做最小化的修改。
1 //將差異應用到真實DOM 2 function applyPatches(node, currentPatches) { 3 util.each(currentPatches, function(currentPatch) { 4 switch (currentPatch.type) { 5 //當修改類型為REPLACE時 6 case REPLACE: 7 var newNode = (typeof currentPatch.node === 'String') 8 ? document.createTextNode(currentPatch.node) 9 : currentPatch.node.render(); 10 node.parentNode.replaceChild(newNode, node); 11 break; 12 //當修改類型為REORDER時 13 case REORDER: 14 reoderChildren(node, currentPatch.moves); 15 break; 16 //當修改類型為PROPS時 17 case PROPS: 18 setProps(node, currentPatch.props); 19 break; 20 //當修改類型為TEXT時 21 case TEXT: 22 if (node.textContent) { 23 node.textContent = currentPatch.content; 24 } else { 25 node.nodeValue = currentPatch.content; 26 } 27 break; 28 default: 29 throw new Error('Unknow patch type ' + currentPatch.type); 30 } 31 }); 32 }
