React專題:抽象UI
本文是『horseshoe·React專題』系列文章之一,後續會有更多專題推出
來我的GitHub repo 閱讀完整的專題文章
來我的個人部落格 獲得無與倫比的閱讀體驗
所有的UI都是樹形結構,大節點巢狀小節點。於是React開發人員開始想,既然它們有這些共性,能不能把它們抽象出來呢?
於是就有了所謂的Virtual DOM
,但我更願意稱它為抽象UI。抽象比虛擬更加觸及精髓,因為DOM是一個只存在於網頁中的概念。
物件
一個節點就是一個物件,物件之間的巢狀關係對應於DOM節點之間的巢狀關係。
這個物件要描述哪些關鍵資訊呢?
- 節點型別。
- 節點屬性。注意,子節點也是屬性之一。
- 服務於React自身的資訊。
下面是一個節點的物件表示法。
{ $$typeof: Symbol(react.element), key: null, props: { children: Object || Array, }, ref: null, type: 'div', _owner: { alternate: {}, child: {}, effectTag: 5, expirationTime: 0, firstEffect: null, index: 0, key: null, lastEffect: null, memoizedProps: null, memoizedState: null, mode: 0, nextEffect: null, pendingProps: null, }, } 複製程式碼
diff
有了抽象UI,那麼元件掛載的過程究竟是怎樣的呢?
首先呢,初次掛載的時候肯定是把抽象UI編譯成DOM標籤,然後插入到container
當中。
沒錯,React再通天,最終也是要使用innerHTML
來掛載DOM 的。
const setInnerHTML = createMicrosoftUnsafeLocalFunction(function(node, html) { node.innerHTML = html; }); 複製程式碼
然後呢,每當有state或props更新,再將抽象UI編譯一次,插入到container
當中。
當然不是這樣的啦!
React怎麼可能跟我們寫的過家家程式碼一樣呢。
初始掛載的時候,React確實是會使用innerHTML
介面,但是一旦掛載完畢,後續的更新都是打補丁,也就是精準的區域性更新。
打補丁就涉及到兩個問題:
- 如何知道在哪裡打補丁?
- 打補丁的頻率。
這就要說到抽象UI的diff演算法。
當React決定來一波更新的時候,它會生成一套新的抽象UI樹。注意,這時候React手裡有兩套抽象UI樹了,通過對比這兩棵樹,React就能知道哪裡產生了變化。
按照常識,假如某棵樹的某個父節點由div換成了ul,但是子節點沒有任何變化,那我們只需要修改該父節點的型別即可。因為只有這裡變了,這才是打補丁的正確方式。
但是我們要考慮另一個問題:比較的過程所花的時間。
實現上述常識操作的演算法複雜度是O(n^3),因為要遞迴比較。也就是說如果抽象UI樹非常龐大,那以JavaScript的尿性是吃不消的,頁面會出現卡頓。
所以React不得不做一些妥協。只比較同級的節點可以讓演算法複雜度降低到O(n),相對來講,是一個比較好的折中方案。那麼上面的例子,一旦父節點由div換成了ul,那麼React就會簡單粗暴的替換掉所有子節點。
每次setState都會觸發diff演算法嗎?
當然不會。因為不是真正的UI,所以React可以控制它的更新頻率,這樣也可以提升效能。
React生命週期大致可以分為三個階段:掛載,更新,解除安裝。在掛載階段內和一次更新階段內,diff演算法只會執行一次,這就是打補丁的頻率。
這也是人們說setState是非同步的原因。
兩種變化型別
現在我們知道,React只會對同一層級的節點進行比較。
那麼就會產生三種結果:沒有變化,節點型別改變(或者同時節點屬性),節點型別不變節點屬性改變。
沒有變化是最好的,早點收工。
節點型別改變,比如說div變成了ul,那麼不再檢查屬性,直接替換。同時所有的子節點也要替換。當然如果新的抽象UI樹沒有對應的節點,那就意味著刪除了。
節點型別不變節點屬性改變,那麼DOM樹結構不變,只是對該節點的屬性做一些增刪改操作。
列表
上面說到,React會依次對同一層級的節點進行比較,如果型別不同則重新掛載節點,如果型別相同屬性不同則更新節點。
我們來看看列表,如果我給一個列表unshift
一項,可以想象,React在比較列表的每一項時,結果都是型別相同屬性不同,於是需要更新列表的每一項。但其實我們只是往列表插入一項而已,原生寫法只需要insertBefore
就好了。
演算法不能太複雜,有沒有什麼其他的辦法呢?
如果給列表的每一項加一個唯一識別符號,React在比較的時候就能知道某一項不是被去掉了,而是換了位置。也就能學會插入操作了。
所以開發者在使用map
方法渲染列表時,都會要求給列表每一項加一個唯一的key屬性,它就是讓React變得更聰明的腦白金。
import React from 'react'; const App = ({ list }) => { return ( <div> {list.map(v => <div key={v}>{v}</div>)} </div> ); } export default App; 複製程式碼
有人問了:我不用map
方法渲染,手動寫一個列表,React打算怎麼處理?
不處理!
你想,map
方法渲染的列表是資料映射出來的,開發者很容易運算元組。但是在JSX中手動寫一個列表,首先不推薦大家這樣做(吃力不討好),其次大家很少會在React中操作DOM,所以你也不能把這個列表玩出花來。最重要的,React沒辦法識別這是一個列表。
還有一點,React不推薦使用列表的索引來充當key屬性值,因為對列表進行插入或者刪除操作時,索引也會相應的改變,它是不穩定的,key存在的意義也就沒有了。
但是有些時候我們實在找不到唯一的穩定的某個值,那就只能用索引來搪塞了。
那意思React是說:孩子,不為難你了,早點下班吧。
Fiber
一般的顯示器重新整理頻率都是60Hz。這是什麼意思呢?意味著螢幕畫面每秒重新整理60次,或者大約每16.7ms螢幕畫面重新整理一次。這是人眼比較舒服的頻率。
對網頁而言,亦是如此。
這意味著如果JavaScript計算任務連續佔用瀏覽器主執行緒超過16.7ms,網頁就沒辦法做到60Hz的重新整理率,結果就是我們常說的頁面卡頓甚至白屏。
一般來說,只要不陷入死迴圈或者太深的呼叫棧,JavaScript可以從容的執行,甚至大概率還有剩餘時間,行到水窮處,坐看雲起時。
偏偏哪,React優化效能的機制是依靠大量的JavaScript計算。如果是一個大型的專案,元件可能會有上百層的巢狀,這個呼叫棧之深可想而知。
所以為了更加平滑的體驗,React開發組閉關兩年,祭出了Fiber這個大殺器。
可以把Fiber理解為虛擬呼叫棧。計算機術語中除了程序(Process)、執行緒(Thread),還有纖程(Fiber),React就是取其更精密的併發控制之意。
React這個開源專案大體上可以分為兩部分,一部分是構建抽象UI,並提供變化檢測,叫Reconciler。另一部分是將抽象UI渲染到具體的平臺上,叫Renderer。
Reconciler的主要工作是計算,Renderer的主要工作是排版和繪製。
首先,要將Reconciler拆分成更小的事務,再將這些事務從使用者體驗的角度劃分優先順序,從而可以實現更細微的排程,而不是像之前一口氣跑完。至於排版和繪製,那本來就是平滑體驗的關鍵,去打斷它沒有任何意義。
排程工具
接下來就輪到大佬上場了。
requestAnimationFrame
API:刀耕火種時期的前端想要寫動畫,得用setTimeout
或者setInterval
來觸發一些定時動作。首先,如果定時器執行頻率比螢幕重新整理率高,有一些動作可能會被忽略,瀏覽器直接渲染下一個動作去了。這就是定時器與螢幕重新整理步調不一致導致的丟幀。還有,定時器是非同步任務,如果主執行緒被佔用它就只能眼巴巴的等著。而requestAnimationFrame
從名字就能看出來,它是為動畫而生的,殺手鐗是瀏覽器會保證它的執行頻率和螢幕重新整理率一致(如果有空餘時間的話),就是那種被大佬罩著的人。優先順序比較高。
requestIdleCallback
API:這個介面更有意思,它會告訴開發者,在16.7ms之內,瀏覽器把活幹完還剩下多少時間,然後把剩下時間的控制權交給開發者。當然如果瀏覽器自己都不夠用,你就喝湯吧。所以它不保證一定會執行,行就行,不行就拉倒。
React就是通過這兩個API來實現事務的排程的。
Reconciler從一個風風火火的意氣少年變成了一個小心翼翼的中年男人,每執行一小段事務就探出頭來看看,瀏覽器當前有沒有高優先順序的事務在排隊啊?如果有趕緊讓開啊,等瀏覽器處理完咱們再偷偷的進村啊。
對生命週期的影響
我們說生命週期分為掛載、更新和解除安裝,生命週期從另一個角度又可以分為render前和render後。
之所以這樣劃分是因為React本身有Reconciler和Renderer兩部分,現在的問題在哪呢?
因為Reconciler現在是可以打斷的,也就是說render前的生命週期鉤子有可能被執行多次,而且它的表現是沒辦法預測的,因為是否被打斷要依據當時的情況。
這也就是componentWillMount、componentWillReceiveProps和componentWillUpdate生命週期鉤子要被逐步替換掉的原因。