之前做过一个在线海报编辑器——用户在浏览器里拖拽元素、改文字、调颜色,最后导出 PNG。业务跑得不错,但老板有了新的想法。
"我们要出微信小程序版本。"他在周会上说。
我算了算工作量。海报编辑器的前端有将近两百个组件——画布、图层面板、属性面板、文字编辑器、图片裁剪器……这些组件全部是基于 React + DOM 写的。小程序没有 DOM,没有
div和span,只有view和text。两百个组件,每一个都要重写。我说:"大概需要六个月。"
老板说:"给你三个月。"
三个月过去了,我们勉强出了一个 MVP。但噩梦才刚刚开始。用户反馈 web 版和小程序版的功能不一致——web 上能用的滤镜,小程序上没有;小程序上的某个动效,web 上没有。每次 web 端加一个新功能,我们都得评估"要不要同步到小程序"——同步的话意味着双倍工作量,不同步的话用户投诉。
更离谱的是,老板后来又说:"我们还要出桌面版。"用 Electron 做。Electron 有 DOM,看起来应该可以直接复用 web 版的代码。但问题是——Electron 的渲染进程和主进程之间的通信模型和浏览器完全不同,文件系统访问、打印、导出 PDF……这些能力都需要重新封装。
到那一刻,我终于理解了一个痛苦的事实:我们的组件逻辑和渲染平台是紧紧耦合在一起的。两百个组件,每一个都知道自己运行在浏览器里,每一个都直接调用
document.createElement和addEventListener。当需要换一个平台时,没有抽象层可以依赖,只能从最底层重新盖楼。后来我在 React 的源码的
packages/react-reconciler/src/ReactFiberConfig.js看了很久。那文件只有二十行,只有一句有用的代码:throw new Error('This module must be shimmed by a specific renderer.');一行
throw,背后藏着一个架构设计的核心判断:把"怎么更新组件"和"怎么操作平台"彻底分开。这就是 Reconciler / Renderer 分离的精髓——一份协议,多端运行。
一、当组件逻辑和渲染平台焊死在一起
上面那个海报编辑器的问题,本质是一个平台耦合的问题。我们的两百个 React 组件里,<Canvas /> 组件内部直接调用了 canvas.getContext('2d'),<TextEditor /> 直接用了 contentEditable 和 document.execCommand,<LayerPanel /> 依赖了 CSS Flexbox 的拖拽排序。这些代码在浏览器里跑得挺好,但它们和 DOM 是焊死的。
这种模式的问题,在只有一个平台的时候不明显。但一旦需要支持多个平台,痛苦指数级放大:
第一,代码重复。 同样的组件逻辑,在浏览器里写一遍,在小程序里写一遍,在桌面端写一遍。不是"复制粘贴"那种重复——是"用不同的 API 实现同样的功能"这种更隐蔽、更昂贵的重复。
第二,行为不一致。 三个平台,三套实现,三个 bug 集合。用户报告"小程序上的文字编辑器有问题",修完小程序的,发现 web 上也有类似的问题——但代码完全不同,修复不能复用。
第三,功能不对齐。 web 端加了一个新滤镜,需要评估"小程序 Canvas 支不支持这种混合模式"。不支持?那这个版本小程序用户用不上。支持?需要额外两周开发。产品决策被技术约束绑架。
第四,测试爆炸。 三个平台,三套测试用例。改一个通用逻辑,需要跑三套测试。CI 时间从 10 分钟变成 30 分钟,再变成一个小时。
根本原因是架构上缺少一个平台抽象层。组件直接和平台 API 打交道,而不是通过一个中间层来间接访问。React 没有犯这个错误。从第一天起,React 就把"组件怎么更新"和"更新结果怎么画到屏幕上"分成了两个独立的层次。
二、Reconciler 是大脑,Renderer 是双手
React 的架构可以粗暴地切成两半:
- Reconciler(协调器):负责"决定什么需要改变"。它比较新的虚拟树和旧的虚拟树,找出差异,生成一个副作用列表("这个节点要插入"、"那个节点要删除"、"这个属性要更新")。它完全不知道 DOM 是什么、Native 视图是什么。
- Renderer(渲染器):负责"执行改变"。它接收 reconciler 生成的副作用列表,翻译成平台特定的操作。在浏览器里,这些操作是
appendChild、setAttribute、removeChild。在 React Native 里,这些操作是UIManager.createView、UIManager.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:
// 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 自这个模块:
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 渲染器的全部平台相关逻辑。
// 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 字符串,checked 和 value 需要特殊处理以保持一致性,事件监听器需要委托到 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,而是把它拆成多个能力模块:
// 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 会覆盖特定的导出:
// 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 的内容:
// 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 组装在一起。
// 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-reconciler 的 createContainer。但关键是——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-dom 的 supportsPersistence = 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接口——createRecord、updateRecord、deleteRecord、queryRecords。Web 端实现用 IndexedDB,移动端实现用 SQLite,测试环境用内存 Map。 - 网络请求层:定义
NetworkConfig接口——request、upload、download。Web 端用fetch,桌面端用 Node.jshttp模块,小程序用wx.request。 - 文件系统层:定义
FileSystemConfig接口——readFile、writeFile、listDirectory。Web 端用 File System Access API,桌面端用 Node.jsfs,移动端用原生桥接。
关键是:业务代码只依赖接口,不依赖具体实现。实现通过构建配置注入。
协议是团队协作的契约
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 花了十年时间告诉我们一个道理:平台会变,协议永存。