一年一度的 React 春晚: React Conf 即將到來,不知道今年會不會有什麼驚喜,去年是 React Hooks,前年是 React Fiber...我得趕在 React Conf 之前發布這篇文章:
😲 React Fiber 已經出來這麼久了, 這文章是老酒裝新瓶吧? 對於我來說,通過這篇文章我重新認識了 React Fiber,它不是一個新東西, 它也是老酒裝新瓶,不信你就看吧...
🆕 React Fiber 不是一個新的東西,但在前端領域是第一次廣為認知的應用。
😦 了解它有啥用? React Fiber 代碼很複雜,門檻很高,你不了解它,後面 React 新出的 Killer Feature 你可能就更不能理解了
🤥 我不是升到React v16了嗎? 沒什麼出奇的啊? 真正要體會到 React Fiber 重構效果,可能下個月、可能要等到 v17。v16 只是一個過渡版本,也就是說,現在的React 還是同步渲染的,一直在跳票、不是說今年第二季度就出來了嗎?
😁 不好意思,一不小心又寫得有點長,你就當小說看吧, 代碼都是偽代碼
以下文章大綱
單處理進程調度: Fiber 不是一個新的東西這個黑乎乎的界面應該就是微軟的 DOS 作業系統
微軟 DOS 是一個單任務作業系統, 也稱為』單工作業系統『. 這種作業系統同一個時間只允許運行一個程序. invalid s在《在沒有GUI的時代(只有一個文本界面),人們是怎麼運行多個程序的?》 的回答中將其稱為: '一種壓根沒有任務調度的「殘疾」作業系統'.
在這種系統中,你想執行多個任務,只能等待前一個進程退出,然後再載入一個新的進程。
直到 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執行環境JavaScript 就像單行道
JavaScript 是單線程運行的,而且在瀏覽器環境屁事非常多,它要負責頁面的JS解析和執行、繪製、事件處理、靜態資源加載和處理, 這些任務可以類比上面』進程『。
這裡特指Javascript 引擎是單線程運行的。嚴格來說,頁面繪製由單獨的GUI渲染進程負責,只不過GUI渲染線程和Javascript線程是互斥的. 另外底層的異步操作實際上也是多線程的。
Rendering Performance它只是一個'JavaScript',同時只能做一件事情,這個和 DOS 的單任務作業系統一樣的,事情只能一件一件的幹。要是前面有一個傻叉任務長期霸佔CPU,後面什麼事情都幹不了,瀏覽器會呈現卡死的狀態,這樣的用戶體驗就會非常差。
對於』前端框架『來說,解決這種問題有三個方向:
1️⃣ 優化每個任務,讓它有多快就多快。擠壓CPU運算量2️⃣ 快速響應用戶,讓用戶覺得夠快,不能阻塞用戶的交互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執行權,除了可以讓瀏覽器及時地響應用戶的交互,還有其他好處:
與其一次性操作大量 DOM 節點相比, 分批延時對DOM進行操作,可以得到更好的用戶體驗。這個在《「前端進階」高性能渲染十萬條數據(時間分片)》 以及司徒正美的《React Fiber架構》 都做了相關實驗司徒正美在《React Fiber架構》 也提到:🔴給瀏覽器一點喘息的機會,他會對代碼進行編譯優化(JIT)及進行熱代碼優化,或者對reflow進行修正.這就是為什麼React 需要 Fiber 😏。
何為 Fiber對於 React 來說,Fiber 可以從兩個角度理解:
1. 一種流程控制原語Fiber 也稱協程、或者纖程。筆者第一次接觸這個概念是在學習 Ruby 的時候,Ruby就將協程稱為 Fiber。後來發現很多語言都有類似的機制,例如Lua 的Coroutine, 還有前端開發者比較熟悉的 ES6 新增的Generator。
本文不糾結 Processes, threads, green threads, protothreads, fibers, coroutines: what's the difference?
🔴 其實協程和線程並不一樣,協程本身是沒有並發或者並行能力的(需要配合線程),它只是一種控制流程的讓出機制。要理解協程,你得和普通函數一起來看, 以Generator為例:
普通函數執行的過程中無法被中斷和恢復:
const tasks = []
functionrun() {
let task
while (task = tasks.shift()) {
execute(task)
}
}
```js
而 `Generator` 可以:
```js
const tasks = []
function * run() {
let task
while (task = tasks.shift()) {
// 🔴 判斷是否有高優先級事件需要處理, 有的話讓出控制權if (hasHighPriorityEvent()) {
yield
}
// 處理完高優先級事件後,恢復函數調用棧,繼續執行...
execute(task)
}
}
React Fiber 的思想和協程的概念是契合的: 🔴React 渲染的過程可以被中斷,可以將控制權交回瀏覽器,讓位給高優先級的任務,瀏覽器空閒後再恢復渲染。
那麼現在你應該有以下疑問:
1️⃣ 瀏覽器沒有搶佔的條件, 所以React只能用讓出機制?2️⃣ 怎麼確定有高優先任務要處理,即什麼時候讓出?3️⃣ React 那為什麼不使用 Generator?答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,可以認為事件循環的一次循環)內可能會做什麼事情:
你可以打開 Chrome 開發者工具的Performance標籤,這裡可以詳細看到Javascript的每一幀都執行了什麼任務(Task), 花費了多少時間。
圖片來源: 你應該知道的requestIdleCallback
瀏覽器在一幀內可能會做執行下列任務,而且它們的執行順序基本是固定的:
上面說理想的一幀時間是 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 = functionwork() {
// 在繪製之後被執行if (pendingCallback) {
const now = performance.now()
// 通過now - startTime可以計算出requestAnimationFrame到繪製結束的執行時間// 通過這些數據來計算剩餘時間// 另外還要處理超時(timeout),避免任務被餓死// ...if (hasRemain && noTimeout) {
pendingCallback(deadline)
}
}
}
// ...functionsimpleRequestIdleCallback(callback, timeout) {
requestAnimationFrame(functionanimation() {
// 在繪製之前被執行// 記錄開始時間
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》 也作出了解答。主要有兩個原因:
Generator 不能在棧中間讓出。比如你想在嵌套的函數調用中間讓出, 首先你需要將這些函數都包裝成Generator,另外這種棧中間的讓出處理起來也比較麻煩,難以理解。除了語法開銷,現有的生成器實現開銷比較大,所以不如不用。Generator 是有狀態的, 很難在中間恢復這些狀態。上面理解可能有出入,建議看一下原文
可能都沒看懂,簡單就是 React 嘗試過用 Generator 實現,後來發現很麻煩,就放棄了。
2. 一個執行單元Fiber的另外一種解讀是』纖維『: 這是一種數據結構或者說執行單元。我們暫且不管這個數據結構長什麼樣,🔴將它視作一個執行單元,每次執行完一個'執行單元', React 就會檢查現在還剩多少時間,如果沒有時間就將控制權讓出去.
上文說了,React 沒有使用 Generator 這些語言/語法層面的讓出機制,而是實現了自己的調度讓出機制。這個機制就是基於』Fiber『這個執行單元的,它的過程如下:
假設用戶調用 setState 更新組件, 這個待更新的任務會先放入隊列中, 然後通過 requestIdleCallback 請求瀏覽器調度:
updateQueue.push(updateTask);
requestIdleCallback(performWork, {timeout});
現在瀏覽器有空閒或者超時了就會調用performWork來執行任務:
// 1️⃣ performWork 會拿到一個Deadline,表示剩餘時間functionperformWork(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// 保存第一個工作單元functionworkLoop(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);
}
}
畫個流程圖吧!
React 的Fiber改造Fiber 的核心內容已經介紹完了,現在來進一步看看React 為 Fiber 架構做了哪些改造, 如果你對這部分內容不感興趣可以跳過。
1. 數據結構的調整左側是Virtual DOM,右側可以看作diff的遞歸調用棧
上文中提到 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 和調用棧幀一樣, 保存了節點處理的上下文信息,因為是手動實現的,所以更為可控,我們可以保存在內存中,隨時中斷和恢復。
有了這個數據結構調整,現在可以以迭代的方式來處理這些節點了。來看看 performUnitOfWork 的實現, 它其實就是一個深度優先的遍歷:
/**
* @params fiber 當前需要處理的節點
* @params topWork 本次更新的根節點
*/functionperformUnitOfWork(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回溯即可。
2. 兩個階段的拆分如果你現在使用最新的 React 版本(v16), 使用 Chrome 的 Performance 工具,可以很清晰地看到每次渲染有兩個階段:Reconciliation(協調階段) 和 Commit(提交階段).
我在之前的多篇文章中都有提及: 《自己寫個React渲染器: 以 Remax 為例(用React寫小程序)》
除了Fiber 工作單元的拆分,兩階段的拆分也是一個非常重要的改造,在此之前都是一邊Diff一邊提交的。先來看看這兩者的區別:
⚛️ 協調階段: 可以認為是 Diff 階段, 這個階段可以被中斷, 這個階段會找出所有節點變更,例如節點新增、刪除、屬性變更等等, 這些變更React 稱之為'副作用(Effect)' . 以下生命周期鉤子會在協調階段被調用:
componentWillReceiveProps 廢棄
static getDerivedStateFromProps
getSnapshotBeforeUpdate()
⚛️ 提交階段: 將上一個階段計算出來的需要處理的**副作用(Effects)**一次性執行了。這個階段必須同步執行,不能被打斷. 這些生命周期鉤子在提交階段被執行:
也就是說,在協調階段如果時間片用完,React就會選擇讓出控制權。因為協調階段執行的工作不會導致任何用戶可見的變更,所以在這個階段讓出控制權不會有什麼問題。
需要注意的是:因為協調階段可能被中斷、恢復,甚至重做,⚠️React 協調階段的生命周期鉤子可能會被調用多次!, 例如 componentWillMount 可能會被調用兩次。
因此建議 協調階段的生命周期鉤子不要包含副作用. 索性 React 就廢棄了這部分可能包含副作用的生命周期方法,例如componentWillMount、componentWillMount. v17後我們就不能再用它們了, 所以現有的應用應該儘快遷移.
現在你應該知道為什麼'提交階段'必須同步執行,不能中斷的吧?因為我們要正確地處理各種副作用,包括DOM變更、還有你在componentDidMount中發起的異步請求、useEffect 中定義的副作用... 因為有副作用,所以必須保證按照次序只調用一次,況且會有用戶可以察覺到的變更, 不容差池。
關於為什麼要拆分兩個階段,這裡有更詳細的解釋。
3. Reconcilation接下來就是就是我們熟知的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 進行比對的:
functionbeginWork(fiber: Fiber): Fiber | undefined{
if (fiber.tag === WorkTag.HostComponent) {
// 宿主節點diff
diffHostComponent(fiber)
} elseif (fiber.tag === WorkTag.ClassComponent) {
// 類組件節點diff
diffClassComponent(fiber)
} elseif (fiber.tag === WorkTag.FunctionComponent) {
// 函數組件節點diff
diffFunctionalComponent(fiber)
} else {
// ... 其他類型節點,省略
}
}
宿主節點比對:
functiondiffHostComponent(fiber: Fiber) {
// 新增節點if (fiber.stateNode == null) {
fiber.stateNode = createHostComponent(fiber)
} else {
updateHostComponent(fiber)
}
const newChildren = fiber.pendingProps.children;
// 比對子節點
diffChildren(fiber, newChildren);
}
類組件節點比對也差不多:
functiondiffClassComponent(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
}
子節點比對:
functiondiffChildren(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;
} elseif (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 就會將這些打上標籤的節點應用變更。
4. 雙緩衝WIP 樹構建這種技術類似於圖形化領域的'雙緩存(Double Buffering)'技術, 圖形繪製引擎一般會使用雙緩衝技術,先將圖片繪製到一個緩衝區,再一次性傳遞給屏幕進行顯示,這樣可以防止屏幕抖動,優化渲染性能。
放到React 中,WIP樹就是一個緩衝,它在Reconciliation 完畢後一次性提交給瀏覽器進行渲染。它可以減少內存分配和垃圾回收,WIP 的節點不完全是新的,比如某顆子樹不需要變動,React會克隆復用舊樹中的子樹。
雙緩存技術還有另外一個重要的場景就是異常的處理,比如當一個節點拋出異常,仍然可以繼續沿用舊樹的節點,避免整棵樹掛掉。
Dan 在 Beyond React 16 演講中用了一個非常恰當的比喻,那就是Git 功能分支,你可以將 WIP 樹想像成從舊樹中 Fork 出來的功能分支,你在這新分支中添加或移除特性,即使是操作失誤也不會影響舊的分支。當你這個分支經過了測試和完善,就可以合併到舊分支,將其替換掉. 這或許就是』提交(commit)階段『的提交一詞的來源吧?:
5. 副作用的收集和提交接下來就是將所有打了 Effect 標記的節點串聯起來,這個可以在completeWork中做, 例如:
functioncompleteWork(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
}
} elseif (fiber.nextEffect) {
parent.nextEffect = fiber.nextEffect
}
}
```js
最後了,將所有副作用提交了:
```js
functioncommitAllWork(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出來。
凌波微步同樣來自Link Clark 的 Slider
前面說了一大堆,從作業系統進程調度、到瀏覽器原理、再到合作式調度、最後談了React的基本改造工作, 地老天荒... 就是為了上面的小人可以在練就凌波微步, 它腳下的坑是瀏覽器的調用棧。
React 開啟 Concurrent Mode 之後就不會挖大坑了,而是一小坑一坑的挖,挖一下休息一下,有緊急任務就優先去做。
來源:Flarnie Marchan - Ready for Concurrent Mode?
開啟 Concurrent Mode 後,我們可以得到以下好處(詳見Concurrent Rendering in React):
利用好I/O 操作空閒期或者CPU空閒期,進行一些預渲染。比如離屏(offscreen)不可見的內容,優先級最低,可以讓 React 等到CPU空閒時才去渲染這部分內容。這和瀏覽器的preload等預加載技術差不多。用Suspense 降低加載狀態(load state)的優先級,減少閃屏。比如數據很快返回時,可以不必顯示加載狀態,而是直接顯示出來,避免閃屏;如果超時沒有返回才顯式加載狀態。但是它肯定不是完美的,因為瀏覽器無法實現搶佔式調度,無法阻止開發者做傻事的,開發者可以隨心所欲,想挖多大的坑,就挖多大的坑。
為了共同創造美好的世界,我們要嚴律於己,該做的優化還需要做: 純組件、虛表、簡化組件、緩存...
尤雨溪在今年的Vue Conf一個觀點讓我印象深刻:如果我們可以把更新做得足夠快的話,理論上就不需要時間分片了。
時間分片並沒有降低整體的工作量,該做的還是要做, 因此React 也在考慮利用CPU空閒或者I/O空閒期間做一些預渲染。所以跟尤雨溪說的一樣:React Fiber 本質上是為了解決 React 更新低效率的問題,不要期望 Fiber 能給你現有應用帶來質的提升, 如果性能問題是自己造成的,自己的鍋還是得自己背