前言
React實現可以粗划為兩部分:reconciliation(diff階段)和 commit(操作DOM階段)。在 v16 之前,reconciliation 簡單說就是一個自頂向下遞歸算法,產出需要對當前DOM進行更新或替換的操作列表,一旦開始,會持續占用主線程,中斷操作卻不容易實現。當JS長時間執行(如大量計算等),會阻塞樣式計算、繪制等工作,出現頁面脫幀現象。所以,v16 進行了一次重寫,迎來了代號為Fiber的異步渲染架構。
Fiber
Fiber核心是實現了一個基於優先級和requestIdleCallback的循環任務調度算法。它包含以下特性:(參考:fiber-reconciler)
- reconciliation階段可以把任務拆分成多個小任務
- reconciliation階段可隨時中止或恢復任務
- 可以根據優先級不同來選擇優先執行任務
從其特性可看出,Fiber核心是更換了reconciliation階段的運作。那么,問題來了:
-
為什么對reconciliation階段進行拆分,commit階段呢?
reconciliation階段包含的主要工作是對current tree 和 new tree 做diff計算,找出變化部分。進行遍歷、對比等是可以中斷,歇一會兒接着再來。
commit階段是對上一階段獲取到的變化部分應用到真實的DOM樹中,是一系列的DOM操作。不僅要維護更復雜的DOM狀態,而且中斷后再繼續,會對用戶體驗造成影響。在普遍的應用場景下,此階段的耗時比diff計算等耗時相對短。
所以,Fiber選擇在reconciliation階段拆分 -
如何拆分呢?
首先,我們可以通過 A Cartoon Intro to Fiber中的一張圖來看:
- 用戶調用ReactDOM.render傳入組件,React創建Element樹;
- 在第一次渲染時,創建vdom樹,用來維護組件狀態和dom節點的信息(如List/Button/Item等)。當后續操作如render或setState時需要更新,通過diff算出變化的部分;
- 根據變化的部分更新vdom樹、調用組件生命周期函數等,同步應用到真實的DOM節點中。
在第二階段,Fiber是把render/update分片,拆解成多個小任務來執行,每次只檢查樹上部分節點,做完此部分后,若當前一幀(16ms)內還有足夠的時間就繼續做下一個小任務,時間不夠就停止操作,等主線程空閑時再恢復。
這種停止/恢復操作,需要記錄上下文信息。而當前只記錄單一dom節點的vDom tree 是無法完成的,
Fiber引入了fiber tree,是用來記錄上下文的vDom tree,可以理解為升級版的鋼鐵俠。
fiber tree上一個節點的結構大致有:
export type Fiber = {
tag: TypeOfWork, // 類型
type: 'div',
return: Fiber|null, // 父節點
child: Fiber|null, // 子節點
sibling: Fiber|null, // 兄弟節點
alternate: Fiber|null, //diff出的變化記錄在這個fiber上
.....
};
完整樹結構可參見 ReactFiber
所以,Fiber是根據一個fiber節點(VDOM節點)來拆分,以fiber node為一個任務單元,一 個組件實例都是一個任務單元。任務循環中,每處理完一個fiber node,可以中斷/掛起/恢復。
-
基於requestIdleCallback和優先級任務調度
1.requestIdleCallback
window.requestIdleCallback(callback[, options])
瀏覽器提供的requestIdleCallback API中的Cooperative Scheduling可以讓瀏覽器在空閑時間執行回調(開發者傳入的方法),在回調參數中可以獲取到當前幀(16ms)剩余的時間。利用這個信息可以合理的安排當前幀需要做的事情,如果時間足夠,那繼續做下一個任務,如果時間不夠就歇一歇,調用requestIdleCallback來獲知主線程不忙的時候,再繼續做任務。
2.不同的任務分配不同的優先級,Fiber根據任務的優先級來動態調整任務調度,先做高優先級的任務
{
NoWork: 0, // No work is pending.
SynchronousPriority: 1, // 文本輸入框
TaskPriority: 2, // 當前調度正執行的任務
AnimationPriority: 3, // 動畫過渡
HighPriority: 4, // 用戶交互反饋
LowPriority: 5, // 數據的更新
OffscreenPriority: 6, // 預估未來需要顯示的任務
}
3.任務調度的過程是:
- 在任務隊列中選出高優先級的fiber node執行,調用requestIdleCallback獲取所剩時間,若執行時間超過了deathLine,或者突然插入更高優先級的任務,則執行中斷,保存當前結果,修改tag標記一下,設置為pending狀態,迅速收尾並再調用一個requestIdleCallback,等主線程釋放出來再繼續
- 恢復任務執行時,檢查tag是被中斷的任務,會接着繼續做任務或者重做
一個任務單元執行結束或掛起,會調用基於requestIdleCallback的調度器,返回一個新的任務隊列繼續進行上述過程。
-
如何任務循環
上面我們有介紹fiber node的屬性存放上下文信息的指向。
-
child:指向當前節點的子節點。當此節點任務結束后,如果child存在,就開始子節點的任務
-
sibling: 當子樹已遍歷到底部,回到父節點時,會拿到父節點的兄弟節點進行任務
-
return: 當當前節點沒有子節點時,會向上回到父節點
-
alternate
在調用render或setState后,會克隆出一個鏡像fiber,diff產生出的變化會標記在鏡像fiber上。而alternate就是鏈接當前fiber tree和鏡像fiber tree, 用於斷點恢復。- workInProgress tree
React中對其描述如下:
work-in-progress
A fiber that has not yet completed; conceptually, a stack frame which has not yet returned.The alternate of the current fiber is the work-in-progress, and the alternate of the work-in-progress is the current fiber.當前fiber節點的alternate屬性指向workInProgress節點,對應workInProgress節點的alternate屬性指向當前fiber節點。
上面alternate中說到鏡像fiber tree就是workInProgress tree。
workInProgress tree上每個節點都有一個effect list,用來存放需要更新的內容。此節點更新完畢會向子節點或鄰近節點合並 effect list。任務循環的過程如下:
- 找到高優先級的待處理的節點
- 檢查當前節點是否需要更新,不需要的話,直接clone子節點,直接到5
- 打個tag標記,更新自己(組件更新props,context等,DOM節點記下DOM change)
- 通過render獲取子節點,生成子節點的workInProgress節點
- 若沒有產生子節點,歸並diff出的不同部分effect list(包含DOM change)到父節點
- 把子節點或兄弟節點作為待處理任務,准備進入下一個任務循環。若已經回到了workInProgress tree的根節點,則任務循環結束
通過每個節點更新結束時向上歸並effect list來收集任務結果,reconciliation結束后,根節點的effect list里記錄了包括DOM change在內的所有side effect
通過一段代碼,我們來看看如何進行任務循環:
export class Home extend React.component<HomeProps, any> { componentWillReceiveProps(nextProps: HomeProps) {} componentDidMount() {} componentDidUpdate() {} componentWillUnmount() {} ..... render() { return ( <div className="top"> <span>ZZ</span> <button>click</button> </div> ) } } ReactDom.render(<Home />, document.querySelector(selectors: '#hostRoot'))
以它為例創建fiber tree:
- workInProgress tree
- 從根節點 #hostRoot出發,走到它的child節點 div.top
- div.top有兩個子節點span和button ,先從左子樹span開始
- span只有一個子節點 ZZ,到達樹的底部后原路返回
- 返回到span時發現其有一個兄弟節點 button, 走去button
- button有一個子節點 click, 同樣到達樹的底部后原路返回到button
- button的右邊沒有兄弟了(若有,則繼續重復4-5),返回到父節點 div.top
- 最后回到根節點 #hostRoot
再次setState或render時,構建workInProgress tree:
- 先把根節點 #hostRoot克隆出來,並用child屬性指向它在fiber tree中的子節點div.top
- 把div.top從fiber tree中克隆出來,需要更新的話,加一個tag標志
- 更新div.top節點的狀態,屬性的指向等,並通過render獲取到它的新子節點
- 盡量復用舊子節點span或button來創建新子節點的workInProgress結構
- 把新節點button做為下一個任務,調用requestIdleCallback獲取當前幀所剩余的時間,如果還足夠,就繼續button的任務,重復2-4,否則,就等主線程空閑后再開始循環
- 當子節點submit中沒有child屬性的指向,表示已到達底部。會把diff時產生的effect list會merge回return屬性指向的父節點button上
- 當父節點button沒有兄弟節點時,會一直向上return回根節點,並把每個節點上產生的effect-list合並到根節點上。任務循環結束
我們再來看下源碼中的reconcilation階段:
- workLoop階段
首先獲取到下一個任務的執行優先級,調用一個performUnitOfWork函數進入reconcilation階段的工作, 分為beginWork和completeWork
1、beginWork
beginWork中只是處理節點的不同tag屬性對應不同的更新方法。如 updateClassComponent方法對應的是tag類型是React組件實例。
reconcileChildren 實現對新老節點的diff。其內部主要是調用 reconcileChildFibers 方法。
React中diff是基於同層級節點的,節點的移動/插入/刪除等操作都是在fiber tree的同一層級中進行。
2、 completeWork
completeWork的工作主要是通過新老節點的prop或tag等,收集節點的effect-list。然后向上一層一層循環,merge每個節點的effect-list,當到達根節點#hostRoot時,節點上包含所有的effect-list。並把effect-list傳給pendingcommit,進入commit階段。
- Fiber對生命周期的影響
// reconciliation階段
componentWillMount
componentWillReceiveProps
shouldComponentUpdate
componentWillUpdate
--------------------------------
// commit階段
componentDidMount
componentDidUpdate
componentWillUnmount
在我們之前的文章中也介紹過,在reconciliation階段,生命周期函數會被多次調用,開發者慎重調用這個階段的生命周期。
Reactv16.3已經開始了廢棄計划:
- 16.3版本:引入帶UNSAFE_前綴的3個生命周期函數UNSAFE_componentWillMount,UNSAFE_componentWillReceiveProps和UNSAFE_componentWillUpdate,這個階段新舊6個函數都能用。新引入生命周期函數getDerivedStateFromProps和getSnapshotBeforeUpdate,用來代替componentWillReceiveProps和componentWillUpdate
- 16.3+版本:警告componentWillMount,componentWillReceiveProps和componentWillUpdate即將過時,這個階段新舊6個函數也都能用,只是舊的在DEV環境會報Warning
- 17.0版本:正式廢棄componentWillMount,componentWillReceiveProps和componentWillUpdate,這個階段只有新的帶UNSAFE_前綴的3個函數能用,舊的不會再觸發
本文只是分析了reconciliation階段,不涉及commit階段,感興趣的同學可以研究下。