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.execCommandLayerPanel /依赖了 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 isnt 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.jsexport 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: Arraymixed,type: string,oldProps: Props,newProps: Props,internalInstanceHandle: Object,): void {// 应用属性差异到 DOM 节点updateProperties(domElement, updatePayload, type, oldProps, newProps);// 更新 Fiber 节点上缓存的 propsupdateFiberProps(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而是把它拆成多个能力模块