
一、当组件逻辑和渲染平台焊死在一起上面那个海报编辑器的问题本质是一个平台耦合的问题。我们的两百个 React 组件里 组件内部直接调用了 canvas.getContext(‘2d’) 直接用了 contentEditable 和 document.execCommand 依赖了 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 configurationsalways 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 makesure that if wedoaccidentally 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.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: Array,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而是把它拆成多个能力模块// https://github.com/facebook/react/blob/main/packages/react-noop-renderer/src/ReactFiberConfigNoop.jsexport * 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 测试选择器 APIReactFiberConfigNoopResources.js 资源预加载link preload/prefetchReactFiberConfigNoopSingletons.js singleton 模式HTML/HEAD/BODYReactFiberConfigNoopNoMutation.js 不支持 mutation——空实现ReactFiberConfigNoopNoPersistence.js 不支持 persistence——空实现主文件通过 export * 把所有模块的能力组合在一起。如果需要创建一个支持 mutation 但不支持 persistence的测试 renderercreateReactNoop.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.jsReactFiberConfigWithNoMicrotasks.jsReactFiberConfigWithNoMutation.jsReactFiberConfigWithNoPersistence.jsReactFiberConfigWithNoResources.jsReactFiberConfigWithNoScopes.jsReactFiberConfigWithNoSingletons.jsReactFiberConfigWithNoTestSelectors.jsReactFiberConfigWithNoViewTransition.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 不支持 mutationreconciler 会选择走 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.jsimport {createContainer,updateContainer,flushSync,} from ‘react-reconciler/src/ReactFiberReconciler’;// ReactFiberReconciler 内部会 import ‘./ReactFiberConfig’// Rollup 构建时这个导入被替换为 react-dom-bindings 的 ReactFiberConfigDOMexport function createRoot(container: Element | Document | DocumentFragment): RootType {const root createContainer(container, // 容器 DOM 节点ConcurrentRoot, // root 类型null, // hydration callbacksfalse, // isStrictModenull, // concurrent updates by defaultidentifierPrefix,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/prefetchSingletons ✓ ✗ ✓ HTML/HEAD/BODY 特殊处理注意 react-dom 的 supportsPersistence false。DOM 是天然支持 mutation 的——可以随时修改一个元素的属性或插入一个子节点。所以 react-dom 选择走 mutation 路径不走 persistence 路径。而某些平台比如一些声明式的 UI 框架可能只支持 persistence——只能提交一整棵新的树来替换旧的不能单独修改某个节点。这种平台会设置 supportsMutation false, supportsPersistence truereconciler 会自动切换算法。