React 設計原理
React 從 v15 升級到 v16 後重構了整個架構,v16 及以上版本一直沿用新架構,重構的主要原因在於:舊架構無法實現 Time Slice。
01 新舊架構介紹
React15 架構可以分爲兩部分:
-
Reconciler(協調器)——VDOM 的實現,負責根據自變量變化計算出 UI 變化。
-
Renderer(渲染器)——負責將 UI 變化渲染到宿主環境中。
在 Reconciler 中,mount 的組件會調用 mountComponent,update 的組件會調用 updateComponent,這兩個方法都會遞歸更新子組件,更新流程一旦開始,中途無法中斷。
基於這個原因,React16 重構了架構。重構後的架構一直沿用至今,可以分爲 3 部分:
-
Scheduler(調度器)——調度任務的優先級,高優先級任務優先進入 Reconciler。
-
Reconciler(協調器)——VDOM 的實現,負責根據自變量變化計算出 UI 變化。
-
Renderer(渲染器)——負責將 UI 變化渲染到宿主環境中。
在新架構中,Reconciler 中的更新流程從遞歸變成了 “可中斷的循環過程”。每次循環都會調用 shouldYield 判斷當前 Time Slice 是否有剩餘時間,沒有剩餘時間則暫停更新流程,將主線程交給渲染流水線,等待下一個宏任務再繼續執行,這就是 Time Slice 的實現原理:
function workLoopConcurrent() {
// 一直執行任務,直到任務執行完或中斷
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
shouldYield 方法如下:
function shouldYield() {
// 當前時間是否大於過期時間
// 其中 deadline = getCurrentTime() + yieldInterval
// yieldInterval 爲調度器預設的時間間隔,默認爲 5ms
return getCurrentTime() >= deadline;
}
過期時間 deadline 在任務執行時被更新爲 “當前時間 + 時間間隔”,時間間隔默認爲 5ms,這也是圖 2-3 中每個 Time Slice 宏任務的時間長度是 5ms 左右的原因。
當 Scheduler 將調度後的任務交給 Reconciler 後,Reconciler 最終會爲 VDOM 元素標記各種副作用 flags,比如:
// 代表插入或移動元素
export const Placement = 0b00000000000000000000000010;
// 代表更新元素
export const Update = 0b00000000000000000000000100;
// 代表刪除元素
export const Deletion = 0b00000000000000000000001000;
Scheduler 與 Reconciler 的工作都在內存中進行。只有當 Reconciler 完成工作後,工作流程纔會進入 Renderer。
Renderer 根據 “Reconciler 爲 VDOM 元素標記的各種 flags” 執行對應操作,比如,如上三個 flags 在瀏覽器宿主環境中對應三種 DOM 操作。
下面的示例 1 演示了上述三個模塊如何配合工作:count 默認值爲 0,每次點擊按鈕執行 count++,UL 中三個 LI 的內容分別爲 “1、2、3 乘以 count 的結果”。
示例 1:
export default () => {
const [count, updateCount] = useState(0);
return (
<ul>
<button onClick={() => updateCount(count + 1)}>乘以{count}</button>
<li>{1 * count}</li>
<li>{2 * count}</li>
<li>{3 * count}</li>
</ul>
)
}
對應工作流程如圖 1 所示。
虛線框中的工作流程隨時可能由於以下原因被中斷:
有其他更高優先級任務需要先執行;
當前 Time Slice 沒有剩餘時間;
發生錯誤。
圖 1 新 React 架構工作流程示例
由於虛線框內的工作都在內存中進行,不會更新宿主環境 UI,因此即使工作流程反覆中斷,用戶也不會看到 “更新不完全的 UI”。
02 主打特性的迭代
隨着 React 架構的重構,上層主打特性也隨之迭代。按照 “主打特性” 劃分,React 大體經歷了四個發展時期:
(1)Sync(同步);
(2)Async Mode(異步模式);
(3)Concurrent Mode(併發模式);
(4)Concurrent Feature(併發特性)
其中,舊架構對應同步時期。異步模式、併發模式、併發特性三個時期與新架構相關。本節主要講解異步模式、併發模式、併發特性的演進過程。
之前曾提到 “CPU 瓶頸” 與“I/O 瓶頸”,React 並不是同時解決這兩個問題的。首先解決的是 “CPU 瓶頸”,解決方式是“架構重構”。重構後 Reconciler 的工作流程從“同步” 變爲“異步、可中斷”。正因如此,這一時期的 React 被稱爲 Async Mode。
單一更新的工作流程變爲 “異步、可中斷” 並不能完全突破“I/O 瓶頸”,解決問題的關鍵在於“使多個更新的工作流程併發執行”。所以,React 繼續迭代爲 Concurrent Mode(併發模式)。在 React 中,Concurrent(併發)概念的意義是“使多個更新的工作流程可以併發執行”。
以上便是從 Sync 到 Async Mode 再到 Concurrent Mode 的演進過程。下一節將講解從 Concurrent Mode 到 Concurrent Feature 的演進過程。
03 漸進升級策略的迭代
從最初的版本到 v18 版本,React 有多少個版本?從架構角度進行概括,所有 React 版本一定屬於如下四種情況之一。
情況 1:舊架構(v15 及之前版本屬於這種情況)。
情況 2:新架構,未開啓併發更新,與情況 1 行爲一致(v16、v17 默認屬於這種情況)。
情況 3:新架構,未開啓併發更新,但是啓用了一些新功能(比如 AutomaticBatching)。
情況 4:新架構,已開啓併發更新。
React 團隊希望:使用舊版本的開發者可以逐步升級到新版本,即從情況 1、2、3 向情況 4 升級。但是升級過程中存在較大阻力,因爲在情況 4 下,React 的一些行爲與情況 1、2、3 不同。比如以下三個生命週期函數在情況 4 的 React 下是 “不安全的”:
-
componentWillMount
-
componentWillReceiveProps
-
componentWillUpdate
強制升級可能造成代碼不兼容。 爲了使 React 的新舊版本之間實現平滑過渡,React 團隊採用了 “漸進升級” 方案。該方案的第一步是規範代碼。v16.3 新增了 StrictMode,針對開發者編寫的 “不符合併發更新規範的代碼” 給出提示,逐步引導開發者編寫規範代碼。比如,使用上述 “不安全的” 生命週期函數時會產生如圖 2 所示的報錯信息。
圖 2 StrictMode 下使用不安全生命週期函數報錯
下一步,React 團隊允許 “不同情況的 React” 在同一個頁面共存,藉此使 “情況 4 的 React” 逐步滲透至原有項目中。具體做法是提供了以下三種開發模式:
-
Legacy 模式,通過 ReactDOM.render(, rootNode) 創建的應用遵循該模式。默認關閉 StrictMode,表現同情況 2。
-
Blocking 模式 通過 ReactDOM.createBlockingRoot(rootNode).render() 創建的應用遵循該模式,作爲從 Legacy 向 Concurrent 過渡的中間模式,默認開啓 StrictMode,表現同情況 3。
-
Concurrent 模式,通過 ReactDOM.createRoot(rootNode).render() 創建的應用遵循該模式,默認開啓 StrictMode,表現同情況 4。
三種開發模式支持特性對比如圖 3 所示
圖 3 三種開發模式支持特性對比
爲了使不同模式的應用可以在同一個頁面內工作,需要對一些底層實現進行調整。比如:調整之前,大多數事件會統一冒泡到 HTML 元素,調整後則冒泡到 “應用所在根元素”。這些調整工作發生在 v17,所以 v17 也被稱作 “爲開啓併發更新做鋪墊” 的“墊腳石”版本。
2021 年 6 月 8 日,v18 工作組成立。在與社區進行大量溝通後,React 團隊意識到當前的 “漸進升級” 策略存在兩方面問題。首先,由於模式影響的是整個應用,因此無法在同一個應用中完成漸進升級。舉例說明,開發者將應用中 ReactDOM.render 改爲 ReactDOM.createBlockingRoot,從 Legacy 模式切換到 Blocking 模式,會自動開啓 StrictMode。此時,整個應用的 “併發不兼容警告” 都會上報,開發者需要修復整個應用中的不兼容代碼。從這個角度看,“漸進升級”的目的並沒有達到。
其次,React 團隊發現:開發者從新架構中獲益,主要是由於使用了併發特性,併發特性指 “開啓併發更新後才能使用的那些 React 爲了解決 CPU 瓶頸、I/O 瓶頸而設計的特性”,比如:
-
useDeferredValue
-
useTransition
所以,React 團隊提出新的漸進升級策略——開發者仍可以在默認情況下使用同步更新,在使用併發特性後再開啓併發更新。
在 v18 中運行示例 2 所示代碼,由於 updateCount 在 startTransition 的回調函數中執行(使用了併發特性),因此 updateCount 會觸發併發更新。如果 updateCount 沒有在 startTransition 的回調函數中執行,那麼 updateCount 將觸發默認的同步更新。
示例 2:
const App = () => {
const [count, updateCount] = useState(0);
const [isPending, startTransition] = useTransition();
const onClick = () => {
// 使用了併發特性 useTransition
startTransition(() => {
// 本次更新是併發更新
updateCount((count) => count + 1);
});
};
return <h3 onClick={onClick}>{count}</h3>;
};
讀者可以調試在線示例中這兩種情況的調用棧火焰圖,根據火焰圖中觀察到的 “是否開啓 Time Slice” 來區分 “是否是併發更新”。
所以,React 在 v18 中不再提供三種開發模式,而是以 “是否使用併發特性” 作爲 “是否開啓併發更新” 的依據。
具體來說,開發者在 v18 中統一使用 ReactDOM.createRoot 創建應用。當不使用併發特性時,表現如情況 3。使用併發特性後,表現如情況 4。
本文節選自卡頌的新書《React 設計原理》,基於 React18,從理念、架構、實現三個層面解構React
。
這本書存在兩條脈絡:
-
抽象層級逐漸降低
-
實現越來越複雜的模塊
對於前者,本書的抽象層級會逐漸從理念到架構,最後到實現,每一層都屏蔽前一層的影響。
這也是爲什麼ReactDOM.createRoot
這個初始化API
會放到第六章再講解 —— 在這個具體API
的背後,是他的理念與架構。
對於後者,本書會從 0 實現與react
相關的 6 個模塊,最後我們會一起在React
源碼內實現一個新的原生Hook
。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/fltre-3WAmZ9rDGcKA9aXg