在React出現之後,虛擬DOM(Virtual DOM)概念逐漸廣為人知,然而,若談到虛擬DOM之目的,我們所得到的答案,多半是「比直接操作DOM高效」。其實,這是個籠統也易令人產生誤解的說法,因為,React本身也並不標榜高效的DOM操作!

瀏覽器的渲染機制

若要討論DOM操作的效能,對於瀏覽器處理網頁的過程,我們必須進一步認識。

基本上,這邊進行的步驟會是剖析HTML結構、建立DOM樹,將CSS剖析、轉為CSSOM(CSS Object Model);然後,DOM與CSSOM結合為渲染樹(Render tree);接著,進行布局(Layout)計算出元素的位置、尺寸等資訊;在這之後,進行繪製(Painting)得到像素;而到了最終階段,會執行顯示(Display)。

渲染(Rendering)是指布局與繪製的過程,元素時若涉及布局、位置、尺寸等修改,就會導致重新渲染,也就是會發生重新布局(reflow)和重繪(repaint)。一旦重新布局後,必然要重繪,然而有些修改不影響布局、幾何資訊,像是修改背景顏色,則只會引發重繪。

重新布局與重繪都是耗時的,如果頁面元素組成複雜,而且經常發生重新布局或重繪,或者是在DOM操作上會導致大量元素更新,頁面就會有明顯的停頓現象。

在入門前端的書籍或文件上,多半也會提到大量建立DOM並直接附加至DOM樹時,可能會引發效能問題,因為,每個DOM元素的附加,後續都會引發重新渲染,開發者如果在背景準備好DOM的片段,最後再附加至DOM樹上,此時就有機會可以改進效能。

不過,現代瀏覽器很聰明,對於連續的元素變更,若過程中不涉及布局、元素幾何等讀取的演算,會儘量集中排在佇列中,最後才一次性地重新渲染,因此,避免變更與讀取元素特性的操作交相混雜,是改進效能的一個可能考量。

實現React原型

想要瞭解虛擬DOM,就是先要瞭解React,想瞭解React最好的方式,就是自行實現簡單的原型,在使用JSX撰寫HTML部份,我們可以使用ES6模版字串來權充一下。

例如,HTML字串可以指定給DOM元素的innerHTML來建立DOM片段,並從render方法中傳回,等到每次元件的狀態改變,就呼叫重新建立HTML字串、剖析建立新的DOM片段,然後用新的DOM取代舊的片段。

開發者可能會對innerHTML有效能上的疑慮,因為它還必須經過剖析等階段,然而,字串是JavaScript的基本型態,相比於DOM物件較為輕量,若以innerHTML方式在背景準備好DOM片段,再附加至DOM樹,這個過程是由瀏覽器原生進行剖析,相較於自行剖析、建立DOM元素,在效能上,可能相去不遠。

動手實現這樣的原型,主要在突顯一個事實:React的核心設計就是「狀態一有變動,就用新畫面取代舊畫面」,在這個原型中,我們可以把元件想成是個函式,若開發者將狀態作為輸入,新的畫面(DOM片段)就會作為輸出,也就是開發者必須從命令元素該如何(How)顯示,改為宣告元件代表什麼(What)狀態。從本質上來看,這就是從命令式到宣告式的典範轉移。

然而,每次都用新畫面取代舊畫面會有幾個問題,即便在背景準備好DOM片段,但如果頁面複雜,狀態又頻繁改變,每次畫面取代而引發的重新渲染,累積下來也會產生很大的負擔;此外,有時只是更動表格中某個欄位,卻要建立整個表格的DOM片段,在這類需求下,重新建構與整個取代顯然很浪費。

DOM物件本身的建立,也是個問題,因為每一個DOM本身其實帶有大量的特性,而開發者可能只是關心其中的少量特性;有些狀態的變更,其實用不著建立新的DOM,只需要更新既有的DOM,就可以了,而有些狀態變更,只需要單純地刪除某個DOM元素。

因此,每次用新畫面取代舊畫面,不單只在效能上引發疑慮,也會引發使用者操作上的問題,例如,輸入欄位會因此失去焦點。

虛擬DOM

虛擬DOM的出發點,就是為了解決React核心設計「狀態一有變動,就用新畫面取代舊畫面」而引發的效能等問題,讓框架的使用者取得可接受的效能表現,從而願意接受框架的設計與典範。

而所謂的虛擬DOM物件,指的就是JavaScript物件,例如,若要表現<li class='book1'>Book 1</li>,此時,我們可以使用物件{tagName: 'li', props: {class: 'book'}, children: ["Book 1"]}來表示。

相對於建立一個DOM片段,建立對應的物件樹,輕量許多。實際上,在React中使用JSX撰寫的HTML片段,產生的就是物件樹。

在首次執行時,React會根據物件樹建立DOM片段,並附加至DOM樹,React會保有元件與DOM樹上的對應節點參考;當狀態改變時,React的元件會建立新的物件樹(也就是render方法傳回值),並與舊的物件樹進行比較,也就是執行虛擬DOM的diff算法,走訪物件樹並找出有差異的物件節點,差異可能是替換、刪除、移動、修改特性等類型,而且,每個差異都會包含類型、節點編號等必要資訊,最後記錄在一個patches清單。

patches清單最後會套用在DOM樹上,由於物件樹與DOM片段的結構是一致的,因此可以採用相同的方式來走訪DOM片段,找出與節點編號對應的DOM,根據差異類型進行替換、刪除、修改等對應動作;採用虛擬DOM的附加好處是,由於物件樹是純粹的JavaScript物件,在其他環境也可以運用,因此才會有ReactNative之類的實現。

也就是說,每次的狀態一有變動,底層實際上是建立新物件樹來取代舊物件樹,並由React來負責對應至DOM樹的操作(並不是更新整個畫面),然而這一切都被React隱藏起來。

因為,開發者使用JSX撰寫HTML片段,概念上,就類似實現React原型時使用innerHTML,兩者都是「狀態一有變動,就用新畫面取代舊畫面」,但React使用的是虛擬DOM,以此減輕原型實現中可能的效能負擔等問題。

比直接操作DOM高效?

雖然物件樹相較於DOM是輕量的,然而,diff演算與根據patches更新的過程,並不是毫無代價的。就像虛擬DOM「比直接操作DOM高效」的這個說法,常令許多開發者誤認為,使用React比原生操作DOM實現相同頁面功能時的效能要高,實際上,React官方首頁並沒有標榜這類特色。

若真要討論效能,得搞清楚比較的對象,的確!相較於拙劣的DOM操作,虛擬DOM有很多機會可以勝出,而通用性也比較大,不過「比直接操作DOM高效」的這個說法,本質上是指框架實現「狀態有變動就用新畫面取代舊畫面」的做法差異,是為了支援典範下的權衡做法。所以,知道這點之後,如果我們看到有些開發者以原生操作DOM實現出相同功能頁面,效能卻比React來得好,就一點也不覺得奇怪了!

作者簡介


Advertisement

更多 iThome相關內容