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 花了十年时间告诉我们一个道理:平台会变,协议永存

相关推荐
hunterandroid1 小时前
Kotlin Coroutines 与 Flow:让异步任务更清晰
前端
Bigger2 小时前
从零搭建 AI 代码审查服务:一份前端也能看懂的 Python 学习笔记
前端·ci/cd·ai编程
lichenyang4532 小时前
JSAPI、NAPI、Biz、Imp:ASCF Demo 如何真正调用系统能力和 C++ 能力
前端
lichenyang4532 小时前
IPC、JSVM、UIThread、libuv:ASCF 架构图里最容易混的几个词
前端
用户059540174462 小时前
Redis记忆存储故障恢复测试踩坑实录:手动测试让我漏掉了2个一致性Bug
前端·css
用户2136610035723 小时前
Vue2脚手架工程化与Axios集成
前端·vue.js
张元清3 小时前
React useDebounce Hook:给状态和回调做防抖(2026)
javascript·react.js
我不是外星人3 小时前
我把 Claude Code 搬到网页!自研高颜值 Web 交互工作台
前端·ai编程·claude
mixuecoding3 小时前
零成本搭建全球科技热点情报站:12 个平台,6 小时,0 元
前端