React 如何实现跨平台
之前做过一个在线海报编辑器——用户在浏览器里拖拽元素、改文字、调颜色,最后导出 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,而是把它拆成多个能力模块:
更多推荐



所有评论(0)