虚拟DOM详解
React為啥這么大?因為它實現(xiàn)了一個虛擬DOM(Virtual DOM)。虛擬DOM是干什么的?這就要從瀏覽器本身講起。
如我們所知,在瀏覽器渲染網(wǎng)頁的過程中,加載到HTML文檔后,會將文檔解析并構(gòu)建DOM樹,然后將其與解析CSS生成的CSSOM樹一起結(jié)合產(chǎn)生愛的結(jié)晶——RenderObject樹,然后將RenderObject樹渲染成頁面(當(dāng)然中間可能會有一些優(yōu)化,比如RenderLayer樹)。這些過程都存在與渲染引擎之中,渲染引擎在瀏覽器中是于JavaScript引擎(JavaScriptCore也好V8也好)分離開的,但為了方便JS操作DOM結(jié)構(gòu),渲染引擎會暴露一些接口供JavaScript調(diào)用。
由于這兩塊相互分離,通信是需要付出代價的,因此JavaScript調(diào)用DOM提供的接口性能不咋地。各種性能優(yōu)化的最佳實踐也都在盡可能的減少DOM操作次數(shù)。
而虛擬DOM干了什么?它直接用JavaScript實現(xiàn)了DOM樹(大致上)。組件的HTML結(jié)構(gòu)并不會直接生成DOM,而是映射生成虛擬的JavaScript DOM結(jié)構(gòu),React又通過在這個虛擬DOM上實現(xiàn)了一個 diff 算法找出最小變更,再把這些變更寫入實際的DOM中。這個虛擬DOM以JS結(jié)構(gòu)的形式存在,計算性能會比較好,而且由于減少了實際DOM操作次數(shù),性能會有較大提升。
?
- 為什么需要虛擬DOM
- 實現(xiàn)虛擬DOM
- Diff算法
- 映射成真實DOM
?
為什么需要虛擬DOM
先介紹瀏覽器加載一個HTML文件需要做哪些事,幫助我們理解為什么我們需要虛擬DOM。webkit引擎的處理流程,一圖勝千言:
?
所有瀏覽器的引擎工作流程都差不多,如上圖大致分5步:創(chuàng)建DOM tree –> 創(chuàng)建Style Rules -> 構(gòu)建Render tree -> 布局Layout –> 繪制Painting
?
第一步,用HTML分析器,分析HTML元素,構(gòu)建一顆DOM樹。
第二步:用CSS分析器,分析CSS文件和元素上的inline樣式,生成頁面的樣式表。
第三步:將上面的DOM樹和樣式表,關(guān)聯(lián)起來,構(gòu)建一顆Render樹。這一過程又稱為Attachment。每個DOM節(jié)點都有attach方法,接受樣式信息,返回一個render對象(又名renderer)。這些render對象最終會被構(gòu)建成一顆Render樹。
第四步:有了Render樹后,瀏覽器開始布局,會為每個Render樹上的節(jié)點確定一個在顯示屏上出現(xiàn)的精確坐標(biāo)值。
第五步:Render數(shù)有了,節(jié)點顯示的位置坐標(biāo)也有了,最后就是調(diào)用每個節(jié)點的paint方法,讓它們顯示出來。
當(dāng)你用傳統(tǒng)的源生api或jQuery去操作DOM時,瀏覽器會從構(gòu)建DOM樹開始從頭到尾執(zhí)行一遍流程。比如當(dāng)你在一次操作時,需要更新10個DOM節(jié)點,理想狀態(tài)是一次性構(gòu)建完DOM樹,再執(zhí)行后續(xù)操作。但瀏覽器沒這么智能,收到第一個更新DOM請求后,并不知道后續(xù)還有9次更新操作,因此會馬上執(zhí)行流程,最終執(zhí)行10次流程。顯然例如計算DOM節(jié)點的坐標(biāo)值等都是白白浪費性能,可能這次計算完,緊接著的下一個DOM更新請求,這個節(jié)點的坐標(biāo)值就變了,前面的一次計算是無用功。
即使計算機硬件一直在更新迭代,操作DOM的代價仍舊是昂貴的,頻繁操作還是會出現(xiàn)頁面卡頓,影響用戶的體驗。真實的DOM節(jié)點,哪怕一個最簡單的div也包含著很多屬性,可以打印出來直觀感受一下:
?
虛擬DOM就是為了解決這個瀏覽器性能問題而被設(shè)計出來的。例如前面的例子,假如一次操作中有10次更新DOM的動作,虛擬DOM不會立即操作DOM,而是將這10次更新的diff內(nèi)容保存到本地的一個js對象中,最終將這個js對象一次性attach到DOM樹上,通知瀏覽器去執(zhí)行繪制工作,這樣可以避免大量的無謂的計算量。
?
實現(xiàn)虛擬DOM
我們來實現(xiàn)一個虛擬DOM。例如一個真實的DOM節(jié)點:
<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節(jié)點如下:
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節(jié)點的好處是,頁面的更新可以先全部反映在js對象上,操作內(nèi)存中的js對象的速度顯然要快多了。等更新完后,再將最終的js對象映射成真實的DOM,交由瀏覽器去繪制。
那具體怎么實現(xiàn)呢?看一下Element方法的具體實現(xiàn):
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;
}
第一個參數(shù)是節(jié)點名(如div),第二個參數(shù)是節(jié)點的屬性(如class),第三個參數(shù)是子節(jié)點(如ul的li)。除了這三個參數(shù)會被保存在對象上外,還保存了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;
};
上面都是自解釋代碼,根據(jù)DOM名調(diào)用源生的createElement創(chuàng)建真實DOM,將DOM的屬性全都加到這個DOM元素上,如果有子元素繼續(xù)遞歸調(diào)用創(chuàng)建子元素,并appendChild掛到該DOM元素上。這樣就完成了從創(chuàng)建虛擬DOM到將其映射成真實DOM的全部工作。
?
Diff算法
我們已經(jīng)完成了創(chuàng)建虛擬DOM并將其映射成真實DOM的工作,這樣所有的更新都可以先反映到虛擬DOM上,如何反映呢?需要明確一下Diff算法。
兩棵樹如果完全比較時間復(fù)雜度是O(n^3),但參照《深入淺出React和Redux》一書中的介紹,React的Diff算法的時間復(fù)雜度是O(n)。要實現(xiàn)這么低的時間復(fù)雜度,意味著只能平層地比較兩棵樹的節(jié)點,放棄了深度遍歷。這樣做,似乎犧牲了一定的精確性來換取速度,但考慮到現(xiàn)實中前端頁面通常也不會跨層級移動DOM元素,所以這樣做是最優(yōu)的。
我們新創(chuàng)建一棵樹,用于和之前的樹進行比較.
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種情況:
第一種是最簡單的,節(jié)點類型變了,例如下圖中的P變成了h3。我們將這個過程稱之為REPLACE。直接將舊節(jié)點卸載(componentWillUnmount)并裝載新節(jié)點(componentWillMount)就行了。
?
(為簡單起見上圖隱藏了文本節(jié)點)
舊節(jié)點包括下面的子節(jié)點都將被卸載,如果新節(jié)點和舊節(jié)點僅僅是類型不同,但下面的所有子節(jié)點都一樣時,這樣做顯得效率不高。但為了避免O(n^3)的時間復(fù)雜度,這樣做是值得的。這也提醒了React開發(fā)者,應(yīng)該避免無謂的節(jié)點類型的變化,例如運行時將div變成p就沒什么太大意義。
第二種也比較簡單,節(jié)點類型一樣,僅僅屬性或?qū)傩灾底兞恕?/span>
renderA: <ul>
renderB: <ul class: 'marginLeft10'>
=> [addAttribute class "marginLeft10"]
我們將這個過程稱之為PROPS。此時不會觸發(fā)節(jié)點的卸載(componentWillUnmount)和裝載(componentWillMount)動作。而是執(zhí)行節(jié)點更新(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,也比較簡單,直接修改文字內(nèi)容就行了,我們將這個過程稱之為TEXT。
第四種是移動,增加,刪除子節(jié)點,我們將這個過程稱之為REORDER。具體可以看這篇虛擬DOM Diff算法解析。例如:
?
在中間插入一個節(jié)點,程序員寫代碼很簡單:$(B).after(F)。但如何高效地插入呢?簡單粗暴的做法是:卸載C,裝載F,卸載D,裝載C,卸載E,裝載D,裝載E。如下圖:
?
我們寫JSX代碼時,如果沒有給數(shù)組或枚舉類型定義一個key,就會看到下面這樣的warning。React提醒我們,沒有key的話,涉及到移動,增加,刪除子節(jié)點的操作時,就會用上面那種簡單粗暴的做法來更新。雖然程序運行不會有錯,但效率太低,因此React會給我們一個warning。
如果我們在JSX里為數(shù)組或枚舉型元素增加上key后,React就能根據(jù)key,直接找到具體的位置進行操作,效率比較高。如下圖:
?
常見的最小編輯距離問題,可以用Levenshtein Distance算法來實現(xiàn),時間復(fù)雜度是O(M*N),但通常我們只要一些簡單的移動就能滿足需要,降低點精確性,將時間復(fù)雜度降低到O(max(M, N)即可。具體可參照采用深度剖析:如何實現(xiàn)一個 Virtual DOM 算法里的一個算法一文。或自行閱讀例子中的源代碼
最終Diff出來的結(jié)果如下:
{
??? 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也有了,現(xiàn)在就可以將Diff應(yīng)用到真實DOM上了。深度遍歷DOM將Diff的內(nèi)容更新進去:
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);
??? }
}
具體更新的代碼如下,其實就是根據(jù)Diff信息調(diào)用源生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的目的是將所有操作累加起來,統(tǒng)計計算出所有的變化后,統(tǒng)一更新一次DOM。其實即使不懂原理,業(yè)務(wù)代碼照樣寫,但理解原理后,出了什么新東東如React Fiber才能快速跟上。
?
?
轉(zhuǎn)載于:https://www.cnblogs.com/ranyonsue/p/9831434.html
總結(jié)
- 上一篇: 专访Docker大牛:说服传统应用程序使
- 下一篇: 世界杯足球竞赛源码下载