參考文章:
深度剖析:如何實現一個Virtual DOM 算法 作者:戴嘉華
1
天下大勢,分久必合,合久必分。
在jQuery一統(tǒng)天下很多年后,前端界進入了三大巨頭,群雄割據,遍地是輪子的紛爭局面。
前不久,國內前端社區(qū)里剛剛結束一場關于 Angular 和 Vue 的大戰(zhàn)。雖然的確大大推廣了這兩個框架,但讓人覺得前端的技術氛圍很浮躁,被非前端的同學們看了笑話,不得不感嘆:貴圈真亂
其實我覺得,技術不該是這樣的,框架的誕生是為了解決問題,各有各的特點和適用場景,不能單純的論好壞,所謂“不談適用場景的技術選型都是耍流氓!”
就像大牛們所說的,無論什么框架,只要盯住一個深挖,總是能有所收獲的。一定要有深度。盯住一只羊死薅,總是可以薅成葛優(yōu)的。
所以今天我要分享的就是 React VirtualDOM 算法的實現和它的升級版,React Fiber。
首先看一個問題,為什么要使用框架?用原生JS不行嗎?
為什么使用框架?
-
模塊化
一個抽象,一次構建,多處復用的能力
當你有一套完畢組件庫的時候,開發(fā)業(yè)務就像在搭積木
-
數據綁定,事件驅動
在各種粒度上真正實現事件驅動,因為這樣我們就不用自己重復手寫本質上并不依賴場景的從視圖到數據從數據到視圖的自動更新,否則我們就得自己操作DOM,優(yōu)化DOM操作,還要自己維護狀態(tài),一自己維護狀態(tài),就要陷入狀態(tài)同步的漩渦,浪費大量時間和精力
簡單的頁面,沒什么問題
可是當應用越來越復雜,調試的時候我跟你講我就這個表情
-
跨平臺
隱藏掉平臺的微妙差異,寫一段代碼可以真正實現跨平臺,而不用我們自己糾結于那些本不該應用開發(fā)糾結的事,我們需要持續(xù)穩(wěn)定的跨平臺支持,最好的移植就是不用移植,這在商業(yè)上有很大的價值。
優(yōu)良性能
我們希望技術棧有非常好的性能,性能的水平和垂直擴展性都很好,這樣我們就不用項目寫到一半回頭去糾結應用開發(fā)團隊很難解決的性能問題,我們需要在快速開發(fā)和基礎性能之間平衡得很好的工具,而不是因為要強調某一方面而對另一方面關注太少的那些工具開發(fā)快速
關于 Virtual DOM
每次提到 React,坊間就會流傳關于 Virtual DOM 的傳說。
然而,今天我們就來了解一下 Virtual DOM 到底是個什么東西,并順便完成一個簡單的實現。
VirtualDOM 強調的是上面的優(yōu)良性能
首先我們需要了解一下瀏覽器繪制HTML的原理。
瀏覽器工作流
NOTE:在下面這張圖中,配圖文字使用的是Webkit引擎的術語。所有的瀏覽器都是遵循類似的工作流,僅在細節(jié)處略有不同。
創(chuàng)建DOM樹
- 一旦瀏覽器接收到一個HTML文件,渲染引擎(render engine)就開始解析它,并根據HTML元素(elements)一一對應地生成DOM 節(jié)點(nodes),組成一棵DOM樹。
創(chuàng)建渲染樹
- 同時,瀏覽器也會解析來自外部CSS文件和元素上的inline樣式。通常瀏覽器會為這些樣式信息,連同包含樣式信息的DOM樹上的節(jié)點,再創(chuàng)建另外一個樹,一般被稱作渲染樹(render tree)
創(chuàng)建渲染樹背后的故事
WebKit內核的瀏覽器上,處理一個節(jié)點的樣式的過程稱為attachment。DOM樹上的每個節(jié)點都有一個attach方法,它接收計算好的樣式信息,返回一個render對象(又名renderer)
Attachment的過程是同步的,新節(jié)點插入DOM樹時,會調用新節(jié)點的attach方法。
構建渲染樹時,由于包含了這些render對象,每個render對象都需要計算視覺屬性(visual properties);這個過程通過計算每個元素的樣式屬性來完成。
布局 Layout
又被簡稱為Reflow(Webkit 里使用layout表示元素的布局,Gecko則稱為Reflow)
- 構造了渲染樹以后,瀏覽器引擎開始著手布局(layout)。布局時,渲染樹上的每個節(jié)點根據其在屏幕上應該出現的精確位置,分配一組屏幕坐標值。
繪制 Painting
- 接著,瀏覽器將會通過遍歷渲染樹,調用每個節(jié)點的paint方法來繪制這些render對象。paint方法根據瀏覽器平臺,使用不同的UI后端API(agnostic UI backend API)。 通過繪制,最終將在屏幕上展示內容。
再來看Virtual DOM
好啦,現在你已經簡單過了一遍瀏覽器引擎的渲染流程,你可以看到,從創(chuàng)建渲染樹,到布局,一直到繪制,只要你在這過程中進行一次DOM更新,整個渲染流程都會重做一遍。尤其是創(chuàng)建渲染樹,它需要重新計算所有元素上的所有樣式。
在一個復雜的單頁面應用中,經常會涉及到大量的DOM操作,這將引起多次計算,使得整個流程變得低效,這應該盡量避免。
以下是一個jQuery的例子,每次操作DOM,都會觸發(fā)重新渲染,在同一個事件里的DOM操作并不會累積進行
function changeToLogUp() {
$('#slickNewUser').css('visibility', 'visible');
$('#logInSection').css('display', 'none');
$('#logUpSection').css('display', 'block');
$('#logUpRightNowWrapper').css('display', 'none');
$('#logInRightNowWrapper').css('display', 'block');
$('#resetPasswordSection').css('display', 'none');
$('#bottomBtnRowWrapper').css('display', 'none');
}
DOM 操作 真正的問題在于每次操作都會觸發(fā)布局的改變、DOM樹的修改和渲染。所以,當你一個接一個地去修改30個節(jié)點的時候,就會引起30次(潛在的)布局重算,30次(潛在的)重繪,等等。
這就是傳統(tǒng)jQuery開發(fā)面臨的性能問題,現在我們來解決它。
Virtual DOM 算法
DOM是很慢的。如果我們把一個簡單的div元素的屬性都打印出來,你會看到:
而這僅僅是第一層。真正的 DOM 元素非常龐大,這是因為標準就是這么設計的。而且操作它們的時候你要小心翼翼,輕微的觸碰可能就會導致頁面重排,這可是殺死性能的罪魁禍首。
相對于 DOM 對象,原生的 JavaScript 對象處理起來更快,而且更簡單。DOM 樹上的結構、屬性信息我們都可以很容易地用 JavaScript 對象表示出來:
var element = {
tagName: 'ul', // 節(jié)點標簽名
props: { // DOM的屬性,用一個對象存儲鍵值對
id: 'list'
},
children: [ // 該節(jié)點的子節(jié)點
{tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
{tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
{tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
]
}
上面對應的HTML寫法是:
<ul id='list'>
<li class='item'>Item 1</li>
<li class='item'>Item 2</li>
<li class='item'>Item 3</li>
</ul>
既然原來 DOM 樹的信息都可以用 JavaScript 對象來表示,反過來,你就可以根據這個用 JavaScript 對象表示的樹結構來構建一棵真正的DOM樹。
之前的章節(jié)所說的,狀態(tài)變更->重新渲染整個視圖的方式可以稍微修改一下:用 JavaScript 對象表示 DOM 信息和結構,當狀態(tài)變更的時候,重新渲染這個 JavaScript 的對象結構。當然這樣做其實沒什么卵用,因為真正的頁面其實沒有改變。
但是可以用新渲染的對象樹去和舊的樹進行對比,記錄這兩棵樹差異。記錄下來的不同就是我們需要對頁面真正的 DOM 操作,然后把它們應用在真正的 DOM 樹上,頁面就變更了。這樣就可以做到:視圖的結構確實是整個全新渲染了,但是最后操作DOM的時候確實只變更有不同的地方。
這就是所謂的 Virtual DOM 算法。包括幾個步驟:
- 用 JavaScript 對象結構表示 DOM 樹的結構;然后用這個樹構建一個真正的 DOM 樹,插到文檔當中
- 當狀態(tài)變更的時候,重新構造一棵新的對象樹。然后用新的樹和舊的樹進行比較,記錄兩棵樹差異
- 把2所記錄的差異應用到步驟1所構建的真正的DOM樹上,視圖就更新了
Virtual DOM 本質上就是在 JS 和 DOM 之間做了一個緩存。可以類比 CPU 和硬盤,既然硬盤這么慢,我們就在它們之間加個緩存:既然 DOM 這么慢,我們就在它們 JS 和 DOM 之間加個緩存。CPU(JS)只操作內存(Virtual DOM),最后的時候再把變更寫入硬盤(DOM)。
Virtual DOM 實際上沒有使用什么全新的技術,僅僅是把 “ 雙緩沖(double buffering)” 技術應用到了DOM上面。 這樣一來,當你在這個單獨的虛擬的DOM樹上也一個接一個地修改30個節(jié)點的時候,它不會每次都去觸發(fā)重繪,所以修改節(jié)點的開銷就變小了。 之后,一旦你要把這些改動傳遞給真實DOM,之前所有的改動就會整合成一次DOM操作。這一次DOM操作引起的布局計算和重繪可能會更大,但是相比而言,整合起來的改動只做一次,減少了(多次)計算。
不過,實際上不借助Virtual DOM也可以做到這一點。你可以自己手動地整合所有的DOM操作到一個DOM 碎片(DOM fragment) 里,然后再傳遞給DOM樹。
既然如此,我們再來看看Virtual DOM到底解決了什么問題。 首先,它把管理DOM碎片這件事情自動化、抽象化了,使得你無需再去手動處理。另外,當你要手動去做這件事情的時候,你還得記得哪些部分變化了,哪些部分沒變,畢竟之后重繪時,DOM樹上的大量細節(jié)你都不需要重新刷新。這時候Virtual DOM的自動化對你來說就非常有用了,如果它的實現是正確的,那么它就會知道到底哪些地方應該需要刷新,哪些地方不要。
最后,Virtual DOM通過各種組件和你寫的一些代碼來請求對它進行操作,而不是直接對它本身進行操作,使你不必非要跟Virtual DOM交互,也不必非要去了解Virtual DOM修改DOM樹的原理,也就不用再想著去修改DOM了。(譯注:對開發(fā)者來說,Virtual DOM幾乎是完全透明的)。這樣你就不用在 修改DOM 和 整合DOM操作為一次 之間做同步處理了。
-
React
以下是一個React的例子,在大部分React的事件里,是不會去直接操作DOM的,而是通過操作數據,React監(jiān)聽數據變化,由
公式 UI = render(data) 或者 View = F(data) 來渲染出頁面;這里可以看到它是將所有操作累加起來,最后統(tǒng)計出所有的變化統(tǒng)一更新一次DOM
handleGetAuthCode2() { if (onClick) { onClick(); this.setState({ isClick: true, }); } if (!mobile) { this.setState({ errorTips: '手機號不能為空', }); } if (!verifyMobileNumber(mobile)) { this.setState({ errorTips: '手機號格式不正確', }); } getAuthCode(mobile); // 更新時間戳 this.setState({ validatecodeTimeStamp: new Date().getTime(), }); }
算法實現
步驟一:用JS對象模擬DOM樹
用 JavaScript 來表示一個 DOM 節(jié)點是很簡單的事情,你只需要記錄它的節(jié)點類型、屬性,還有子節(jié)點:
/**
* 通過 JS 對象來表示 DOM
* @param {*} tagName 標簽名
* @param {*} props 屬性
* @param {*} children 子節(jié)點
*/
function Element(tagName, props, children) {
this.tagName = tagName;
this.props = props;
this.children = children;
}
module.exports = function (tagName, props, children) {
return new Element(tagName, props, children);
}
例如上面的 DOM 結構就可以簡單的表示:
var el = require('./element')
var ul = el('ul', {id: 'list'}, [
el('li', {class: 'item'}, ['Item 1']),
el('li', {class: 'item'}, ['Item 2']),
el('li', {class: 'item'}, ['Item 3'])
])
現在ul只是一個 JavaScript 對象表示的 DOM 結構,頁面上并沒有這個結構。我們可以根據這個ul構建真正的<ul>
Element.prototype.render = function () {
var el = document.createElement(this.tagName) // 根據tagName構建
var props = this.props
for (var propName in props) { // 設置節(jié)點的DOM屬性
var propValue = props[propName]
el.setAttribute(propName, propValue)
}
var children = this.children || []
children.forEach(function (child) {
var childEl = (child instanceof Element)
? child.render() // 如果子節(jié)點也是虛擬DOM,遞歸構建DOM節(jié)點
: document.createTextNode(child) // 如果字符串,只構建文本節(jié)點
el.appendChild(childEl)
})
return el
}
render方法會根據tagName構建一個真正的DOM節(jié)點,然后設置這個節(jié)點的屬性,最后遞歸地把自己的子節(jié)點也構建起來。所以只需要:
var ulRoot = ul.render()
document.body.appendChild(ulRoot)
上面的ulRoot是真正的DOM節(jié)點,把它塞入文檔中,這樣body里面就有了真正的<ul>的DOM結構:
<ul id='list'>
<li class='item'>Item 1</li>
<li class='item'>Item 2</li>
<li class='item'>Item 3</li>
</ul>
步驟二:比較兩棵虛擬DOM樹的差異
正如你所預料的,比較兩棵DOM樹的差異是 Virtual DOM 算法最核心的部分,這也是所謂的 Virtual DOM 的 diff 算法。
什么是DOM Diff算法
Web界面由DOM樹來構成,當其中某一部分發(fā)生變化時,其實就是對應的某個DOM節(jié)點發(fā)生了變化。在React中,構建UI界面的思路是由當前狀態(tài)決定界面。前后兩個狀態(tài)就對應兩套界面,然后由React來比較兩個界面的區(qū)別,這就需要對DOM樹進行Diff算法分析。
即給定任意兩棵樹,找到最少的轉換步驟。但是標準的的Diff算法復雜度需要O(n^3),這顯然無法滿足性能要求。要達到每次界面都可以整體刷新界面的目的,勢必需要對算法進行優(yōu)化。這看上去非常有難度,然而Facebook工程師卻做到了,他們結合Web界面的特點做出了兩個簡單的假設,使得Diff算法復雜度直接降低到O(n)
- 兩個相同組件產生類似的DOM結構,不同的組件產生不同的DOM結構;
- 對于同一層次的一組子節(jié)點,它們可以通過唯一的id進行區(qū)分。
算法上的優(yōu)化是React整個界面Render的基礎,事實也證明這兩個假設是合理而精確的,保證了整體界面構建的性能。
不同節(jié)點類型的比較
為了在樹之間進行比較,我們首先要能夠比較兩個節(jié)點,在React中即比較兩個虛擬DOM節(jié)點,當兩個節(jié)點不同時,應該如何處理。這分為兩種情況:
(1)節(jié)點類型不同
(2)節(jié)點類型相同,但是屬性不同。本節(jié)先看第一種情況。
當在樹中的同一位置前后輸出了不同類型的節(jié)點,React直接刪除前面的節(jié)點,然后創(chuàng)建并插入新的節(jié)點。假設我們在樹的同一位置前后兩次輸出不同類型的節(jié)點。
renderA: <div />
renderB: <span />
=> [removeNode <div />], [insertNode <span />]
當一個節(jié)點從div變成span時,簡單的直接刪除div節(jié)點,并插入一個新的span節(jié)點。這符合我們對真實DOM操作的理解。
需要注意的是,刪除節(jié)點意味著徹底銷毀該節(jié)點,而不是再后續(xù)的比較中再去看是否有另外一個節(jié)點等同于該刪除的節(jié)點。如果該刪除的節(jié)點之下有子節(jié)點,那么這些子節(jié)點也會被完全刪除,它們也不會用于后面的比較。這也是算法復雜能夠降低到O(n)的原因。
上面提到的是對虛擬DOM節(jié)點的操作,而同樣的邏輯也被用在React組件的比較,例如:
renderA: <Header />
renderB: <Content />
=> [removeNode <Header />], [insertNode <Content />]
當React在同一個位置遇到不同的組件時,也是簡單的銷毀第一個組件,而把新創(chuàng)建的組件加上去。這正是應用了第一個假設,不同的組件一般會產生不一樣的DOM結構,與其浪費時間去比較它們基本上不會等價的DOM結構,還不如完全創(chuàng)建一個新的組件加上去。
由這一React對不同類型的節(jié)點的處理邏輯我們很容易得到推論,那就是React的DOM Diff算法實際上只會對樹進行逐層比較,如下所述。
逐層進行節(jié)點比較
提到樹,相信大多數同學立刻想到的是二叉樹,遍歷,最短路徑等復雜的數據結構算法。而在React中,樹的算法其實非常簡單,那就是兩棵樹只會對同一層次的節(jié)點進行比較。如下圖所示:
React只會對相同顏色方框內的DOM節(jié)點進行比較,即同一個父節(jié)點下的所有子節(jié)點。當發(fā)現節(jié)點已經不存在,則該節(jié)點及其子節(jié)點會被完全刪除掉,不會用于進一步的比較。這樣只需要對樹進行一次遍歷,便能完成整個DOM樹的比較。
例如,考慮有下面的DOM結構轉換:
A.parent.remove(A);
D.append(A);
但因為React只會簡單的考慮同層節(jié)點的位置變換,對于不同層的節(jié)點,只有簡單的創(chuàng)建和刪除。當根節(jié)點發(fā)現子節(jié)點中A不見了,就會直接銷毀A;而當D發(fā)現自己多了一個子節(jié)點A,則會創(chuàng)建一個新的A作為子節(jié)點。因此對于這種結構的轉變的實際操作是:
A.destroy();
A = new A();
A.append(new B());
A.append(new C());
D.append(A);
可以看到,以A為根節(jié)點的樹被整個重新創(chuàng)建。
雖然看上去這樣的算法有些“簡陋”,但是其基于的是第一個假設:兩個不同組件一般產生不一樣的DOM結構。根據React官方博客,這一假設至今為止沒有導致嚴重的性能問題。這當然也給我們一個提示,在實現自己的組件時,保持穩(wěn)定的DOM結構會有助于性能的提升。例如,我們有時可以通過CSS隱藏或顯示某些節(jié)點,而不是真的移除或添加DOM節(jié)點。
由DOM Diff算法理解組件的生命周期
在上一篇文章中介紹了React組件的生命周期,其中的每個階段其實都是和DOM Diff算法息息相關的。例如以下幾個方法:
- constructor: 構造函數,組件被創(chuàng)建時執(zhí)行;
- componentDidMount: 當組件添加到DOM樹之后執(zhí)行;
- componentWillUnmount: 當組件從DOM樹中移除之后執(zhí)行,在React中可以認為組件被銷毀;
- componentDidUpdate: 當組件更新時執(zhí)行。
為了演示組件生命周期和DOM Diff算法的關系,示例:https://supnate.github.io/react-dom-diff/index.html 。這時當DOM樹進行如下轉變時,即從“shape1”轉變到“shape2”時。我們來觀察這幾個方法的執(zhí)行情況:
瀏覽器開發(fā)工具控制臺輸出如下結果:
C will unmount.
C is created.
B is updated.
A is updated.
C did mount.
D is updated.
R is updated.
可以看到,C節(jié)點是完全重建后再添加到D節(jié)點之下,而不是將其“移動”過去。
相同類型節(jié)點的比較
第二種節(jié)點的比較是相同類型的節(jié)點,算法就相對簡單而容易理解。React會對屬性進行重設從而實現節(jié)點的轉換。例如:
renderA: <div id="before" />
renderB: <div id="after" />
=> [replaceAttribute id "after"]
虛擬DOM的style屬性稍有不同,其值并不是一個簡單字符串而必須為一個對象,因此轉換過程如下:
renderA: <div style={{color: 'red'}} />
renderB: <div style={{fontWeight: 'bold'}} />
=> [removeStyle color], [addStyle font-weight 'bold']
列表節(jié)點的比較
上面介紹了對于不在同一層的節(jié)點的比較,即使它們完全一樣,也會銷毀并重新創(chuàng)建。那么當它們在同一層時,又是如何處理的呢?這就涉及到列表節(jié)點的Diff算法。相信很多使用React的同學大多遇到過這樣的警告:
這是React在遇到列表時卻又找不到key時提示的警告。雖然無視這條警告大部分界面也會正確工作,但這通常意味著潛在的性能問題。因為React覺得自己可能無法高效的去更新這個列表。
列表節(jié)點的操作通常包括添加、刪除和排序。例如下圖,我們需要往B和C直接插入節(jié)點F,在jQuery中我們可能會直接使用$(B).after(F)來實現。而在React中,我們只會告訴React新的界面應該是A-B-F-C-D-E,由Diff算法完成更新界面。
這時如果每個節(jié)點都沒有唯一的標識,React無法識別每一個節(jié)點,那么更新過程會很低效,即,將C更新成F,D更新成C,E更新成D,最后再插入一個E節(jié)點。效果如下圖所示:
可以看到,React會逐個對節(jié)點進行更新,轉換到目標節(jié)點。而最后插入新的節(jié)點E,涉及到的DOM操作非常多。而如果給每個節(jié)點唯一的標識(key),那么React能夠找到正確的位置去插入新的節(jié)點,入下圖所示:
對于列表節(jié)點順序的調整其實也類似于插入或刪除,下面結合示例代碼我們看下其轉換的過程。仍然使用前面提到的示例:https://supnate.github.io/react-dom-diff/index.html ,我們將樹的形態(tài)從shape5轉換到shape6:
即將同一層的節(jié)點位置進行調整。如果未提供key,那么React認為B和C之后的對應位置組件類型不同,因此完全刪除后重建,控制臺輸出如下:
B will unmount.
C will unmount.
C is created.
B is created.
C did mount.
B did mount.
A is updated.
R is updated.
而如果提供了key,如下面的代碼:
shape5: function() {
return (
<Root>
<A>
<B key="B" />
<C key="C" />
</A>
</Root>
);
},
shape6: function() {
return (
<Root>
<A>
<C key="C" />
<B key="B" />
</A>
</Root>
);
},
那么控制臺輸出如下:
C is updated.
B is updated.
A is updated.
R is updated.
可以看到,對于列表節(jié)點提供唯一的key屬性可以幫助React定位到正確的節(jié)點進行比較,從而大幅減少DOM操作次數,提高了性能。
算法的簡單實現
深度優(yōu)先遍歷,記錄差異
在實際的代碼中,會對新舊兩棵樹進行一個深度優(yōu)先的遍歷,這樣每個節(jié)點都會有一個唯一的標記:
在深度優(yōu)先遍歷的時候,每遍歷到一個節(jié)點就把該節(jié)點和新的的樹進行對比。如果有差異的話就記錄到一個對象里面。
// diff 函數,對比兩棵樹
function diff (oldTree, newTree) {
var index = 0 // 當前節(jié)點的標志
var patches = {} // 用來記錄每個節(jié)點差異的對象
dfsWalk(oldTree, newTree, index, patches)
return patches
}
// 對兩棵樹進行深度優(yōu)先遍歷
function dfsWalk (oldNode, newNode, index, patches) {
// 對比oldNode和newNode的不同,記錄下來
patches[index] = [...]
diffChildren(oldNode.children, newNode.children, index, patches)
}
// 遍歷子節(jié)點
function diffChildren (oldChildren, newChildren, index, patches) {
var leftNode = null
var currentNodeIndex = index
oldChildren.forEach(function (child, i) {
var newChild = newChildren[i]
currentNodeIndex = (leftNode && leftNode.count) // 計算節(jié)點的標識
? currentNodeIndex + leftNode.count + 1
: currentNodeIndex + 1
dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍歷子節(jié)點
leftNode = child
})
}
例如,上面的div和新的div有差異,當前的標記是0,那么:
patches[0] = [{difference}, {difference}, ...] // 用數組存儲新舊節(jié)點的不同
同理p是patches[1],ul是patches[3],類推。
差異類型
上面說的節(jié)點的差異指的是什么呢?對 DOM 操作可能會:
- 替換掉原來的節(jié)點,例如把上面的div換成了section
- 移動、刪除、新增子節(jié)點,例如上面div的子節(jié)點,把p和ul順序互換
- 修改了節(jié)點的屬性
- 對于文本節(jié)點,文本內容可能會改變。例如修改上面的文本節(jié)點2內容為Virtual DOM 2。
所以我們定義了幾種差異類型:
var REPLACE = 0
var REORDER = 1
var PROPS = 2
var TEXT = 3
對于節(jié)點替換,很簡單。判斷新舊節(jié)點的tagName和是不是一樣的,如果不一樣的說明需要替換掉。如div換成section,就記錄下:
patches[0] = [{
type: REPALCE,
node: newNode // el('section', props, children)
}]
如果給div新增了屬性id為container,就記錄下:
patches[0] = [{
type: REPALCE,
node: newNode // el('section', props, children)
}, {
type: PROPS,
props: {
id: "container"
}
}]
如果是文本節(jié)點,如上面的文本節(jié)點2,就記錄下:
patches[2] = [{
type: TEXT,
content: "Virtual DOM2"
}]
那如果把我div的子節(jié)點重新排序呢?例如p, ul, div的順序換成了div, p, ul。這個該怎么對比?如果按照同層級進行順序對比的話,它們都會被替換掉。如p和div的tagName不同,p會被div所替代。最終,三個節(jié)點都會被替換,這樣DOM開銷就非常大。而實際上是不需要替換節(jié)點,而只需要經過節(jié)點移動就可以達到,我們只需知道怎么進行移動。
這牽涉到兩個列表的對比算法,需要另外起一個小節(jié)來討論。
列表對比算法
假設現在可以英文字母唯一地標識每一個子節(jié)點:
舊的節(jié)點順序:
a b c d e f g h i
現在對節(jié)點進行了刪除、插入、移動的操作。新增j節(jié)點,刪除e節(jié)點,移動h節(jié)點:
新的節(jié)點順序:
a b c h d f g i j
現在知道了新舊的順序,求最小的插入、刪除操作(移動可以看成是刪除和插入操作的結合)。這個問題抽象出來其實是字符串的最小編輯距離問題(Edition Distance),最常見的解決算法是 Levenshtein Distance,通過動態(tài)規(guī)劃求解,時間復雜度為 O(M * N)。但是我們并不需要真的達到最小的操作,我們只需要優(yōu)化一些比較常見的移動情況,犧牲一定DOM操作,讓算法時間復雜度達到線性的(O(max(M, N))。具體算法細節(jié)比較多,這里不累述,有興趣可以參考代碼。
我們能夠獲取到某個父節(jié)點的子節(jié)點的操作,就可以記錄下來:
patches[0] = [{
type: REORDER,
moves: [{remove or insert}, {remove or insert}, ...]
}]
但是要注意的是,因為tagName
是可重復的,不能用這個來進行對比。所以需要給子節(jié)點加上唯一標識key
,列表對比的時候,使用key
進行對比,這樣才能復用老的 DOM 樹上的節(jié)點。
這樣,我們就可以通過深度優(yōu)先遍歷兩棵樹,每層的節(jié)點進行對比,記錄下每個節(jié)點的差異了。完整 diff 算法代碼可見 diff.js。
步驟三:把差異應用到真正的DOM樹上
因為步驟一所構建的 JavaScript 對象樹和render出來真正的DOM樹的信息、結構是一樣的。所以我們可以對那棵DOM樹也進行深度優(yōu)先的遍歷,遍歷的時候從步驟二生成的patches對象中找出當前遍歷的節(jié)點差異,然后進行 DOM 操作。
function patch (node, patches) {
var walker = {index: 0}
dfsWalk(node, walker, patches)
}
function dfsWalk (node, walker, patches) {
var currentPatches = patches[walker.index] // 從patches拿出當前節(jié)點的差異
var len = node.childNodes
? node.childNodes.length
: 0
for (var i = 0; i < len; i++) { // 深度遍歷子節(jié)點
var child = node.childNodes[i]
walker.index++
dfsWalk(child, walker, patches)
}
if (currentPatches) {
applyPatches(node, currentPatches) // 對當前節(jié)點進行DOM操作
}
}
applyPatches,根據不同類型的差異對當前節(jié)點進行 DOM 操作:
function applyPatches (node, currentPatches) {
currentPatches.forEach(function (currentPatch) {
switch (currentPatch.type) {
case REPLACE:
node.parentNode.replaceChild(currentPatch.node.render(), node)
break
case REORDER:
reorderChildren(node, currentPatch.moves)
break
case PROPS:
setProps(node, currentPatch.props)
break
case TEXT:
node.textContent = currentPatch.content
break
default:
throw new Error('Unknown patch type ' + currentPatch.type)
}
})
}
完整代碼可見 patch.js。
結語
Virtual DOM 算法主要是實現上面步驟的三個函數:element,diff,patch。然后就可以實際的進行使用:
// 1. 構建虛擬DOM
var tree = el('div', {'id': 'container'}, [
el('h1', {style: 'color: blue'}, ['simple virtal dom']),
el('p', ['Hello, virtual-dom']),
el('ul', [el('li')])
])
// 2. 通過虛擬DOM構建真正的DOM
var root = tree.render()
document.body.appendChild(root)
// 3. 生成新的虛擬DOM
var newTree = el('div', {'id': 'container'}, [
el('h1', {style: 'color: red'}, ['simple virtal dom']),
el('p', ['Hello, virtual-dom']),
el('ul', [el('li'), el('li')])
])
// 4. 比較兩棵虛擬DOM樹的不同
var patches = diff(tree, newTree)
// 5. 在真正的DOM元素上應用變更
patch(root, patches)
當然這是非常粗糙的實踐,實際中還需要處理事件監(jiān)聽等;生成虛擬 DOM 的時候也可以加入 JSX 語法。這些事情都做了的話,就可以構造一個簡單的ReactJS了。
React Fiber 簡介
React Fiber是個什么東西呢?官方的一句話解釋是“React Fiber是對核心算法的一次重新實現”。這么說似乎太虛無縹緲,所以還是要詳細說一下。
為什么Facebook要搞React Fiber呢?我們先要了解現在React(也就是直到目前最新的v15版本)的局限。
同步更新過程的局限
在現有React中,更新過程是同步的,這可能會導致性能問題。
當React決定要加載或者更新組件樹時,會做很多事,比如調用各個組件的生命周期函數,計算和比對Virtual DOM,最后更新DOM樹,這整個過程是同步進行的,也就是說只要一個加載或者更新過程開始,那React就以不破樓蘭終不還的氣概,一鼓作氣運行到底,中途絕不停歇。
表面上看,這樣的設計也是挺合理的,因為更新過程不會有任何I/O操作嘛,完全是CPU計算,所以無需異步操作,的確只要一路狂奔就行了,但是,當組件樹比較龐大的時候,問題就來了。
假如更新一個組件需要1毫秒,如果有200個組件要更新,那就需要200毫秒,在這200毫秒的更新過程中,瀏覽器那個唯一的主線程都在專心運行更新操作,無暇去做任何其他的事情。想象一下,在這200毫秒內,用戶往一個input元素中輸入點什么,敲擊鍵盤也不會獲得響應,因為渲染輸入按鍵結果也是瀏覽器主線程的工作,但是瀏覽器主線程被React占著呢,抽不出空,最后的結果就是用戶敲了按鍵看不到反應,等React更新過程結束之后,咔咔咔那些按鍵一下子出現在input元素里了。
這就是所謂的界面卡頓,很不好的用戶體驗。
現有的React版本,當組件樹很大的時候就會出現這種問題,因為更新過程是同步地一層組件套一層組件,逐漸深入的過程,在更新完所有組件之前不停止,函數的調用棧就像下圖這樣,調用得很深,而且很長時間不會返回。
因為JavaScript單線程的特點,每個同步任務不能耗時太長,不然就會讓程序不會對其他輸入作出相應,React的更新過程就是犯了這個禁忌,而React Fiber就是要改變現狀。
React Fiber的方式
破解JavaScript中同步操作時間過長的方法其實很簡單——分片。
把一個耗時長的任務分成很多小片,每一個小片的運行時間很短,雖然總時間依然很長,但是在每個小片執(zhí)行完之后,都給其他任務一個執(zhí)行的機會,這樣唯一的線程就不會被獨占,其他任務依然有運行的機會。
React Fiber把更新過程碎片化,執(zhí)行過程如下面的圖所示,每執(zhí)行完一段更新過程,就把控制權交還給React負責任務協調的模塊,看看有沒有其他緊急任務要做,如果沒有就繼續(xù)去更新,如果有緊急任務,那就去做緊急任務。
維護每一個分片的數據結構,就是Fiber。
有了分片之后,更新過程的調用棧如下圖所示,中間每一個波谷代表深入某個分片的執(zhí)行過程,每個波峰就是一個分片執(zhí)行結束交還控制權的時機。
道理很簡單,但是React實現這一點卻不容易,要不然怎么折騰了兩年多!
對具體數據結構原理感興趣的同學可以去看Lin Clark在React Conf 2017上的演講*
"); background-size: cover; background-position: 0px 2px;">* (要翻墻的),本文中的介紹圖片也出自這個演講。
為什么叫Fiber呢?
大家應該都清楚進程(Process)和線程(Thread)的概念,在計算機科學中還有一個概念叫做Fiber,英文含義就是“纖維”,意指比Thread更細的線,也就是比線程(Thread)控制得更精密的并發(fā)處理機制。
上面說的Fiber和React Fiber不是相同的概念,但是,我相信,React團隊把這個功能命名為Fiber,含義也是更加緊密的處理機制,比Thread更細。
試用React Fiber?
這就是 React Fiber 的概念,目前 React 還沒有正式發(fā)布 v16 版本,所以只可以在試用版本里體會 React Fiber
yarn add react@next react-dom@next
我相信當React Fiber正式發(fā)布的時候,會有更清晰詳盡的文檔,到時候我們再來詳細介紹。