跳至主要内容

課程:JavaScript 與 React 底層原理 第 13 堂:Fiber 架構基礎

40 Stack Reconciler 的問題

想像一下,你正在使用一個標籤(Tag)系統非常複雜的筆記軟體。當你在搜尋框輸入一個字母時,畫面突然僵住了約 0.5 秒,這段時間內你打的字完全沒出現在螢幕上,滑鼠點擊也沒有反應。隨後,畫面突然「跳」了一下,所有的字和搜尋結果才一次噴出來。

這種令人沮喪的體驗,在開發者圈子裡有個專門的術語叫 Jank(卡頓)。在 React 16 以前,這種現象非常難以根治。為什麼即使 React 已經有了 Virtual DOM 和高效的 Diffing 演算法,在面對大型應用程式時仍然會顯得力不從心?這就要從 React 舊時代的渲染引擎——Stack Reconciler 的結構性缺陷說起。

回到原點:Call Stack 與單執行緒的宿命

要理解 Stack Reconciler 的問題,我們必須先串連回我們在 Topic 1.2 學過的 Call Stack(呼叫堆疊)

JavaScript 是單執行緒(Single-threaded)的語言,這意味著它在同一個時間點只能做一件事。當 JavaScript 引擎在執行一段程式碼時,它會使用 Call Stack 來追蹤函數的呼叫順序。如果函數 A 呼叫了函數 B,那麼 B 就會被推入堆疊頂端,直到 B 執行完畢被彈出,A 才能繼續執行。

遞迴渲染:停不下來的火車

在 React 15 及更早的版本中,協調(Reconciliation)過程是基於遞迴的。當你呼叫 setState 觸發更新時,React 會從根節點(Root)開始,同步地遍歷整棵 Virtual DOM 樹,比對新舊節點的差異,並計算出需要變更的部分。

這個過程為什麼被稱為 Stack Reconciler?因為它是利用 JavaScript 引擎原生的 Call Stack 來處理這棵樹的遍歷。

請試著預測以下場景: 假設你有一個包含 2000 個元件的清單,每個元件都有複雜的邏輯。當你點擊一個按鈕觸發全域狀態更新時,React 開始進行遞迴。這時候 Call Stack 會發生什麼事?

  • 揭曉: Call Stack 會被瞬間填滿成千上萬個 mountComponentupdateComponent 的 Stack Frames。由於 JavaScript 引擎必須處理完堆疊中所有的任務後才能釋放主執行緒,這意味著一旦遞迴開始,就沒有任何力量可以讓它中斷,直到整棵樹比對完成為止。

這就像一列一旦啟動就沒有煞車、也沒有停靠站的特快火車,它必須抵達終點站(整棵樹處理完)才能讓乘客(其他任務)下車。

16.6ms 的生存預算

為了理解這列「停不下來的火車」對使用者的真實影響,我們必須引入瀏覽器渲染頻率的概念。

現代顯示器的刷新率通常是 60Hz,這意味著瀏覽器每秒鐘會重新繪製畫面 60 次。換算下來,每一幀(Frame)的處理時間大約只有 16.6 毫秒(1000ms / 60)

在瀏覽器的事件循環(Event Loop)中,這 16.6ms 的預算並不是全部給 JavaScript 使用的。在一幀的生命週期內,瀏覽器需要完成以下工作:

  1. 處理使用者輸入(點擊、鍵盤事件)。
  2. 執行 JavaScript(React 就在這裡運行)。
  3. 解析 CSS、計算佈局(Layout)
  4. 繪製(Paint)並合成(Composite)畫面

當 JavaScript 逾時:掉幀(Dropped Frames)

如果 React 的 Stack Reconciler 處理那棵包含 2000 個元件的樹需要 100ms,會發生什麼事?

這 100ms 期間,JavaScript 引擎一直霸佔著主執行緒,Call Stack 塞滿了遞迴函數。瀏覽器想要處理使用者的打字事件?沒辦法,主執行緒正忙著。瀏覽器想要進行下一幀的 Layout 和 Paint?沒辦法,主執行緒還沒放人。

結果就是:瀏覽器被迫跳過了中間的 5 到 6 幀。使用者看到的畫面會停在 100ms 前的狀態,這就是所謂的「掉幀」。在視覺上,這會讓動畫變得斷斷續續,讓互動產生明顯的延遲感。

長任務(Long Task)的副作用

在效能分析中,我們把這種執行時間超過 50ms 的 JavaScript 任務稱為 長任務(Long Task)。Stack Reconciler 產生的長任務會導致以下幾個核心問題:

1. 阻塞 UI 互動

這是最直接的體感問題。如果使用者在 React 渲染期間點擊了「取消」按鈕,這個點擊事件會被放入 Task Queue(還記得 Topic 4.1 的 Callback Queue 嗎?)。但因為 Call Stack 被同步遞迴佔滿了,Event Loop 根本沒機會去 Task Queue 拿這個點擊事件出來執行。等到 React 渲染完了,這個「取消」動作才被處理,但可能已經太晚了。

2. 動畫卡頓

CSS 動畫或 SVG 動畫通常需要穩定的幀率。如果主執行緒被 JS 阻塞,瀏覽器就無法計算動畫的下一組數值,導致動畫看起來像是在「閃爍」或直接跳格。

3. 無法區分優先級

在 Stack Reconciler 模型中,所有的更新都被視為「平等的」。

  • 情境 A:使用者正在搜尋框輸入文字(極高優先級,需要立即回饋)。
  • 情境 B:後台正在載入一組新的統計數據並渲染圖表(低優先級,晚 200ms 沒關係)。

如果情境 B 的圖表渲染先開始了,它就會霸佔 Call Stack。即便使用者開始輸入文字,React 也無法暫停圖表渲染來先處理打字。這種「先來先服務且不可中斷」的機制,在複雜應用中會嚴重損害用戶體驗。

為什麼「優化」在舊架構下是有限的?

你可能會問:「難道我們不能透過優化程式碼,讓遞迴變快嗎?」

確實,我們可以使用 shouldComponentUpdateReact.memo(這是後來的東西)來減少不必要的比對,或組件拆小。但這些方法只是在「減少工作量」,並沒有解決「工作模式」的問題。

問題的本質不在於工作量的大小,而在於主控權的歸屬。

在 Stack Reconciler 中,主控權是在 JavaScript 引擎的 Call Stack 手中。身為開發者的我們,無法告訴引擎:「嘿,這個遞迴先跑到這裡,把控制權還給瀏覽器 5ms,讓它處理一下使用者點擊,然後我再繼續。」

標準的 JS 函數呼叫是不可中斷的。如果你呼叫了一個遞迴函數,你只能等它回傳。這種「全有或全無(All or Nothing)」的特性,就是 React 團隊決定要徹底拋棄 Stack Reconciler、重新設計底層架構的根本原因。

邁向 Fiber:從火車到區間車

為了打破這個僵局,React 團隊意識到:我們不能再依賴 JavaScript 原生的 Call Stack 來管理渲染任務了。

他們需要一種方式,能夠將一個巨大的渲染任務拆分成許多個「微小的任務單元」。執行完一個微小單元後,React 可以停下來問一下瀏覽器:「你有更緊急的事情要做嗎?(比如使用者點擊或動畫)」如果有,React 就讓出主執行緒,等瀏覽器忙完了,再回來繼續剛才沒做完的工作。

這種特性被稱為 可中斷渲染(Interruptible Rendering)

為了實現這一點,React 必須模擬出一套「虛擬的呼叫堆疊」。這套堆疊不再由 JS 引擎自動管理,而是由 React 手動控制。這意味著:

  1. 樹的遍歷不能是遞迴的(因為遞迴會佔用 Call Stack)。
  2. 樹的結構必須改變(從單純的父子關係轉變為更能靈活跳轉的結構)。

這列曾經停不下來的「遞迴火車」,即將被拆解成一輛輛可以隨時停靠、隨時讓路的「區間小車」。而這套全新的調度機制與資料結構,就是我們即將深入探討的 Fiber 架構

關鍵瓶頸總結與銜接

在這個部分,我們剖析了 React 16 以前舊架構的限制。Stack Reconciler 的核心問題在於它利用了 JS 原生的同步遞迴模型,導致大型更新會長時間霸佔 Call Stack。這不僅會阻塞瀏覽器的 UI 渲染與事件處理,更導致了無法根據任務緊急程度進行優先級調度的困境。16.6ms 的幀預算成了舊架構難以逾越的高牆,而 Jank 則是這種架構下揮之不去的陰影。

為了克服這些問題,React 引入了 Fiber。但要實現「可中斷」與「優先級調度」,第一步就是要把原本隱含在遞迴呼叫中的狀態「物件化」。我們不再讓函數呼叫留在 Stack 裡,而是要把每一個元件的更新資訊包裝成一個 JavaScript 物件。在下一節中,我們將深入拆解這個關鍵的物件結構:Fiber 節點