文章首發於個人部落格
前言
2016 年都已經透露出來的概念,這都 9102 年了,我才開始寫 Fiber 的文章,表示慚愧呀。不過現在好的是關於 Fiber 的資料已經很豐富了,在寫文章的時候參考資料比較多,比較容易深刻的理解。
React 作為我最喜歡的框架,沒有之一,我願意花很多時間來好好的學習他,我發現對於學習一門框架會有四種感受,剛開始沒使用過,可能有一種很神奇的感覺;然後接觸了,遇到了不熟悉的語法,感覺這是什麼垃圾東西,這不是反人類麼;然後當你熟悉了之後,真香,設計得挺好的,這個時候它已經改變了你程式設計的思維方式了;再到後來,看過他的原始碼,理解他的設計之後,設計得確實好,感覺自己也能寫一個的樣子。
所以我今年(對,沒錯,就是一年)就是想完全的學透 React,所以開了一個 Deep In React 的系列,把一些新手在使用 API 的時候不知道為什麼的點,以及一些為什麼有些東西要這麼設計寫出來,與大家共同探討 React 的奧祕。
我的思路是自上而下的介紹,先理解整體的 Fiber 架構,然後再細挖每一個點,所以這篇文章主要是談 Fiber 架構的。
介紹
在詳細介紹 Fiber 之前,先了解一下 Fiber 是什麼,以及為什麼 React 團隊要話兩年時間重構協調演算法。
React 的核心思想
記憶體中維護一顆虛擬DOM樹,資料變化時(setState),自動更新虛擬 DOM,得到一顆新樹,然後 Diff 新老虛擬 DOM 樹,找到有變化的部分,得到一個 Change(Patch),將這個 Patch 加入佇列,最終批量更新這些 Patch 到 DOM 中。
React 16 之前的不足
首先我們瞭解一下 React 的工作過程,當我們通過render()
和 setState()
進行元件渲染和更新的時候,React 主要有兩個階段:
調和階段(Reconciler):官方解釋。React 會自頂向下通過遞迴,遍歷新資料生成新的 Virtual DOM,然後通過 Diff 演算法,找到需要變更的元素(Patch),放到更新佇列裡面去。
渲染階段(Renderer):遍歷更新佇列,通過呼叫宿主環境的API,實際更新渲染對應元素。宿主環境,比如 DOM、Native、WebGL 等。
在協調階段階段,由於是採用的遞迴的遍歷方式,這種也被成為 Stack Reconciler,主要是為了區別 Fiber Reconciler 取的一個名字。這種方式有一個特點:一旦任務開始進行,就無法中斷,那麼 js 將一直佔用主執行緒, 一直要等到整棵 Virtual DOM 樹計算完成之後,才能把執行權交給渲染引擎,那麼這就會導致一些使用者互動、動畫等任務無法立即得到處理,就會有卡頓,非常的影響使用者體驗。
如何解決之前的不足
之前的問題主要的問題是任務一旦執行,就無法中斷,js 執行緒一直佔用主執行緒,導致卡頓。
可能有些接觸前端不久的不是特別理解上面為什麼 js 一直佔用主執行緒就會卡頓,我這裡還是簡單的普及一下。
瀏覽器每一幀都需要完成哪些工作?
頁面是一幀一幀繪製出來的,當每秒繪製的幀數(FPS)達到 60 時,頁面是流暢的,小於這個值時,使用者會感覺到卡頓。
1s 60 幀,所以每一幀分到的時間是 1000/60 ≈ 16 ms。所以我們書寫程式碼時力求不讓一幀的工作量超過 16ms。
瀏覽器一幀內的工作
通過上圖可看到,一幀內需要完成如下六個步驟的任務:
- 處理使用者的互動
- JS 解析執行
- 幀開始。視窗尺寸變更,頁面滾去等的處理
- rAF(requestAnimationFrame)
- 佈局
- 繪製
如果這六個步驟中,任意一個步驟所佔用的時間過長,總時間超過 16ms 了之後,使用者也許就能看到卡頓。
而在上一小節提到的調和階段花的時間過長,也就是 js 執行的時間過長,那麼就有可能在使用者有互動的時候,本來應該是渲染下一幀了,但是在當前一幀裡還在執行 JS,就導致使用者互動不能麻煩得到反饋,從而產生卡頓感。
解決方案
把渲染更新過程拆分成多個子任務,每次只做一小部分,做完看是否還有剩餘時間,如果有繼續下一個任務;如果沒有,掛起當前任務,將時間控制權交給主執行緒,等主執行緒不忙的時候在繼續執行。 這種策略叫做 Cooperative Scheduling(合作式排程),作業系統常用任務排程策略之一。
補充知識,作業系統常用任務排程策略:先來先服務(FCFS)排程演算法、短作業(程式)優先排程演算法(SJ/PF)、最高優先權優先排程演算法(FPF)、高響應比優先排程演算法(HRN)、時間片輪轉法(RR)、多級佇列反饋法。
合作式排程主要就是用來分配任務的,當有更新任務來的時候,不會馬上去做 Diff 操作,而是先把當前的更新送入一個 Update Queue 中,然後交給 Scheduler 去處理,Scheduler 會根據當前主執行緒的使用情況去處理這次 Update。為了實現這種特性,使用了requestIdelCallback
API。對於不支援這個API 的瀏覽器,React 會加上 pollyfill。
在上面我們已經知道瀏覽器是一幀一幀執行的,在兩個執行幀之間,主執行緒通常會有一小段空閒時間,requestIdleCallback
可以在這個空閒期(Idle Period)呼叫空閒期回撥(Idle Callback),執行一些任務。
- 低優先順序任務由
requestIdleCallback
處理; - 高優先順序任務,如動畫相關的由
requestAnimationFrame
處理; requestIdleCallback
可以在多個空閒期呼叫空閒期回撥,執行任務;requestIdleCallback
方法提供 deadline,即任務執行限制時間,以切分任務,避免長時間執行,阻塞UI渲染而導致掉幀;
這個方案看似確實不錯,但是怎麼實現可能會遇到幾個問題:
- 如何拆分成子任務?
- 一個子任務多大合適?
- 怎麼判斷是否還有剩餘時間?
- 有剩餘時間怎麼去排程應該執行哪一個任務?
- 沒有剩餘時間之前的任務怎麼辦?
接下里整個 Fiber 架構就是來解決這些問題的。
什麼是 Fiber
為了解決之前提到解決方案遇到的問題,提出了以下幾個目標:
- 暫停工作,稍後再回來。
- 為不同型別的工作分配優先權。
- 重用以前完成的工作。
- 如果不再需要,則中止工作。
為了做到這些,我們首先需要一種方法將任務分解為單元。從某種意義上說,這就是 Fiber,Fiber 代表一種工作單元。
但是僅僅是分解為單元也無法做到中斷任務,因為函式呼叫棧就是這樣,每個函式為一個工作,每個工作被稱為堆疊幀,它會一直工作,直到堆疊為空,無法中斷。
所以我們需要一種增量渲染的排程,那麼就需要重新實現一個堆疊幀的排程,這個堆疊幀可以按照自己的排程演算法執行他們。另外由於這些堆疊是可以自己控制的,所以可以加入併發或者錯誤邊界等功能。
因此 Fiber 就是重新實現的堆疊幀,本質上 Fiber 也可以理解為是一個虛擬的堆疊幀,將可中斷的任務拆分成多個子任務,通過按照優先順序來自由排程子任務,分段更新,從而將之前的同步渲染改為非同步渲染。
所以我們可以說 Fiber 是一種資料結構(堆疊幀),也可以說是一種解決可中斷的呼叫任務的一種解決方案,它的特性就是時間分片(time slicing)和暫停(supense)。
如果瞭解協程的可能會覺得 Fiber 的這種解決方案,跟協程有點像(區別還是很大的),是可以中斷的,可以控制執行順序。在 JS 裡的 generator 其實就是一種協程的使用方式,不過顆粒度更小,可以控制函式裡面的程式碼呼叫的順序,也可以中斷。
Fiber 是如何工作的
ReactDOM.render()
和setState
的時候開始建立更新。- 將建立的更新加入任務佇列,等待排程。
- 在 requestIdleCallback 空閒時執行任務。
- 從根節點開始遍歷 Fiber Node,並且構建 WokeInProgress Tree。
- 生成 effectList。
- 根據 EffectList 更新 DOM。
下面是一個詳細的執行過程圖:
- 第一部分從
ReactDOM.render()
方法開始,把接收的 React Element 轉換為 Fiber 節點,併為其設定優先順序,建立 Update,加入到更新佇列,這部分主要是做一些初始資料的準備。 - 第二部分主要是三個函式:
scheduleWork
、requestWork
、performWork
,即安排工作、申請工作、正式工作三部曲,React 16 新增的非同步呼叫的功能則在這部分實現,這部分就是 Schedule 階段,前面介紹的 Cooperative Scheduling 就是在這個階段,只有在這個解決獲取到可執行的時間片,第三部分才會繼續執行。具體是如何排程的,後面文章再介紹,這是 React 排程的關鍵過程。 - 第三部分是一個大迴圈,遍歷所有的 Fiber 節點,通過 Diff 演算法計算所有更新工作,產出 EffectList 給到 commit 階段使用,這部分的核心是 beginWork 函式,這部分基本就是 Fiber Reconciler ,包括 reconciliation 和 commit 階段。
Fiber Node
FIber Node,承載了非常關鍵的上下文資訊,可以說是貫徹整個建立和更新的流程,下來分組列了一些重要的 Fiber 欄位。
{
...
// 跟當前Fiber相關本地狀態(比如瀏覽器環境就是DOM節點)
stateNode: any,
// 單連結串列樹結構
return: Fiber | null,// 指向他在Fiber節點樹中的`parent`,用來在處理完這個節點之後向上返回
child: Fiber | null,// 指向自己的第一個子節點
sibling: Fiber | null, // 指向自己的兄弟結構,兄弟節點的return指向同一個父節點
// 更新相關
pendingProps: any, // 新的變動帶來的新的props
memoizedProps: any, // 上一次渲染完成之後的props
updateQueue: UpdateQueue<any> | null, // 該Fiber對應的元件產生的Update會存放在這個佇列裡面
memoizedState: any, // 上一次渲染的時候的state
// Scheduler 相關
expirationTime: ExpirationTime, // 代表任務在未來的哪個時間點應該被完成,不包括他的子樹產生的任務
// 快速確定子樹中是否有不在等待的變化
childExpirationTime: ExpirationTime,
// 在Fiber樹更新的過程中,每個Fiber都會有一個跟其對應的Fiber
// 我們稱他為`current <==> workInProgress`
// 在渲染完成之後他們會交換位置
alternate: Fiber | null,
// Effect 相關的
effectTag: SideEffectTag, // 用來記錄Side Effect
nextEffect: Fiber | null, // 單連結串列用來快速查詢下一個side effect
firstEffect: Fiber | null, // 子樹中第一個side effect
lastEffect: Fiber | null, // 子樹中最後一個side effect
....
};
複製程式碼
Fiber Reconciler
在第二部分,進行 Schedule 完,獲取到時間片之後,就開始進行 reconcile。
Fiber Reconciler 是 React 裡的調和器,這也是任務排程完成之後,如何去執行每個任務,如何去更新每一個節點的過程,對應上面的第三部分。
reconcile 過程分為2個階段(phase):
- (可中斷)render/reconciliation 通過構造 WorkInProgress Tree 得出 Change。
- (不可中斷)commit 應用這些DOM change。
reconciliation 階段
在 reconciliation 階段的每個工作迴圈中,每次處理一個 Fiber,處理完可以中斷/掛起整個工作迴圈。通過每個節點更新結束時向上歸併 Effect List 來收集任務結果,reconciliation 結束後,根節點的 Effect List裡記錄了包括 DOM change 在內的所有 Side Effect。
render 階段可以理解為就是 Diff 的過程,得出 Change(Effect List),會執行宣告如下的宣告週期方法:
- [UNSAFE_]componentWillMount(棄用)
- [UNSAFE_]componentWillReceiveProps(棄用)
- getDerivedStateFromProps
- shouldComponentUpdate
- [UNSAFE_]componentWillUpdate(棄用)
- render
由於 reconciliation 階段是可中斷的,一旦中斷之後恢復的時候又會重新執行,所以很可能 reconciliation 階段的生命週期方法會被多次呼叫,所以在 reconciliation 階段的生命週期的方法是不穩定的,我想這也是 React 為什麼要廢棄 componentWillMount
和 componentWillReceiveProps
方法而改為靜態方法 getDerivedStateFromProps
的原因吧。
commit 階段
commit 階段可以理解為就是將 Diff 的結果反映到真實 DOM 的過程。
在 commit 階段,在 commitRoot 裡會根據 effect
的 effectTag
,具體 effectTag 見原始碼 ,進行對應的插入、更新、刪除操作,根據 tag
不同,呼叫不同的更新方法。
commit 階段會執行如下的宣告週期方法:
- getSnapshotBeforeUpdate
- componentDidMount
- componentDidUpdate
- componentWillUnmount
P.S:注意區別 reconciler、reconcile 和 reconciliation,reconciler 是調和器,是一個名詞,可以說是 React 工作的一個模組,協調模組;reconcile 是調和器調和的動作,是一個動詞;而 reconciliation 只是 reconcile 過程的第一個階段。
Fiber Tree 和 WorkInProgress Tree
React 在 render 第一次渲染時,會通過 React.createElement 建立一顆 Element 樹,可以稱之為 Virtual DOM Tree,由於要記錄上下文資訊,加入了 Fiber,每一個 Element 會對應一個 Fiber Node,將 Fiber Node 連結起來的結構成為 Fiber Tree。它反映了用於渲染 UI 的應用程式的狀態。這棵樹通常被稱為 current 樹(當前樹,記錄當前頁面的狀態)。
在後續的更新過程中(setState),每次重新渲染都會重新建立 Element, 但是 Fiber 不會,Fiber 只會使用對應的 Element 中的資料來更新自己必要的屬性,
Fiber Tree 一個重要的特點是連結串列結構,將遞迴遍歷程式設計迴圈遍歷,然後配合 requestIdleCallback API, 實現任務拆分、中斷與恢復。
這個連結的結構是怎麼構成的呢,這就要主要到之前 Fiber Node 的節點的這幾個欄位:
// 單連結串列樹結構
{
return: Fiber | null, // 指向父節點
child: Fiber | null,// 指向自己的第一個子節點
sibling: Fiber | null,// 指向自己的兄弟結構,兄弟節點的return指向同一個父節點
}
複製程式碼
每一個 Fiber Node 節點與 Virtual Dom 一一對應,所有 Fiber Node 連線起來形成 Fiber tree, 是個單連結串列樹結構,如下圖所示:
對照圖來看,是不是可以知道 Fiber Node 是如何聯絡起來的呢,Fiber Tree 就是這樣一個單連結串列。
當 render 的時候有了這麼一條單連結串列,當呼叫 setState
的時候又是如何 Diff 得到 change 的呢?
採用的是一種叫雙緩衝技術(double buffering),這個時候就需要另外一顆樹:WorkInProgress Tree,它反映了要重新整理到螢幕的未來狀態。
WorkInProgress Tree 構造完畢,得到的就是新的 Fiber Tree,然後喜新厭舊(把 current 指標指向WorkInProgress Tree,丟掉舊的 Fiber Tree)就好了。
這樣做的好處:
- 能夠複用內部物件(fiber)
- 節省記憶體分配、GC的時間開銷
- 就算執行中有錯誤,也不會影響 View 上的資料
每個 Fiber上都有個alternate
屬性,也指向一個 Fiber,建立 WorkInProgress 節點時優先取alternate
,沒有的話就建立一個。
建立 WorkInProgress Tree 的過程也是一個 Diff 的過程,Diff 完成之後會生成一個 Effect List,這個 Effect List 就是最終 Commit 階段用來處理副作用的階段。
後記
本開始想一篇文章把 Fiber 講透的,但是寫著寫著發現確實太多了,想寫詳細,估計要寫幾萬字,所以我這篇文章的目的僅僅是在沒有涉及到原始碼的情況下梳理了大致 React 的工作流程,對於細節,比如如何排程非同步任務、如何去做 Diff 等等細節將以小節的方式一個個的結合原始碼進行分析。
說實話,自己不是特別滿意這篇,感覺頭重腳輕,在講協調之前寫得還挺好的,但是在講協調這塊文字反而變少了,因為我是專門想寫一篇文章講協調的,所以這篇僅僅用來梳理整個流程。
但是梳理整個流程又發現 Schedule 這塊基本沒什麼體現,哎,不想寫了,這篇文章拖太久了,請繼續後續的文章。
可以關注我的 github:Deep In React
一些問題
接下來留一些思考題。
- 如何去劃分任務優先順序?
- 在 reconcile 過程的 render 階段是如何去遍歷連結串列,如何去構建 workInProgress 的?
- 當任務被打斷,如何恢復?
- 如何去收集 EffectList?
- 針對不同的元件型別如何進行更新?
參考
我是桃翁,一個愛思考的前端er,想了解關於更多的前端相關的,請關注我的公號:「前端桃園」