近一年業務項目中都在使用React框架,也出現了一些以前沒怎么關注過的新概念,例如虛擬DOM。虛擬DOM本身不是什么新鮮事物,網上隨便一搜,早在2015年就有人詳細介紹過了,但我只知道它速度快,效率高,對原理一無所知,最近抽空學習了一下。
參考資料如下:
深度剖析:如何實現一個 Virtual DOM 算法(作者:戴嘉華)
Why Virtual DOM(作者:Sai Kishore Komanduri)
- 為什么需要虛擬DOM
- 實現虛擬DOM
- Diff算法
- 映射成真實DOM
為什么需要虛擬DOM
先介紹瀏覽器加載一個HTML文件需要做哪些事,幫助我們理解為什么我們需要虛擬DOM。webkit引擎的處理流程,一圖勝千言:
所有瀏覽器的引擎工作流程都差不多,如上圖大致分5步:創建DOM tree –> 創建Style Rules -> 構建Render tree -> 布局Layout –> 繪制Painting
第一步,用HTML分析器,分析HTML元素,構建一顆DOM樹。
第二步:用CSS分析器,分析CSS文件和元素上的inline樣式,生成頁面的樣式表。
第三步:將上面的DOM樹和樣式表,關聯起來,構建一顆Render樹。這一過程又稱為Attachment。每個DOM節點都有attach方法,接受樣式信息,返回一個render對象(又名renderer)。這些render對象最終會被構建成一顆Render樹。
第四步:有了Render樹后,瀏覽器開始布局,會為每個Render樹上的節點確定一個在顯示屏上出現的精確坐標值。
第五步:Render數有了,節點顯示的位置坐標也有了,最后就是調用每個節點的paint方法,讓它們顯示出來。
當你用傳統的源生api或jQuery去操作DOM時,瀏覽器會從構建DOM樹開始從頭到尾執行一遍流程。比如當你在一次操作時,需要更新10個DOM節點,理想狀態是一次性構建完DOM樹,再執行后續操作。但瀏覽器沒這么智能,收到第一個更新DOM請求后,并不知道后續還有9次更新操作,因此會馬上執行流程,最終執行10次流程。顯然例如計算DOM節點的坐標值等都是白白浪費性能,可能這次計算完,緊接著的下一個DOM更新請求,這個節點的坐標值就變了,前面的一次計算是無用功。
即使計算機硬件一直在更新迭代,操作DOM的代價仍舊是昂貴的,頻繁操作還是會出現頁面卡頓,影響用戶的體驗。真實的DOM節點,哪怕一個最簡單的div也包含著很多屬性,可以打印出來直觀感受一下:
虛擬DOM就是為了解決這個瀏覽器性能問題而被設計出來的。例如前面的例子,假如一次操作中有10次更新DOM的動作,虛擬DOM不會立即操作DOM,而是將這10次更新的diff內容保存到本地的一個js對象中,最終將這個js對象一次性attach到DOM樹上,通知瀏覽器去執行繪制工作,這樣可以避免大量的無謂的計算量。
實現虛擬DOM
我們來實現一個虛擬DOM。例如一個真實的DOM節點:代碼見倉庫里的src/firstStep
<div id="real-container">
<p>Real DOM</p>
<div>cannot update</div>
<ul>
<li className="item">Item 1</li>
<li className="item">Item 2</li>
<li className="item">Item 3</li>
</ul>
</div>
用js對象來模擬DOM節點如下:
const tree = Element('div', { id: 'virtual-container' }, [
Element('p', {}, ['Virtual DOM']),
Element('div', {}, ['before update']),
Element('ul', {}, [
Element('li', { class: 'item' }, ['Item 1']),
Element('li', { class: 'item' }, ['Item 2']),
Element('li', { class: 'item' }, ['Item 3']),
]),
]);
const root = tree.render();
document.getElementById('virtualDom').appendChild(root);
用js對象模擬DOM節點的好處是,頁面的更新可以先全部反映在js對象上,操作內存中的js對象的速度顯然要快多了。等更新完后,再將最終的js對象映射成真實的DOM,交由瀏覽器去繪制。
那具體怎么實現呢?看一下Element方法的具體實現:
function Element(tagName, props, children) {
if (!(this instanceof Element)) {
return new Element(tagName, props, children);
}
this.tagName = tagName;
this.props = props || {};
this.children = children || [];
this.key = props ? props.key : undefined;
let count = 0;
this.children.forEach((child) => {
if (child instanceof Element) {
count += child.count;
}
count++;
});
this.count = count;
}
第一個參數是節點名(如div),第二個參數是節點的屬性(如class),第三個參數是子節點(如ul的li)。除了這三個參數會被保存在對象上外,還保存了key和count。
有了js對象后,最終還需要將其映射成真實的DOM:
Element.prototype.render = function() {
const el = document.createElement(this.tagName);
const props = this.props;
for (const propName in props) {
setAttr(el, propName, props[propName]);
}
this.children.forEach((child) => {
const childEl = (child instanceof Element) ? child.render() : document.createTextNode(child);
el.appendChild(childEl);
});
return el;
};
上面都是自解釋代碼,根據DOM名調用源生的createElement創建真實DOM,將DOM的屬性全都加到這個DOM元素上,如果有子元素繼續遞歸調用創建子元素,并appendChild掛到該DOM元素上。這樣就完成了從創建虛擬DOM到將其映射成真實DOM的全部工作。
Diff算法
我們已經完成了創建虛擬DOM并將其映射成真實DOM的工作,這樣所有的更新都可以先反映到虛擬DOM上,如何反映呢?需要明確一下Diff算法。
兩棵樹如果完全比較時間復雜度是O(n^3),但參照《深入淺出React和Redux》一書中的介紹,React的Diff算法的時間復雜度是O(n)。要實現這么低的時間復雜度,意味著只能平層地比較兩棵樹的節點,放棄了深度遍歷。這樣做,似乎犧牲了一定的精確性來換取速度,但考慮到現實中前端頁面通常也不會跨層級移動DOM元素,所以這樣做是最優的。
我們新創建一棵樹,用于和之前的樹進行比較,代碼見倉庫里的src/secondStep:
const newTree = Element('div', { id: 'virtual-container' }, [
Element('h3', {}, ['Virtual DOM']), // REPLACE
Element('div', {}, ['after update']), // TEXT
Element('ul', { class: 'marginLeft10' }, [ // PROPS
Element('li', { class: 'item' }, ['Item 1']),
// Element('li', { class: 'item' }, ['Item 2']), // REORDER remove
Element('li', { class: 'item' }, ['Item 3']),
]),
]);
只考慮平層地Diff的話,就簡單多了,只需要考慮以下4種情況:
第一種是最簡單的,節點類型變了,例如下圖中的P變成了h3。我們將這個過程稱之為REPLACE。直接將舊節點卸載(componentWillUnmount)并裝載新節點(componentWillMount)就行了。
(為簡單起見上圖隱藏了文本節點)
舊節點包括下面的子節點都將被卸載,如果新節點和舊節點僅僅是類型不同,但下面的所有子節點都一樣時,這樣做顯得效率不高。但為了避免O(n^3)的時間復雜度,這樣做是值得的。這也提醒了React開發者,應該避免無謂的節點類型的變化,例如運行時將div變成p就沒什么太大意義。
第二種也比較簡單,節點類型一樣,僅僅屬性或屬性值變了。
renderA: <ul>
renderB: <ul class: 'marginLeft10'>
=> [addAttribute class "marginLeft10"]
我們將這個過程稱之為PROPS。此時不會觸發節點的卸載(componentWillUnmount)和裝載(componentWillMount)動作。而是執行節點更新(shouldComponentUpdate到componentDidUpdate的一系列方法)。
function diffProps(oldNode, newNode) {
const oldProps = oldNode.props;
const newProps = newNode.props;
let key;
const propsPatches = {};
let isSame = true;
// find out different props
for (key in oldProps) {
if (newProps[key] !== oldProps[key]) {
isSame = false;
propsPatches[key] = newProps[key];
}
}
// find out new props
for (key in newProps) {
if (!oldProps.hasOwnProperty(key)) {
isSame = false;
propsPatches[key] = newProps[key];
}
}
return isSame ? null : propsPatches;
}
第三種是文本變了,文本對也是一個Text Node,也比較簡單,直接修改文字內容就行了,我們將這個過程稱之為TEXT。
第四種是移動,增加,刪除子節點,我們將這個過程稱之為REORDER。具體可以看這篇虛擬DOM Diff算法解析。例如:
如果我們在JSX里為數組或枚舉型元素增加上key后,React就能根據key,直接找到具體的位置進行操作,效率比較高。如下圖:
最終Diff出來的結果如下:
{
1: [ {type: REPLACE, node: Element} ],
4: [ {type: TEXT, content: "after update"} ],
5: [ {type: PROPS, props: {class: "marginLeft10"}}, {type: REORDER, moves: [{index: 2, type: 0}]} ],
6: [ {type: REORDER, moves: [{index: 2, type: 0}]} ],
8: [ {type: REORDER, moves: [{index: 2, type: 0}]} ],
9: [ {type: TEXT, content: "Item 3"} ],
}
映射成真實DOM
虛擬DOM有了,Diff也有了,現在就可以將Diff應用到真實DOM上了。代碼見倉庫里的src/thirdStep
深度遍歷DOM將Diff的內容更新進去:
function dfsWalk(node, walker, patches) {
const currentPatches = patches[walker.index];
const len = node.childNodes ? node.childNodes.length : 0;
for (let i = 0; i < len; i++) {
walker.index++;
dfsWalk(node.childNodes[i], walker, patches);
}
if (currentPatches) {
applyPatches(node, currentPatches);
}
}
具體更新的代碼如下,其實就是根據Diff信息調用源生API操作DOM:
function applyPatches(node, currentPatches) {
currentPatches.forEach((currentPatch) => {
switch (currentPatch.type) {
case REPLACE: {
const newNode = (typeof currentPatch.node === 'string')
? document.createTextNode(currentPatch.node)
: currentPatch.node.render();
node.parentNode.replaceChild(newNode, node);
break;
}
case REORDER:
reorderChildren(node, currentPatch.moves);
break;
case PROPS:
setProps(node, currentPatch.props);
break;
case TEXT:
if (node.textContent) {
node.textContent = currentPatch.content;
} else {
// ie
node.nodeValue = currentPatch.content;
}
break;
default:
throw new Error(`Unknown patch type ${currentPatch.type}`);
}
});
}
虛擬DOM的目的是將所有操作累加起來,統計計算出所有的變化后,統一更新一次DOM。其實即使不懂原理,業務代碼照樣寫,但理解原理后,出了什么新東東如React Fiber才能快速跟上。前端就是這樣痛并快樂著。