React Renderer 分離的多平台架構

一份協議,多端執行 —— Reconciler / Renderer 分離的多平台架構

之前做過一個線上海報編輯器——使用者在瀏覽器裡拖曳元素、改文字、調顏色,最後匯出 PNG。業務跑得不錯,但老闆有了新的想法。

"我們要出微信小程式版本。"他在週會上說。

我算了算工作量。海報編輯器的前端有將近兩百個元件——畫布、圖層面板、屬性面板、文字編輯器、圖片裁剪器……這些元件全部是基於 React + DOM 寫的。小程式沒有 DOM,沒有 divspan,只有 viewtext。兩百個元件,每一個都要重寫。

我說:"大概需要六個月。"

老闆說:"給你三個月。"

三個月過去,我們勉強做出了一個 MVP。但惡夢才剛開始。使用者回饋 web 版和小程式版的功能不一致——web 上能用的濾鏡,小程式上沒有;小程式上的某個動效,web 上沒有。每次 web 端加一個新功能,我們都得評估「要不要同步到小程式」——同步的話意味著雙倍工作量,不同步的話使用者投訴。

更離譜的是,老闆後來又說:"我們還要出桌面版。"用 Electron 做。Electron 有 DOM,看起來應該可以直接重用 web 版的程式碼。但問題是——Electron 的渲染行程和主行程之間的通訊模型和瀏覽器完全不同,檔案系統存取、列印、匯出 PDF……這些能力都需要重新封裝。

到那一刻,我終於理解了一個痛苦的事實:我們的元件邏輯和渲染平台是緊緊耦合在一起的。兩百個元件,每一個都知道自己執行在瀏覽器裡,每一個都直接呼叫 document.createElementaddEventListener。當需要換一個平台時,沒有抽象層可以依賴,只能從最底層重新蓋樓。

後來我在 React 原始碼的 packages/react-reconciler/src/ReactFiberConfig.js 看了很久。那個檔案只有二十行,只有一句有用的程式碼:

javascript 代碼解讀複製程式碼throw new Error('This module must be shimmed by a specific renderer.');

一行 throw,背後藏著一個架構設計的核心判斷:把「怎麼更新元件」和「怎麼操作平台」徹底分開。這就是 Reconciler / Renderer 分離的精髓——一份協議,多端執行。


一、當元件邏輯和渲染平台焊死在一起

上面那個海報編輯器的問題,本質上是平台耦合的問題。我們的兩百個 React 元件裡,<Canvas /> 元件內部直接呼叫了 canvas.getContext('2d')<TextEditor /> 直接用了 contentEditabledocument.execCommand<LayerPanel /> 依賴了 CSS Flexbox 的拖曳排序。這些程式碼在瀏覽器裡跑得很好,但它們和 DOM 是焊死的。

這種模式的問題,在只有一個平台的時候不明顯。但一旦需要支援多個平台,痛苦就會指數級放大:

第一,程式碼重複。 同樣的元件邏輯,在瀏覽器裡寫一遍,在小程式裡寫一遍,在桌面端寫一遍。不是「複製貼上」那種重複——是「用不同的 API 實現同樣的功能」這種更隱蔽、更昂貴的重複。

第二,行為不一致。 三個平台,三套實作,三個 bug 集合。使用者回報「小程式上的文字編輯器有問題」,修完小程式的,發現 web 上也有類似的問題——但程式碼完全不同,修復不能重用。

第三,功能不對齊。 web 端加了一個新濾鏡,需要評估「小程式 Canvas 支不支援這種混合模式」。不支援?那這個版本小程式使用者用不上。支援?需要額外兩週開發。產品決策被技術限制綁架。

第四,測試爆炸。 三個平台,三套測試案例。改一個通用邏輯,需要跑三套測試。CI 時間從 10 分鐘變成 30 分鐘,再變成一個小時。

根本原因是架構上缺少一個平台抽象層。元件直接和平台 API 打交道,而不是透過一個中間層來間接存取。React 沒有犯這個錯誤。從第一天起,React 就把「元件怎麼更新」和「更新結果怎麼畫到螢幕上」分成了兩個獨立的層次。


二、Reconciler 是大腦,Renderer 是雙手

React 的架構可以粗暴地切成兩半:

  • Reconciler(協調器):負責「決定什麼需要改變」。它比較新的虛擬樹和舊的虛擬樹,找出差異,生成一個副作用列表(「這個節點要插入」、「那個節點要刪除」、「這個屬性要更新」)。它完全不知道 DOM 是什麼、Native 視圖是什麼。
  • Renderer(渲染器):負責「執行改變」。它接收 reconciler 生成的副作用列表,翻譯成平台特定的操作。在瀏覽器裡,這些操作是 appendChildsetAttributeremoveChild。在 React Native 裡,這些操作是 UIManager.createViewUIManager.updateView。在測試環境裡,這些操作可能只是一次記憶體中的物件修改。

它們之間的契約,就是 HostConfig。Reconciler 在需要操作平台時,呼叫 HostConfig 中的函式,而不是直接操作 DOM 或 Native API。

這組介面大約包括:

函式 作用 DOM 實作 Native 實作
createInstance(type, props) 建立平台元素 document.createElement(type) UIManager.createView(tag, class, props)
createTextInstance(text) 建立文字節點 document.createTextNode(text) UIManager.createView(tag, RCTText, {text})
appendChild(parent, child) 新增子節點 parent.appendChild(child) UIManager.manageChildren(tag, [], [child])
insertBefore(parent, child, before) 插入子節點 parent.insertBefore(child, before) UIManager.manageChildren(tag, [], [child], [index])
removeChild(parent, child) 移除子節點 parent.removeChild(child) UIManager.manageChildren(tag, [child], [])
commitUpdate(instance, updatePayload) 更新屬性 node.setAttribute(key, val) UIManager.updateView(tag, props)
finalizeInitialChildren() 初始化完成綁定事件監聽器 無操作

Reconciler 永遠不會直接呼叫 document.createElement。它呼叫 createInstance——這個函式由 renderer 提供。如果 renderer 是給瀏覽器用的,createInstance 內部呼叫 document.createElement。如果 renderer 是給 React Native 用的,createInstance 內部呼叫 UIManager.createView

同樣的 reconciler 大腦,換一雙不同的手,就能在不同的平台上工作。


三、原始碼裡的協議與實作

3.1 ReactFiberConfig.js —— 一行 throw,一份契約

打開 packages/react-reconciler/src/ReactFiberConfig.js

javascript 代碼解讀複製程式碼// https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberConfig.js
/**
 * We expect that our Rollup, Jest, and Flow configurations
 * always shim this module with the corresponding host config
 * (either provided by a renderer, or a generic shim for npm).
 *
 * We should never resolve to this file, but it exists to make
 * sure that if we *do* accidentally break the configuration,
 * the failure isn't silent.
 */

throw new Error('This module must be shimmed by a specific renderer.');

二十行程式碼,九成是註解。唯一有用的程式碼是那行 throw

但正是這行 throw,定義了整個架構的契約邊界。Reconciler 套件的原始碼中,所有需要操作平台的地方,都 import 自這個模組:

javascript 代碼解讀複製程式碼import {
  createInstance,
  appendChild,
  removeChild,
  commitUpdate,
  // ...
} from './ReactFiberConfig';

注意路徑——'./ReactFiberConfig',不是 '../react-dom-bindings/...'。Reconciler 不依賴任何具體的 renderer。它依賴的是一個抽象的介面

這個介面的「具體實作」,是在建置時透過 Rollup 的模組別名(alias)注入的。看 react-dom 的建置設定:所有對 react-reconciler/src/ReactFiberConfig 的匯入,都被重新導向到 react-dom-bindings/src/client/ReactFiberConfigDOM.js。而 react-native-renderer 的建置設定,則把同樣的匯入重新導向到它自己內部的 Native HostConfig。

這說明:同一份 reconciler 原始碼,被編譯到 react-dom 套件裡就變成操作 DOM 的版本,被編譯到 react-native-renderer 套件裡就變成操作 Native 視圖的版本。程式碼是一樣的,只是連結時的介面實作不同。

3.2 ReactFiberConfigDOM.js —— 6669 行的 DOM 操作百科全書

如果說 ReactFiberConfig.js 是一份「協議宣言」,那麼 ReactFiberConfigDOM.js 就是協議的「完整實作」。6669 行程式碼,幾乎是整個 React DOM 渲染器的全部平台相關邏輯。

javascript 代碼解讀複製程式碼// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
export function createInstance(
  type: string,
  props: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object,
): Instance {
  const ownerDocument = getOwnerDocumentFromRootContainer(
    rootContainerInstance,
  );
  const domElement: Instance = ownerDocument.createElement(type);
  // ... 屬性處理、事件綁定、ref 關聯
  precacheFiberNode(internalInstanceHandle, domElement);
  updateFiberProps(domElement, props);
  return domElement;
}

export function createTextInstance(
  text: string,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object,
): TextInstance {
  const ownerDocument = getOwnerDocumentFromRootContainer(
    rootContainerInstance,
  );
  const textNode: TextInstance = ownerDocument.createTextNode(text);
  precacheFiberNode(internalInstanceHandle, textNode);
  return textNode;
}

export function appendChild(parentInstance: Instance, child: Instance | TextInstance): void {
  parentInstance.appendChild(child);
}

export function insertBefore(
  parentInstance: Instance,
  child: Instance | TextInstance,
  beforeChild: Instance | TextInstance,
): void {
  parentInstance.insertBefore(child, beforeChild);
}

export function removeChild(
  parentInstance: Instance,
  child: Instance | TextInstance,
): void {
  parentInstance.removeChild(child);
}

export function commitUpdate(
  domElement: Instance,
  updatePayload: Array<mixed>,
  type: string,
  oldProps: Props,
  newProps: Props,
  internalInstanceHandle: Object,
): void {
  // 將屬性差異套用到 DOM 節點
  updateProperties(domElement, updatePayload, type, oldProps, newProps);
  // 更新 Fiber 節點上快取的 props
  updateFiberProps(domElement, newProps);
}

這些函式看起來很簡單——不就是包裝了一下 DOM API 嗎?但魔鬼在細節裡。

createInstance 裡的 precacheFiberNode。這個函式把 Fiber 節點和 DOM 節點之間的對應關係快取到一個全域 Map 裡。當 React 需要「從 DOM 事件找到對應的 Fiber 節點」時(比如事件委派),它不需要遍歷 Fiber 樹——直接從 Map 裡查。這個 Map 的管理完全在 HostConfig 層完成,reconciler 不操心。

commitUpdate 裡的 updateProperties。DOM 屬性更新不是簡單的 element.setAttribute。不同屬性有不同的更新邏輯——style 需要解析 CSS 字串,checkedvalue 需要特殊處理以保持一致性,事件監聽器需要委派到 root 節點而不是直接綁定。這些 DOM 特有的複雜性,全部被封裝在 HostConfig 裡。Reconciler 只需要說「更新這個節點的屬性」,具體怎麼更新,HostConfig 決定。

這 6669 行程式碼裡,大約只有 5% 是上面這種「直接代理 DOM API」的函式。剩下的 95% 都是DOM 特有的複雜邏輯——事件系統、屬性處理、hydration、表單元素特殊行為、資源預載入、無障礙屬性……所有這些平台特定的細節,都被 HostConfig 吞掉了,reconciler 完全不知情。

3.3 ReactFiberConfigNoop.js —— 能力組合的藝術

如果說 ReactFiberConfigDOM.js 是「全功能 renderer」的典範,那麼 ReactFiberConfigNoop.js 則是「按需組合能力」的精妙設計。

Noop renderer 是 React 內部測試用的渲染器。它不操作任何真實平台——沒有 DOM,沒有 Native 視圖,所有操作都在記憶體中進行。但測試情境有不同的需求:有時需要模擬 mutation(插入/刪除/更新),有時需要模擬 persistence(快照/還原),有時需要 hydration,有時不需要。

React 的做法不是寫一個大而全的 Noop Config,而是把它拆成多個能力模組

javascript 代碼解讀複製程式碼// https://github.com/facebook/react/blob/main/packages/react-noop-renderer/src/ReactFiberConfigNoop.js
export * from './ReactFiberConfigNoopHydration';
export * from './ReactFiberConfigNoopScopes';
export * from './ReactFiberConfigNoopTestSelectors';
export * from './ReactFiberConfigNoopResources';
export * from './ReactFiberConfigNoopSingletons';
export * from './ReactFiberConfigNoopNoMutation';
export * from './ReactFiberConfigNoopNoPersistence';

export type HostContext = Object;
export type TextInstance = { text: string, id: number, ... };
export type Instance = { type: string, id: number, ... };
export type Container = { rootID: string, children: Array<...>, ... };

看看這些檔名:

模組 功能
ReactFiberConfigNoopHydration.js hydration 能力(伺服器端渲染後客戶端啟用)
ReactFiberConfigNoopScopes.js scope API 支援
ReactFiberConfigNoopTestSelectors.js 測試選擇器 API
ReactFiberConfigNoopResources.js 資源預載入(link preload/prefetch)
ReactFiberConfigNoopSingletons.js singleton 模式(HTML/HEAD/BODY)
ReactFiberConfigNoopNoMutation.js 支援 mutation——空實作
ReactFiberConfigNoopNoPersistence.js 支援 persistence——空實作

主檔案透過 export * 把所有模組的能力組合在一起。如果需要建立一個「支援 mutation 但不支援 persistence」的測試 renderer,createReactNoop.js 會覆蓋特定的匯出:

javascript 代碼解讀複製程式碼// https://github.com/facebook/react/blob/main/packages/react-noop-renderer/src/createReactNoop.js
// 覆蓋 NoMutation 的匯出,換成實際支援 mutation 的版本
Object.assign(fiberConfig, mutationConfig);
// 覆蓋 NoPersistence 的匯出,換成實際支援 persistence 的版本(如果需要)
if (usePersistentMode) {
  Object.assign(fiberConfig, persistentConfig);
}

這是一種 能力組合(capability composition) 的設計模式。每個能力是一個獨立的模組,renderer 透過選擇性地匯入和覆蓋這些模組來宣告自己支援什麼、不支援什麼。

3.4 ReactFiberConfigWithNoMutation.js —— 不支援的能力怎麼表達

react-reconciler/src/ 目錄下,有一組 ReactFiberConfigWithNo*.js 檔案:

 代碼解讀複製程式碼ReactFiberConfigWithNoHydration.js
ReactFiberConfigWithNoMicrotasks.js
ReactFiberConfigWithNoMutation.js
ReactFiberConfigWithNoPersistence.js
ReactFiberConfigWithNoResources.js
ReactFiberConfigWithNoScopes.js
ReactFiberConfigWithNoSingletons.js
ReactFiberConfigWithNoTestSelectors.js
ReactFiberConfigWithNoViewTransition.js

看看 ReactFiberConfigWithNoMutation.js 的內容:

javascript 代碼解讀複製程式碼// https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js
// Renderers that don't support mutation can re-export everything from this module.

function shim(...args: any): empty {
  throw new Error(
    'The current renderer does not support mutation. ' +
      'This error is likely caused by a bug in React. ' +
      'Please file an issue.',
  );
}

export const supportsMutation = false;
export const appendChild = shim;
export const removeChild = shim;
export const commitUpdate = shim;
// ... 更多 mutation 函式都是 shim

這是 Null Object Pattern 的應用。當某個平台不支援 mutation 時(比如一些純宣告式的渲染目標),reconciler 不會去呼叫這些函式——因為它會檢查 supportsMutation 標誌。但如果程式碼路徑有 bug,不小心呼叫到這些函式,shim 會立刻拋出一個清楚的錯誤,而不是靜默失敗或產生 undefined behavior。

這種設計體現了 React 團隊的一個工程判斷:不支援的功能,不應該用「不匯出」來表達,而應該用「匯出但標記為不支援」來表達。因為「不匯出」會導致匯入時得到 undefined,而 undefined 被呼叫時的錯誤訊息極其晦澀。shim 函式明確的錯誤訊息,能節省數小時的除錯時間。

同時,supportsMutation = false 這個標誌位讓 reconciler 可以在執行期檢測平台能力。如果 renderer 不支援 mutation,reconciler 會選擇走 persistence 路徑(先建立新樹,再整體替換)。這就像一個聰明的管家——如果家裡沒有拖把(mutation),它會改用吸塵器(persistence)來打掃。

3.5 ReactDOMRoot.js —— reconciler 的組裝現場

看看 react-dom 套件怎麼把 reconciler 和 HostConfig 組裝在一起。

javascript 代碼解讀複製程式碼// https://github.com/facebook/react/blob/main/packages/react-dom/src/client/ReactDOMRoot.js
import {
  createContainer,
  updateContainer,
  flushSync,
} from 'react-reconciler/src/ReactFiberReconciler';

// ReactFiberReconciler 內部會 import './ReactFiberConfig'
// Rollup 建置時,這個匯入被替換為 react-dom-bindings 的 ReactFiberConfigDOM

export function createRoot(container: Element | Document | DocumentFragment): RootType {
  const root = createContainer(
    container,           // 容器 DOM 節點
    ConcurrentRoot,      // root 類型
    null,                // hydration callbacks
    false,               // isStrictMode
    null,                // concurrent updates by default
    identifierPrefix,
    onUncaughtError,
    onCaughtError,
    onRecoverableError,
    null,
  );
  // ...
  return {
    render(children) { updateContainer(children, root, null); },
    unmount() { updateContainer(null, root, null); },
    _internalRoot: root,
  };
}

createRoot 看起來只是在呼叫 react-reconcilercreateContainer。但關鍵是——createContainer 的實作(在 ReactFiberReconciler.js 中)內部會呼叫 HostConfig 的函式。比如建立 root fiber 時,它需要知道怎麼建立容器實例——這就調到了 createInstance

而在 react-dom 的建置產物中,這個 createInstance 來自 ReactFiberConfigDOM.js,它內部呼叫的是 document.createElement。如果建置的是 react-native-renderer,同一個 createContainer 呼叫的是 Native 的 UIManager.createView

這就是「一份協議,多端執行」的本質——同一份 reconciler 原始碼,連結不同的 HostConfig 實作,就得到了不同的 renderer。


四、能力矩陣——各平台 renderer 的能力差異

不同的 renderer 對 HostConfig 協議的實作程度不同。有些功能某些平台天生不支援,有些則是設計上的取捨。

能力 react-dom react-native react-noop 含義
基本樹操作 ✓ ✓ ✓ 所有平台都支援
supportsMutation ✓ ✓ 可選 增量更新節點
supportsPersistence ✗ ✗ 可選 整體替換樹
supportsHydration ✓ ✗ ✓ SSR 後客戶端啟用
資源預載入 ✓ ✗ ✓ link preload/prefetch
Singletons ✓ ✗ ✓ HTML/HEAD/BODY 特殊處理

注意 react-domsupportsPersistence = false。DOM 是天生支援 mutation 的——可以隨時修改一個元素的屬性或插入一個子節點。所以 react-dom 選擇走 mutation 路徑,不走 persistence 路徑。

而某些平台(比如一些宣告式的 UI 框架)可能只支援 persistence——只能提交一整棵新的樹來替換舊的,不能單獨修改某個節點。這種平台會設定 supportsMutation = false, supportsPersistence = true,reconciler 會自動切換演算法。


五、從 React 的協議設計到我們的工程

用協議隔離變化

React 的 Reconciler / Renderer 分離,本質是一種協議驅動架構(Protocol-Driven Architecture)。Reconciler 定義「我需要什麼操作」,Renderer 實作「這些操作怎麼在平台上執行」。兩者透過 HostConfig 協議通訊。

這種架構的價值在於:任何一方都可以獨立演化。Reconciler 可以升級調度演算法(Fiber → Concurrent → 未來的什麼),只要 HostConfig 介面不變,所有 renderer 都不需要改。反過來,renderer 可以新增新的平台能力(比如 react-dom 新增了 View Transition API 支援),只要實作了 HostConfig 中對應的函式,reconciler 就能用上。

在自己的系統裡,當需要支援多個平台或多個後端時,考慮定義一個核心協議:

  • 核心層定義「我需要什麼操作」
  • 適配層實作「這些操作在平台 X 上怎麼執行」
  • 用建置工具(Rollup alias / Webpack resolve.alias)在編譯時注入具體實作
  • 對不支援的能力,提供 shim 空實作 + supportsXxx = false 標誌

HostConfig 的思想不只屬於 React

HostConfig 的核心思想——用一組介面函式抽象平台差異——是普適的。舉幾個例子:

  • 資料儲存層:定義 StorageConfig 介面——createRecordupdateRecorddeleteRecordqueryRecords。Web 端實作用 IndexedDB,行動端實作用 SQLite,測試環境用記憶體 Map。
  • 網路請求層:定義 NetworkConfig 介面——requestuploaddownload。Web 端用 fetch,桌面端用 Node.js http 模組,小程式用 wx.request
  • 檔案系統層:定義 FileSystemConfig 介面——readFilewriteFilelistDirectory。Web 端用 File System Access API,桌面端用 Node.js fs,行動端用原生橋接。

關鍵是:業務程式碼只依賴介面,不依賴具體實作。實作透過建置設定注入。

協議是團隊協作的契約

HostConfig 不僅是程式碼層面的介面,更是團隊層面的契約。React 核心團隊維護 reconciler,Facebook 內部團隊維護 react-dom,社群維護 react-native-renderer。三方團隊不需要頻繁溝通——只要 HostConfig 介面不變,各自可以獨立開發、獨立發佈。

這種「透過協議解耦團隊」的模式,對於大型組織的架構設計有重要啟示:

React 的做法 遷移策略
HostConfig 定義 reconciler 和 renderer 的邊界 在團隊之間定義 API 契約,而不是直接依賴對方的內部實作
supportsXxx = false 標誌讓 reconciler 自適應 服務降級策略——後端能力不可用時,前端自動切換為簡化模式
Rollup alias 在建置時注入實作 在 CI/CD 中透過環境變數切換不同的後端實作,同一份業務程式碼跑在不同環境
shim 函式明確報錯而非靜默失敗 介面未實作時拋出清楚錯誤,而不是回傳 undefined 導致後續難以除錯


六、好架構的本質是定義邊界

回頭看 ReactFiberConfig.js 那行 throw new Error。二十行程式碼,九成註解,一行有效程式碼。但它定義了整個 React 多平台架構的基石。

好架構的本質不是寫出多麼精巧的演算法,而是定義清楚的邊界——什麼東西屬於這一層,什麼東西屬於那一層,層與層之間透過什麼協議通訊。邊界定好了,每一層內部的實作可以任意替換、任意演化,而不會波及到別處。

React 的 reconciler 已經有上萬行程式碼——調度演算法、優先級系統、Fiber 樹管理、副作用收集……但這些程式碼對「自己執行在哪個平台上」一無所知。它們只認識 HostConfig 中定義的十幾個函式。就是這十幾個函式,讓同一份 reconciler 大腦,能夠驅動瀏覽器 DOM、iOS/Android Native 視圖、記憶體中的測試物件,甚至未來還沒有被發明出來的新平台。

我在那個海報編輯器專案失敗後,花了很多時間思考「如果重來一次,該怎麼設計」。答案是:從第一天起就定義一個 RendererConfig 介面。畫布操作不直接調 Canvas API,而是透過 config.createCanvasContext()。文字編輯不直接用 contentEditable,而是透過 config.createTextEditor()。當老闆說「我們要出小程式版」的時候,我只需要實作一個新的小程式 RendererConfig,而不是重寫兩百個元件。

React 花了十年時間告訴我們一個道理:平台會變,協議永存


原文出處:https://juejin.cn/post/7656342465024540718


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝13   💬2   ❤️1
573
🥈
我愛JS
📝1   ❤️1
71
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登