React 設計原理

React 從 v15 升級到 v16 後重構了整個架構,v16 及以上版本一直沿用新架構,重構的主要原因在於:舊架構無法實現 Time Slice。

01 新舊架構介紹

React15 架構可以分爲兩部分:

在 Reconciler 中,mount 的組件會調用 mountComponent,update 的組件會調用 updateComponent,這兩個方法都會遞歸更新子組件,更新流程一旦開始,中途無法中斷。

基於這個原因,React16 重構了架構。重構後的架構一直沿用至今,可以分爲 3 部分:

在新架構中,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 下是 “不安全的”:

強制升級可能造成代碼不兼容。 爲了使 React 的新舊版本之間實現平滑過渡,React 團隊採用了 “漸進升級” 方案。該方案的第一步是規範代碼。v16.3 新增了 StrictMode,針對開發者編寫的 “不符合併發更新規範的代碼” 給出提示,逐步引導開發者編寫規範代碼。比如,使用上述 “不安全的” 生命週期函數時會產生如圖 2 所示的報錯信息。

圖 2 StrictMode 下使用不安全生命週期函數報錯

下一步,React 團隊允許 “不同情況的 React” 在同一個頁面共存,藉此使 “情況 4 的 React” 逐步滲透至原有項目中。具體做法是提供了以下三種開發模式:

  1. Legacy 模式,通過 ReactDOM.render(, rootNode) 創建的應用遵循該模式。默認關閉 StrictMode,表現同情況 2。

  2. Blocking 模式 通過 ReactDOM.createBlockingRoot(rootNode).render() 創建的應用遵循該模式,作爲從 Legacy 向 Concurrent 過渡的中間模式,默認開啓 StrictMode,表現同情況 3。

  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 瓶頸而設計的特性”,比如:

所以,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