寫一篇關於 React Fiber 的文章, 這個 Flag 立了好久,這也是今年的目標之一。 最近的在掘金的文章得到不少關注和鼓勵,給了我不少動力,因此下定決心好好把它寫出來。 我會以最通俗的方式將它講透, 所以這算是一篇科普式的文章。無論你是使用React、仍是Vue,這裏面的思想值得學習學習!javascript
一年一度的 React 春晚: React Conf 即將到來,不知道今年會不會有什麼驚喜,去年是 React Hooks,前年是 React Fiber... 我得趕在 React Conf 以前發佈這篇文章:html
😲 React Fiber 已經出來這麼久了, 這文章是老酒裝新瓶吧? 對於我來講,經過這篇文章我從新認識了 React Fiber,它不是一個新東西, 它也是老酒裝新瓶,不信你就看吧...前端
🆕 React Fiber 不是一個新的東西,但在前端領域是第一次廣爲認知的應用。vue
😦 瞭解它有啥用? React Fiber 代碼很複雜,門檻很高,你不瞭解它,後面 React 新出的 Killer Feature 你可能就更不能理解了java
🤥 我不是升到React v16了嗎? 沒什麼出奇的啊? 真正要體會到 React Fiber 重構效果,可能下個月、可能要等到 v17。v16 只是一個過渡版本,也就是說,如今的React 仍是同步渲染的,一直在跳票、不是說今年第二季度就出來了嗎?react
😁 很差意思,一不當心又寫得有點長,你就當小說看吧, 代碼都是僞代碼git
如下文章大綱github
DOS
操做系統
微軟 DOS
是一個單任務操做系統
, 也稱爲’單工操做系統‘. 這種操做系統同一個時間只容許運行一個程序. invalid s在《在沒有GUI的時代(只有一個文本界面),人們是怎麼運行多個程序的?》 的回答中將其稱爲: '一種壓根沒有任務調度的「殘疾」操做系統'.golang
在這種系統中,你想執行多個任務,只能等待前一個進程退出,而後再載入一個新的進程。web
直到 Windows 3.x,它纔有了真正意義的進程調度器,實現了多進程併發執行。
注意併發和並行不是同一個概念。
現代操做系統都是多任務操做系統. 進程的調度策略若是按照CPU核心數來劃分,能夠分爲單處理器調度和多處理器調度。本文只關注的是單處理器調度,由於它能夠類比JavaScript的運行機制。
🔴說白了,爲了實現進程的併發,操做系統會按照必定的調度策略,將CPU的執行權分配給多個進程,多個進程都有被執行的機會,讓它們交替執行,造成一種「同時在運行」假象, 由於CPU速度太快,人類根本感受不到。實際上在單核的物理環境下同時只能有一個程序在運行。
這讓我想起了「龍珠」中的分身術(小時候看過,說錯了別噴),實質上是一我的,只不過是他運動速度太快,看起來就像分身了. 這就是所謂的併發(Concurrent)(單處理器)。
相比而言, 火影忍者中的分身術,是物理存在的,他們能夠真正實現同時處理多個任務,這就是並行(嚴格地講這是Master-Slave
架構,分身雖然物理存在,但應該沒有獨立的意志)。
因此說🔴並行能夠是併發,而併發不必定是並行,兩種不能劃等號, 並行通常須要物理層面的支持。 關於併發和並行,Go 之父 Rob Pike 有一個很是著名的演講Concurrency is not parallelism
扯遠了,接下來進程怎麼調度就是教科書的內容了。若是讀者在大學認真學過操做系統原理, 你能夠很快理解如下幾種單處理器進程調度策略(我就隨便科普一下,算送的, 若是你很熟悉這塊,能夠跳過):
0️⃣ 先到先得(First-Come-First-Served, FCFS)
這是最簡單的調度策略, 簡單說就是沒有調度。誰先來誰就先執行,執行完畢後就執行下一個。不過若是中間某些進程由於I/O阻塞了,這些進程會掛起移回就緒隊列(說白了就是從新排隊).
FCFS
上面 DOS
的單任務操做系統沒有太大的區別。因此很是好理解,由於生活中處處是這樣的例子:。
FCFS 對短進程
不利。 短進程即執行時間很是短的進程,能夠用飯堂排隊來比喻: 在飯堂排隊打飯的時候,最煩那些一我的打包好好幾份的人,這些人就像長進程
同樣,霸佔着CPU資源,後面排隊只打一份的人會以爲很吃虧,打一份的人會以爲他們優先級應該更高,畢竟他們花的時間很短,反正你打包那麼多份再等一會也是能夠的,何須讓後面那麼多人等這麼久...
FCFS 對I/O密集
不利。I/O密集型進程(這裏特指同步I/O)在進行I/O操做時,會阻塞休眠,這會致使進程從新被放入就緒隊列,等待下一次被寵幸。 能夠類比ZF部門辦業務: 假設 CPU 一個窗口、I/O 一個窗口。在CPU窗口好不容易排到你了,這時候發現一個不符合條件或者漏辦了, 須要去I/O搞一下,Ok 去 I/O窗口排隊,I/O執行完了,到CPU窗口又得從新排隊。對於這些丟三落四的人很不公平...
因此 FCFS 這種原始的策略在單處理器進程調度中並不受歡迎。
1️⃣ 輪轉
這是一種基於時鐘的搶佔策略,這也是搶佔策略中最簡單的一種: 公平地給每個進程必定的執行時間,當時間消耗完畢或阻塞,操做系統就會調度其餘進程,將執行權搶佔過來。
決策模式:
搶佔策略
相對應的有非搶佔策略
,非搶佔策略指的是讓進程運行直到結束、阻塞(如I/O或睡眠)、或者主動讓出控制權;搶佔策略支持中斷正在運行的進程,將主動權掌握在操做系統這裏,不過一般開銷會比較大。
這種調度策略的要點是肯定合適的時間片長度: 太長了,長進程霸佔過久資源,其餘進程會得不到響應(等待執行時間過長),這時候就跟上述的 FCFS
沒什麼區別了; 過短了也很差,由於進程搶佔和切換都是須要成本的, 並且成本不低,時間片過短,時間可能都浪費在上下文切換上了,致使進程幹不了什麼實事。
所以時間片的長度最好符合大部分進程完成一次典型交互所需的時間.
輪轉策略很是容易理解,只不過肯定時間片長度須要傷點腦筋;另外和FCFS
同樣,輪轉策略對I/O進程仍是不公平。
2️⃣ 最短進程優先(Shortest Process Next, SPN)
上面說了先到先得
策略對短進程
不公平,最短進程優先
索性就讓'最短'的進程優先執行,也就是說: 按照進程的預估執行時間對進程進行優先級排序,先執行完短進程,後執行長進程。這是一種非搶佔策略。
這樣可讓短進程能獲得較快的響應。可是怎麼獲取或者評估進程執行時間呢?一是讓程序的提供者提供,這不太靠譜;二是由操做系統來收集進程運行數據,並對它們進程統計分析。例如最簡單的是計算它們的平均運行時間。無論怎麼說都比上面兩種策略要複雜一點。
SPN
的缺陷是: 若是系統有大量的短進程,那麼長進程可能會飢餓得不到響應。
另外由於它不是搶佔性策略, 儘管如今短進程能夠獲得更多的執行機會,可是仍是沒有解決 FCFS
的問題: 一旦長進程獲得CPU資源,得等它執行完,致使後面的進程得不到響應。
3️⃣ 最短剩餘時間(Shortest Remaining Time, SRT)
SRT 進一步優化了SPN,增長了搶佔機制。在 SPN 的基礎上,當一個進程添加到就緒隊列時,操做系統會比較剛添加的新進程和當前正在執行的老進程的‘剩餘時間’,若是新進程剩餘時間更短,新進程就會搶佔老進程。
相比輪轉的搶佔,SRT 沒有中斷處理的開銷。可是在 SPN 的基礎上,操做系統須要記錄進程的歷史執行時間,這是新增的開銷。另外長進程飢餓問題仍是沒有解決。
4️⃣ 最高響應比優先(HRRN)
爲了解決長進程飢餓問題,同時提升進程的響應速率。還有一種最高響應比優先的
策略,首先了解什麼是響應比:
響應比 = (等待執行時間 + 進程執行時間) / 進程執行時間
複製代碼
這種策略會選擇響應比最高的進程優先執行:
5️⃣ 反饋法
SPN、SRT、HRRN都須要對進程時間進行評估和統計,實現比較複雜且須要必定開銷。而反饋法採起的是過後反饋的方式。這種策略下: 每一個進程一開始都有相同的優先級,每次被搶佔(須要配合其餘搶佔策略使用,如輪轉),優先級就會下降一級。所以一般它會根據優先級劃分多個隊列。
舉個例子:
隊列1
隊列2
...
隊列N
複製代碼
新增的任務會推入隊列1
,隊列1
會按照輪轉策略
以一個時間片爲單位進行調度。短進程能夠很快獲得響應,而對於長進程可能一個時間片處理不完,就會被搶佔,放入隊列2
。
隊列2
會在隊列1
任務清空後被執行,有時候低優先級隊列可能會等待好久才被執行,因此通常會給予必定的補償,例如增長執行時間,因此隊列2
的輪轉時間片長度是2。
反饋法仍然可能致使長進程飢餓,因此操做系統能夠統計長進程的等待時間,當等待時間超過必定的閾值,能夠選擇提升它們的優先級。
沒有一種調度策略是萬能的, 它須要考慮不少因素:
這二者在某些狀況下是對立的,提升了響應,可能會減低公平性,致使飢餓。短進程、長進程、I/O進程之間要取得平衡也很是難。
上面這些知識對本文來講已經足夠了,現實世界操做系統的進程調度算法比教科書上說的要複雜的多,有興趣讀者能夠去研究一下 Linux
相關的進程調度算法,這方面的資料也很是多, 例如《Linux進程調度策略的發展和演變》。
JavaScript 是單線程運行的,並且在瀏覽器環境屁事很是多,它要負責頁面的JS解析和執行、繪製、事件處理、靜態資源加載和處理, 這些任務能夠類比上面’進程‘。
這裏特指Javascript 引擎是單線程運行的。 嚴格來講,Javascript 引擎和頁面渲染引擎在同一個
渲染線程
,GUI 渲染和 Javascript執行 二者是互斥的. 另外異步 I/O 操做底層實際上多是多線程的在驅動。
它只是一個'JavaScript',同時只能作一件事情,這個和 DOS
的單任務操做系統同樣的,事情只能一件一件的幹。要是前面有一個傻叉任務長期霸佔CPU,後面什麼事情都幹不了,瀏覽器會呈現卡死的狀態,這樣的用戶體驗就會很是差。
對於’前端框架‘來講,解決這種問題有三個方向:
Vue 選擇的是第1️⃣, 由於對於Vue來講,使用模板
讓它有了不少優化的空間,配合響應式機制可讓Vue能夠精確地進行節點更新, 讀者能夠去看一下今年Vue Conf 尤雨溪的演講,很是棒!;而 React 選擇了2️⃣ 。對於Worker 多線程渲染方案也有人嘗試,要保證狀態和視圖的一致性至關麻煩。
React 爲何要引入 Fiber 架構? 看看下面的火焰圖,這是React V15 下面的一個列表渲染資源消耗狀況。整個渲染花費了130ms, 🔴在這裏面 React 會遞歸比對VirtualDOM樹,找出須要變更的節點,而後同步更新它們, 一鼓作氣。這個過程 React 稱爲 Reconcilation
(中文能夠譯爲協調
).
在 Reconcilation 期間,React 會霸佔着瀏覽器資源,一則會致使用戶觸發的事件得不到響應, 二則會致使掉幀,用戶能夠感知到這些卡頓。
這樣說,你可能沒辦法體會到,經過下面兩個圖片來體會一下(圖片來源於:Dan Abramov 的 Beyond React 16 演講, 推薦看一下👍. 另外很是感謝淡蒼 將一個相似的DEMO 分享在了 CodeSandbox上🎉,你們自行體驗):
同步模式下的 React:
優化後的 Concurrent
模式下的 React:
React 的 Reconcilation 是CPU密集型的操做, 它就至關於咱們上面說的’長進程‘。因此初衷和進程調度同樣,咱們要讓高優先級的進程或者短進程優先運行,不能讓長進程長期霸佔資源。
因此React 是怎麼優化的? 劃重點, 🔴爲了給用戶製造一種應用很快的'假象',咱們不能讓一個程序長期霸佔着資源. 你能夠將瀏覽器的渲染、佈局、繪製、資源加載(例如HTML解析)、事件響應、腳本執行視做操做系統的'進程',咱們須要經過某些調度策略合理地分配CPU資源,從而提升瀏覽器的用戶響應速率, 同時兼顧任務執行效率。
🔴因此 React 經過Fiber 架構,讓本身的Reconcilation 過程變成可被中斷。 '適時'地讓出CPU執行權,除了可讓瀏覽器及時地響應用戶的交互,還有其餘好處:
這就是爲何React 須要 Fiber 😏。
對於 React 來講,Fiber 能夠從兩個角度理解:
Fiber 也稱協程、或者纖程。 筆者第一次接觸這個概念是在學習 Ruby 的時候,Ruby就將協程稱爲 Fiber。後來發現不少語言都有相似的機制,例如Lua 的Coroutine
, 還有前端開發者比較熟悉的 ES6
新增的Generator
。
本文不糾結 Processes, threads, green threads, protothreads, fibers, coroutines: what's the difference?
🔴 其實協程和線程並不同,協程自己是沒有併發或者並行能力的(須要配合線程),它只是一種控制流程的讓出機制。要理解協程,你得和普通函數一塊兒來看, 以Generator爲例:
普通函數執行的過程當中沒法被中斷和恢復:
const tasks = []
function run() {
let task
while (task = tasks.shift()) {
execute(task)
}
}
複製代碼
而 Generator
能夠:
const tasks = []
function * run() {
let task
while (task = tasks.shift()) {
// 🔴 判斷是否有高優先級事件須要處理, 有的話讓出控制權
if (hasHighPriorityEvent()) {
yield
}
// 處理完高優先級事件後,恢復函數調用棧,繼續執行...
execute(task)
}
}
複製代碼
React Fiber 的思想和協程的概念是契合的: 🔴React 渲染的過程能夠被中斷,能夠將控制權交回瀏覽器,讓位給高優先級的任務,瀏覽器空閒後再恢復渲染。
那麼如今你應該有如下疑問:
答1️⃣: 沒錯, 主動讓出機制
一是瀏覽器中沒有相似進程的概念,’任務‘之間的界限很模糊,沒有上下文,因此不具有中斷/恢復的條件。二是沒有搶佔的機制,咱們沒法中斷一個正在執行的程序。
因此咱們只能採用相似協程這樣控制權讓出機制。這個和上文提到的進程調度策略都不一樣,它有更一個專業的名詞:合做式調度(Cooperative Scheduling), 相對應的有搶佔式調度(Preemptive Scheduling)
這是一種’契約‘調度,要求咱們的程序和瀏覽器緊密結合,互相信任。好比能夠由瀏覽器給咱們分配執行時間片(經過requestIdleCallback
實現, 下文會介紹),咱們要按照約定在這個時間內執行完畢,並將控制權還給瀏覽器。
這種調度方式頗有趣,你會發現這是一種身份的對調,之前咱們是老子,想怎麼執行就怎麼執行,執行多久就執行多久; 如今爲了咱們共同的用戶體驗統一了戰線, 一切聽由瀏覽器指揮調度,瀏覽器是老子,咱們要跟瀏覽器申請執行權,並且這個執行權有期限,借了後要按照約定歸還給瀏覽器。
固然你超時不還瀏覽器也拿你沒辦法 🤷... 合做式調度的缺點就在於此,全憑自律,用戶要挖大坑,誰都攔不住。
答2️⃣: requestIdleCallback API
上面代碼示例中的 hasHighPriorityEvent()
在目前瀏覽器中是沒法實現的,咱們沒辦法判斷當前是否有更高優先級的任務等待被執行。
只能換一種思路,經過超時檢查的機制來讓出控制權。解決辦法是: 肯定一個合理的運行時長,而後在合適的檢查點檢測是否超時(好比每執行一個小任務),若是超時就中止執行,將控制權交換給瀏覽器。
舉個例子,爲了讓視圖流暢地運行,能夠按照人類能感知到最低限度每秒60幀的頻率劃分時間片,這樣每一個時間片就是 16ms。
其實瀏覽器提供了相關的接口 —— requestIdleCallback
API:
window.requestIdleCallback(
callback: (dealine: IdleDeadline) => void,
option?: {timeout: number}
)
複製代碼
IdleDeadline
的接口以下:
interface IdleDealine {
didTimeout: boolean // 表示任務執行是否超過約定時間
timeRemaining(): DOMHighResTimeStamp // 任務可供執行的剩餘時間
}
複製代碼
單從名字上理解的話, requestIdleCallback
的意思是讓瀏覽器在'有空'的時候就執行咱們的回調,這個回調會傳入一個期限,表示瀏覽器有多少時間供咱們執行, 爲了避免耽誤事,咱們最好在這個時間範圍內執行完畢。
那瀏覽器何時有空?
咱們先來看一下瀏覽器在一幀(Frame,能夠認爲事件循環的一次循環)內可能會作什麼事情:
瀏覽器在一幀內可能會作執行下列任務,並且它們的執行順序基本是固定的:
上面說理想的一幀時間是 16ms
(1000ms / 60),若是瀏覽器處理完上述的任務(佈局和繪製以後),還有盈餘時間,瀏覽器就會調用 requestIdleCallback
的回調。例如
可是在瀏覽器繁忙的時候,可能不會有盈餘時間,這時候requestIdleCallback
回調可能就不會被執行。 爲了不餓死,能夠經過requestIdleCallback的第二個參數指定一個超時時間。
另外不建議在
requestIdleCallback
中進行DOM
操做,由於這可能致使樣式從新計算或從新佈局(好比操做DOM後立刻調用getBoundingClientRect
),這些時間很難預估的,頗有可能致使回調執行超時,從而掉幀。
目前 requestIdleCallback
目前只有Chrome支持。因此目前 React 本身實現了一個。它利用MessageChannel
模擬將回調延遲到'繪製操做'以後執行:
const el = document.getElementById('root')
const btn = document.getElementById('btn')
const ch = new MessageChannel()
let pendingCallback
let startTime
let timeout
ch.port2.onmessage = function work() {
// 在繪製以後被執行
if (pendingCallback) {
const now = performance.now()
// 經過now - startTime能夠計算出requestAnimationFrame到繪製結束的執行時間
// 經過這些數據來計算剩餘時間
// 另外還要處理超時(timeout),避免任務被餓死
// ...
if (hasRemain && noTimeout) {
pendingCallback(deadline)
}
}
}
// ...
function simpleRequestIdleCallback(callback, timeout) {
requestAnimationFrame(function animation() {
// 在繪製以前被執行
// 記錄開始時間
startTime = performance.now()
timeout = timeout
dosomething()
// 調度回調到繪製結束後執行
pendingCallback = callback
ch.port1.postMessage('hello')
})
}
複製代碼
任務優先級
上面說了,爲了不任務被餓死,能夠設置一個超時時間. 這個超時時間不是死的,低優先級的能夠慢慢等待, 高優先級的任務應該率先被執行. 目前 React 預約義了 5 個優先級, 這個我在[《談談React事件機制和將來(react-events)》]中也介紹過:
Immediate
(-1) - 這個優先級的任務會同步執行, 或者說要立刻執行且不能中斷UserBlocking
(250ms) 這些任務通常是用戶交互的結果, 須要即時獲得反饋Normal
(5s) 應對哪些不須要當即感覺到的任務,例如網絡請求Low
(10s) 這些任務能夠放後,可是最終應該獲得執行. 例如分析通知Idle
(沒有超時時間) 一些沒有必要作的任務 (e.g. 好比隱藏的內容), 可能會被餓死答3️⃣: 太麻煩
官方在《Fiber Principles: Contributing To Fiber》 也做出瞭解答。主要有兩個緣由:
上面理解可能有出入,建議看一下原文
可能都沒看懂,簡單就是 React 嘗試過用 Generator 實現,後來發現很麻煩,就放棄了。
Fiber的另一種解讀是’纖維‘: 這是一種數據結構或者說執行單元。咱們暫且無論這個數據結構長什麼樣,🔴將它視做一個執行單元,每次執行完一個'執行單元', React 就會檢查如今還剩多少時間,若是沒有時間就將控制權讓出去.
上文說了,React 沒有使用 Generator 這些語言/語法層面的讓出機制,而是實現了本身的調度讓出機制。這個機制就是基於’Fiber‘這個執行單元的,它的過程以下:
假設用戶調用 setState
更新組件, 這個待更新的任務會先放入隊列中, 而後經過 requestIdleCallback
請求瀏覽器調度:
updateQueue.push(updateTask);
requestIdleCallback(performWork, {timeout});
複製代碼
如今瀏覽器有空閒或者超時了就會調用performWork
來執行任務:
// 1️⃣ performWork 會拿到一個Deadline,表示剩餘時間
function performWork(deadline) {
// 2️⃣ 循環取出updateQueue中的任務
while (updateQueue.length > 0 && deadline.timeRemaining() > ENOUGH_TIME) {
workLoop(deadline);
}
// 3️⃣ 若是在本次執行中,未能將全部任務執行完畢,那就再請求瀏覽器調度
if (updateQueue.length > 0) {
requestIdleCallback(performWork);
}
}
複製代碼
workLoop
的工做大概猜到了,它會從更新隊列(updateQueue)中彈出更新任務來執行,每執行完一個‘執行單元
‘,就檢查一下剩餘時間是否充足,若是充足就進行執行下一個執行單元
,反之則中止執行,保存現場,等下一次有執行權時恢復:
// 保存當前的處理現場
let nextUnitOfWork: Fiber | undefined // 保存下一個須要處理的工做單元
let topWork: Fiber | undefined // 保存第一個工做單元
function workLoop(deadline: IdleDeadline) {
// updateQueue中獲取下一個或者恢復上一次中斷的執行單元
if (nextUnitOfWork == null) {
nextUnitOfWork = topWork = getNextUnitOfWork();
}
// 🔴 每執行完一個執行單元,檢查一次剩餘時間
// 若是被中斷,下一次執行仍是從 nextUnitOfWork 開始處理
while (nextUnitOfWork && deadline.timeRemaining() > ENOUGH_TIME) {
// 下文咱們再看performUnitOfWork
nextUnitOfWork = performUnitOfWork(nextUnitOfWork, topWork);
}
// 提交工做,下文會介紹
if (pendingCommit) {
commitAllWork(pendingCommit);
}
}
複製代碼
畫個流程圖吧!
Fiber 的核心內容已經介紹完了,如今來進一步看看React 爲 Fiber 架構作了哪些改造, 若是你對這部份內容不感興趣能夠跳過。
上文中提到 React 16 以前,Reconcilation 是同步的、遞歸執行的。也就是說這是基於函數’調用棧‘的Reconcilation算法,所以一般也稱它爲Stack Reconcilation
. 你能夠經過這篇文章《從Preact中瞭解React組件和hooks基本原理》 來回顧一下歷史。
棧挺好的,代碼量少,遞歸容易理解, 至少比如今的 React Fiber架構好理解😂, 遞歸很是適合樹這種嵌套數據結構的處理。
只不過這種依賴於調用棧的方式不能隨意中斷、也很難被恢復, 不利於異步處理。 這種調用棧,不是程序所能控制的, 若是你要恢復遞歸現場,可能須要從頭開始, 恢復到以前的調用棧。
所以首先咱們須要對React現有的數據結構進行調整,模擬函數調用棧
, 將以前須要遞歸進行處理的事情分解成增量的執行單元,將遞歸轉換成迭代.
React 目前的作法是使用鏈表
, 每一個 VirtualDOM 節點內部如今使用 Fiber
表示, 它的結構大概以下:
export type Fiber = {
// Fiber 類型信息
type: any,
// ...
// ⚛️ 鏈表結構
// 指向父節點,或者render該節點的組件
return: Fiber | null,
// 指向第一個子節點
child: Fiber | null,
// 指向下一個兄弟節點
sibling: Fiber | null,
}
複製代碼
用圖片來展現這種關係會更直觀一些:
使用鏈表結構只是一個結果,而不是目的,React 開發者一開始的目的是衝着模擬調用棧去的。這個不少關於Fiber 的文章都有說起, 關於調用棧的詳細定義參見Wiki:
調用棧最常常被用於存放子程序的返回地址。在調用任何子程序時,主程序都必須暫存子程序運行完畢後應該返回到的地址。所以,若是被調用的子程序還要調用其餘的子程序,其自身的返回地址就必須存入調用棧,在其自身運行完畢後再行取回。除了返回地址,還會保存
本地變量
、函數參數
、環境傳遞
(Scope?)
React Fiber 也被稱爲虛擬棧幀(Virtual Stack Frame), 你能夠拿它和函數調用棧類比一下, 二者結構很是像:
函數調用棧 | Fiber | |
---|---|---|
基本單位 | 函數 | Virtual DOM 節點 |
輸入 | 函數參數 | Props |
本地狀態 | 本地變量 | State |
輸出 | 函數返回值 | React Element |
下級 | 嵌套函數調用 | 子節點(child) |
上級引用 | 返回地址 | 父節點(return) |
Fiber 和調用棧幀同樣, 保存了節點處理的上下文信息,由於是手動實現的,因此更爲可控,咱們能夠保存在內存中,隨時中斷和恢復。
有了這個數據結構調整,如今能夠以迭代的方式來處理這些節點了。來看看 performUnitOfWork
的實現, 它其實就是一個深度優先的遍歷:
/** * @params fiber 當前須要處理的節點 * @params topWork 本次更新的根節點 */
function performUnitOfWork(fiber: Fiber, topWork: Fiber) {
// 對該節點進行處理
beginWork(fiber);
// 若是存在子節點,那麼下一個待處理的就是子節點
if (fiber.child) {
return fiber.child;
}
// 沒有子節點了,上溯查找兄弟節點
let temp = fiber;
while (temp) {
completeWork(temp);
// 到頂層節點了, 退出
if (temp === topWork) {
break
}
// 找到,下一個要處理的就是兄弟節點
if (temp.sibling) {
return temp.sibling;
}
// 沒有, 繼續上溯
temp = temp.return;
}
}
複製代碼
你能夠配合上文的 workLoop
一塊兒看,Fiber 就是咱們所說的工做單元,performUnitOfWork
負責對 Fiber
進行操做,並按照深度遍歷的順序返回下一個 Fiber。
由於使用了鏈表結構,即便處理流程被中斷了,咱們隨時能夠從上次未處理完的Fiber
繼續遍歷下去。
整個迭代順序和以前遞歸的同樣, 下圖假設在 div.app
進行了更新:
text(hello)
中斷了,那麼下一次就會從 p
節點開始處理
這個數據結構調整還有一個好處,就是某些節點異常時,咱們能夠打印出完整的’節點棧‘,只須要沿着節點的return
回溯便可。
若是你如今使用最新的 React 版本(v16), 使用 Chrome 的 Performance 工具,能夠很清晰地看到每次渲染有兩個階段:Reconciliation
(協調階段) 和 Commit
(提交階段).
我在以前的多篇文章中都有說起: 《本身寫個React渲染器: 以 Remax 爲例(用React寫小程序)》
除了Fiber 工做單元的拆分,兩階段的拆分也是一個很是重要的改造,在此以前都是一邊Diff一邊提交的。先來看看這二者的區別:
⚛️ 協調階段: 能夠認爲是 Diff 階段, 這個階段能夠被中斷, 這個階段會找出全部節點變動,例如節點新增、刪除、屬性變動等等, 這些變動React 稱之爲'反作用
(Effect)' . 如下生命週期鉤子會在協調階段被調用:
⚛️ 提交階段: 將上一個階段計算出來的須要處理的**反作用(Effects)**一次性執行了。這個階段必須同步執行,不能被打斷. 這些生命週期鉤子在提交階段被執行:
也就是說,在協調階段若是時間片用完,React就會選擇讓出控制權。由於協調階段執行的工做不會致使任何用戶可見的變動,因此在這個階段讓出控制權不會有什麼問題。
須要注意的是:由於協調階段可能被中斷、恢復,甚至重作,⚠️React 協調階段的生命週期鉤子可能會被調用屢次!, 例如 componentWillMount
可能會被調用兩次。
所以建議 協調階段的生命週期鉤子不要包含反作用. 索性 React 就廢棄了這部分可能包含反作用的生命週期方法,例如componentWillMount
、componentWillUpdate
. v17後咱們就不能再用它們了, 因此現有的應用應該儘快遷移.
如今你應該知道爲何'提交階段'必須同步執行,不能中斷的吧? 由於咱們要正確地處理各類反作用,包括DOM變動、還有你在componentDidMount
中發起的異步請求、useEffect 中定義的反作用... 由於有反作用,因此必須保證按照次序只調用一次,何況會有用戶能夠察覺到的變動, 不容差池。
關於爲何要拆分兩個階段,這裏有更詳細的解釋。
接下來就是就是咱們熟知的Reconcilation
(爲了方便理解,本文不區分Diff和Reconcilation, 二者是同一個東西)階段了. 思路和 Fiber 重構以前差異不大, 只不過這裏不會再遞歸去比對、並且不會立刻提交變動。
首先再進一步看一下Fiber
的結構:
interface Fiber {
/** * ⚛️ 節點的類型信息 */
// 標記 Fiber 類型, 例如函數組件、類組件、宿主組件
tag: WorkTag,
// 節點元素類型, 是具體的類組件、函數組件、宿主組件(字符串)
type: any,
/** * ⚛️ 結構信息 */
return: Fiber | null,
child: Fiber | null,
sibling: Fiber | null,
// 子節點的惟一鍵, 即咱們渲染列表傳入的key屬性
key: null | string,
/** * ⚛️ 節點的狀態 */
// 節點實例(狀態):
// 對於宿主組件,這裏保存宿主組件的實例, 例如DOM節點。
// 對於類組件來講,這裏保存類組件的實例
// 對於函數組件說,這裏爲空,由於函數組件沒有實例
stateNode: any,
// 新的、待處理的props
pendingProps: any,
// 上一次渲染的props
memoizedProps: any, // The props used to create the output.
// 上一次渲染的組件狀態
memoizedState: any,
/** * ⚛️ 反作用 */
// 當前節點的反作用類型,例如節點更新、刪除、移動
effectTag: SideEffectTag,
// 和節點關係同樣,React 一樣使用鏈表來將全部有反作用的Fiber鏈接起來
nextEffect: Fiber | null,
/** * ⚛️ 替身 * 指向舊樹中的節點 */
alternate: Fiber | null,
}
複製代碼
Fiber 包含的屬性能夠劃分爲 5 個部分:
🆕 結構信息 - 這個上文咱們已經見過了,Fiber 使用鏈表的形式來表示節點在樹中的定位
節點類型信息 - 這個也容易理解,tag表示節點的分類、type 保存具體的類型值,如div、MyComp
節點的狀態 - 節點的組件實例、props、state等,它們將影響組件的輸出
🆕 反作用 - 這個也是新東西. 在 Reconciliation 過程當中發現的'反作用'(變動需求)就保存在節點的effectTag
中(想象爲打上一個標記). 那麼怎麼將本次渲染的全部節點反作用都收集起來呢? 這裏也使用了鏈表結構,在遍歷過程當中React會將全部有‘反作用’的節點都經過nextEffect
鏈接起來
🆕 替身 - React 在 Reconciliation 過程當中會構建一顆新的樹(官方稱爲workInProgress tree,WIP樹),能夠認爲是一顆表示當前工做進度的樹。還有一顆表示已渲染界面的舊樹,React就是一邊和舊樹比對,一邊構建WIP樹的。 alternate 指向舊樹的同等節點。
如今能夠放大看看beginWork
是如何對 Fiber 進行比對的:
function beginWork(fiber: Fiber): Fiber | undefined {
if (fiber.tag === WorkTag.HostComponent) {
// 宿主節點diff
diffHostComponent(fiber)
} else if (fiber.tag === WorkTag.ClassComponent) {
// 類組件節點diff
diffClassComponent(fiber)
} else if (fiber.tag === WorkTag.FunctionComponent) {
// 函數組件節點diff
diffFunctionalComponent(fiber)
} else {
// ... 其餘類型節點,省略
}
}
複製代碼
宿主節點比對:
function diffHostComponent(fiber: Fiber) {
// 新增節點
if (fiber.stateNode == null) {
fiber.stateNode = createHostComponent(fiber)
} else {
updateHostComponent(fiber)
}
const newChildren = fiber.pendingProps.children;
// 比對子節點
diffChildren(fiber, newChildren);
}
複製代碼
類組件節點比對也差很少:
function diffClassComponent(fiber: Fiber) {
// 建立組件實例
if (fiber.stateNode == null) {
fiber.stateNode = createInstance(fiber);
}
if (fiber.hasMounted) {
// 調用更新前生命週期鉤子
applybeforeUpdateHooks(fiber)
} else {
// 調用掛載前生命週期鉤子
applybeforeMountHooks(fiber)
}
// 渲染新節點
const newChildren = fiber.stateNode.render();
// 比對子節點
diffChildren(fiber, newChildren);
fiber.memoizedState = fiber.stateNode.state
}
複製代碼
子節點比對:
function diffChildren(fiber: Fiber, newChildren: React.ReactNode) {
let oldFiber = fiber.alternate ? fiber.alternate.child : null;
// 全新節點,直接掛載
if (oldFiber == null) {
mountChildFibers(fiber, newChildren)
return
}
let index = 0;
let newFiber = null;
// 新子節點
const elements = extraElements(newChildren)
// 比對子元素
while (index < elements.length || oldFiber != null) {
const prevFiber = newFiber;
const element = elements[index]
const sameType = isSameType(element, oldFiber)
if (sameType) {
newFiber = cloneFiber(oldFiber, element)
// 更新關係
newFiber.alternate = oldFiber
// 打上Tag
newFiber.effectTag = UPDATE
newFiber.return = fiber
}
// 新節點
if (element && !sameType) {
newFiber = createFiber(element)
newFiber.effectTag = PLACEMENT
newFiber.return = fiber
}
// 刪除舊節點
if (oldFiber && !sameType) {
oldFiber.effectTag = DELETION;
oldFiber.nextEffect = fiber.nextEffect
fiber.nextEffect = oldFiber
}
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index == 0) {
fiber.child = newFiber;
} else if (prevFiber && element) {
prevFiber.sibling = newFiber;
}
index++
}
}
複製代碼
上面的代碼很粗糙地還原了 Reconciliation 的過程, 可是對於咱們理解React的基本原理已經足夠了.
這裏引用一下Youtube: Lin Clark presentation in ReactConf 2017 的Slide,來還原 Reconciliation 的過程. Lin Clark 這個演講太經典了,幾乎全部介紹 React Fiber 的文章都會引用它的Slide. 偷個懶,我也用下:
這篇文章《React Fiber》 用文字版解釋了Link Clark Slide.
上圖是 Reconciliation 完成後的狀態,左邊是舊樹,右邊是WIP樹。對於須要變動的節點,都打上了'標籤'。 在提交階段,React 就會將這些打上標籤的節點應用變動。
WIP 樹
構建這種技術相似於圖形化領域的'雙緩存(Double Buffering)'技術, 圖形繪製引擎通常會使用雙緩衝技術,先將圖片繪製到一個緩衝區,再一次性傳遞給屏幕進行顯示,這樣能夠防止屏幕抖動,優化渲染性能。
放到React 中,WIP樹就是一個緩衝,它在Reconciliation 完畢後一次性提交給瀏覽器進行渲染。它能夠減小內存分配和垃圾回收,WIP 的節點不徹底是新的,好比某顆子樹不須要變更,React會克隆複用舊樹中的子樹。
雙緩存技術還有另一個重要的場景就是異常的處理,好比當一個節點拋出異常,仍然能夠繼續沿用舊樹的節點,避免整棵樹掛掉。
Dan 在 Beyond React 16 演講中用了一個很是恰當的比喻,那就是Git 功能分支,你能夠將 WIP 樹想象成從舊樹中 Fork 出來的功能分支,你在這新分支中添加或移除特性,即便是操做失誤也不會影響舊的分支。當你這個分支通過了測試和完善,就能夠合併到舊分支,將其替換掉. 這或許就是’提交(commit)階段‘的提交一詞的來源吧?:
接下來就是將全部打了 Effect 標記的節點串聯起來,這個能夠在completeWork
中作, 例如:
function completeWork(fiber) {
const parent = fiber.return
// 到達頂端
if (parent == null || fiber === topWork) {
pendingCommit = fiber
return
}
if (fiber.effectTag != null) {
if (parent.nextEffect) {
parent.nextEffect.nextEffect = fiber
} else {
parent.nextEffect = fiber
}
} else if (fiber.nextEffect) {
parent.nextEffect = fiber.nextEffect
}
}
複製代碼
最後了,將全部反作用提交了:
function commitAllWork(fiber) {
let next = fiber
while(next) {
if (fiber.effectTag) {
// 提交,偷一下懶,這裏就不展開了
commitWork(fiber)
}
next = fiber.nextEffect
}
// 清理現場
pendingCommit = nextUnitOfWork = topWork = null
}
複製代碼
上文只是介紹了簡單的中斷和恢復機制,咱們從哪裏跌倒就從哪裏站起來,在哪一個節點中斷就從哪一個節點繼續處理下去。 也就是說,到目前爲止:⚠️更新任務仍是串行執行的,咱們只是將整個過程碎片化了. 對於那些須要優先處理的更新任務仍是會被阻塞。我我的以爲這纔是 React Fiber 中最難處理的一部分。
實際狀況是,在 React 獲得控制權後,應該優先處理高優先級的任務。也就是說中斷時正在處理的任務,在恢復時會讓位給高優先級任務,本來中斷的任務可能會被放棄或者重作。
可是若是不按順序執行任務,可能會致使先後的狀態不一致。 好比低優先級任務將 a
設置爲0,而高優先級任務將 a
遞增1, 兩個任務的執行順序會影響最終的渲染結果。所以要讓高優先級任務插隊, 首先要保證狀態更新的時序。
解決辦法是: 全部更新任務按照順序插入一個隊列, 狀態必須按照插入順序進行計算,但任務能夠按優先級順序執行, 例如:
紅色表示高優先級任務。要計算它的狀態必須基於前序任務計算出來的狀態, 從而保證狀態的最終一致性:
最終紅色的高優先級任務 C
執行時的狀態值是a=5,b=3
. 在恢復控制權時,會按照優先級先執行 C
, 前面的A
、 B
暫時跳過
上面被跳過任務不會被移除,在執行完高優先級任務後它們仍是會被執行的。由於不一樣的更新任務影響的節點樹範圍多是不同的,舉個例子 a
、b
可能會影響 Foo
組件樹,而 c
會影響 Bar
組件樹。因此爲了保證視圖的最終一致性, 全部更新任務都要被執行。
首先 C
先被執行,它更新了 Foo
組件
接着執行 A
任務,它更新了Foo
和 Bar
組件,因爲 C
已經以最終狀態a=5, b=3
更新了Foo
組件,這裏能夠作一下性能優化,直接複用C的更新結果, 沒必要觸發從新渲染。所以 A
僅需更新 Bar
組件便可。
接着執行 B
,同理能夠複用 Foo 更新結果。
道理講起來都很簡單,React Fiber 實際上很是複雜,無論執行的過程怎樣拆分、以什麼順序執行,最重要的是保證狀態的一致性和視圖的一致性,這給了 React 團隊很大的考驗,以至於如今都沒有正式release出來。
前面說了一大堆,從操做系統進程調度、到瀏覽器原理、再到合做式調度、最後談了React的基本改造工做, 地老天荒... 就是爲了上面的小人能夠在練就凌波微步, 它腳下的坑是瀏覽器的調用棧。
React 開啓 Concurrent Mode
以後就不會挖大坑了,而是一小坑一坑的挖,挖一下休息一下,有緊急任務就優先去作。
開啓 Concurrent Mode
後,咱們能夠獲得如下好處(詳見Concurrent Rendering in React):
Suspense
下降加載狀態(load state)的優先級,減小閃屏。 好比數據很快返回時,能夠沒必要顯示加載狀態,而是直接顯示出來,避免閃屏;若是超時沒有返回才顯式加載狀態。可是它確定不是完美的,由於瀏覽器沒法實現搶佔式調度,沒法阻止開發者作傻事的,開發者能夠爲所欲爲,想挖多大的坑,就挖多大的坑。
爲了共同創造美好的世界,咱們要嚴律於己,該作的優化還須要作: 純組件、虛表、簡化組件、緩存...
尤雨溪在今年的Vue Conf一個觀點讓我印象深入:若是咱們能夠把更新作得足夠快的話,理論上就不須要時間分片了。
時間分片並無下降總體的工做量,該作的仍是要作, 所以React 也在考慮利用CPU空閒或者I/O空閒期間作一些預渲染。因此跟尤雨溪說的同樣:React Fiber 本質上是爲了解決 React 更新低效率的問題,不要指望 Fiber 能給你現有應用帶來質的提高, 若是性能問題是本身形成的,本身的鍋仍是得本身背.
本文之因此能成文,離不開社區上優質的開源項目和資料。
迷你 Fiber 實現:
React 如今的代碼庫太複雜了! 並且一直在變更和推翻本身,Hax 在 《爲何社區裏那些類 React 庫至今沒有選擇實現 Fiber 架構?》 就開玩笑說: Fiber 性價比略低... 到了這個階段,競品太多,facebook 就搞一個 fiber 來做爲護城河……
這種工程量不是通常團隊能Hold住的, 若是你只是想了解 Fiber,去讀 React 的源碼性價比也很低,不妨看看這些 Mini 版實現, 感覺其精髓,不求甚解:
優秀的文章 & 演講
本文只是對React Fiber進行了簡單的科普,實際上React 的實現比本文複雜的多,若是你想深刻理解React Fiber的,下面這些文章不容錯過:
自薦React 相關文章
回顧一下今年寫的關於 React 的相關文章
Concurrent模式預覽(推薦):
往期文章:
本文講了 React 如何優化 CPU 問題,React 野心遠不在於此, I/O 方向的優化也在實踐,例如 Suspend... 還有不少沒講完,後面的文章見!
問卷調查,你以爲這種文章風格怎樣?
多選,下方評論,👍點贊走起
改了一個正經一點的網名:sx(傻叉) -> 荒山 ⛰