第五章:当 React 离开 DOM
2013 年的 React 介绍已经提到两个看似超前的原型:在 Web Worker 中运行 React,以及通过 Objective-C bridge 驱动原生 iOS view。还有 canvas chart 与 server rendering。这些实验都依赖同一事实:component render 返回的是轻量描述,不是 DOM 节点。
但早期公开包名仍然叫 react,DOM API、组件 API 和 reconciler 在使用体验上紧密缠绕。要让“多宿主”从演示变成架构,项目必须回答:哪些部分属于 React,哪些只属于浏览器?
React Native 不是 WebView
2015 年 3 月,Facebook 开源 React Native。官方介绍说,希望结合原生平台的用户体验与 React 在 Web 上的开发体验:同一个 React 在嵌入式 JavaScriptCore 中运行,但不创建 div、span,而是生成更高层的平台组件。
这不是把网页装进壳,也不是把 CSS/DOM 原样复制到 iOS。React Native 明确拒绝“write once, run anywhere”,提出“learn once, write anywhere”:平台有不同外观、交互和能力,应用仍应针对平台设计,但工程师可以复用组件模型、状态管理方式和一部分业务逻辑。
史料事实:同期官方文章明确区分这两句口号。这个选择避免把跨平台价值建立在像素级完全复用上,而把重点放在编程模型复用。
react 与 react-dom 拆开
React 0.14 在 2015 年 10 月发布时,把原包拆成 react 与 react-dom。发布说明的理由非常直接:观察 React Native、React ART、React Canvas、React Three 等项目后,团队认为 React 的“美和本质”与浏览器或 DOM 无关。
拆分后:
react提供createElement、component class、children helpers 等用于描述组件的公共能力。react-dom提供render、findDOMNode、卸载与 server renderer。- 不同 renderer 可以共享 element 与 component model,而实现自己的宿主创建、更新和删除。
包拆分看似是发布组织调整,实际上把一条概念边界变成公共 API。应用代码不应从 React 对象直接调用 DOM 操作;renderer 也不应决定用户组件怎样表达状态。
Renderer 真正需要做什么
一个 renderer 至少需要把抽象工作翻译成宿主操作:
- 创建宿主实例,例如 DOM element、原生 view 或测试树节点。
- 比较并提交 props 更新。
- 插入、移动、删除 child。
- 管理文本、事件、焦点与宿主上下文。
- 决定更新怎样被安排到平台事件循环。
旧架构中这些能力分散在 DOM-specific 模块。Fiber 重写时,早期 PR #6690 先建立 Noop renderer,只为测试 reconciler 语义,不产生真实输出;随后 #7154 加入 rudimentary DOM renderer 与 host side effects。这种顺序说明新架构有意让 reconciler 在不知道 DOM 的情况下成立。
现代源码通过 Host Config 形成多套构建 fork。React DOM、Native、Test Renderer、Fizz 和 Flight 并非简单调用同一个通用接口的普通 npm 插件;许多 renderer 接缝仍是内部协议,随 React 版本一起演进。这给官方 renderer 高度优化空间,也让第三方自定义 renderer 维护成本较高。
本文判断:React 的 renderer 抽象是“内部可替换”而非“完全稳定的公共插件标准”。理解这一点可以解释为何 react-reconciler 包长期带 experimental 色彩,也解释 React 与 React Native 必须紧密协调版本和功能。
Function Component 在多宿主时代更自然
React 0.14 同时正式引入 stateless function component。它接收 props,返回 element,不创建 class 实例,也没有 lifecycle。官方当时已预期这类组件可减少检查与分配,并构成应用的大部分。
函数组件与 renderer 分离相互强化:用户组件只计算描述,不知道最终宿主;需要宿主能力时通过 props、context 或 ref 进入受控出口。Hooks 出现后,函数组件又获得 state 与 effect,最终成为主流表达方式。
不过,“函数组件”不等于普通纯函数。加入 Hooks 后,它的执行依赖当前 dispatcher 与 Fiber 上的 Hook 记录;调用期间还可能 suspend。它在语法上是函数,在运行时上属于 React 管理的计算单元。
Server Renderer:同一描述,不同提交目标
服务端没有可持续更新的 DOM。早期 renderer 把树转成 HTML 字符串;React 16 重写 server renderer 并支持 streaming;React 18 的 Fizz 将 Suspense boundary 与流式 HTML、选择性 hydration 结合;React 19.2 又加入部分预渲染与 resume API。
这些 renderer 共享组件描述,却不共享完全相同的生命周期。服务端不能运行依赖浏览器布局的 effect,也不保留可交互实例;客户端 hydration 则要把已有 HTML 与新的 Fiber tree 对齐。
Server Components 更进一步:Flight renderer 的输出甚至不是 HTML,而是客户端可解释的组件模型、数据和模块引用。它再次证明 renderer 的目标不必是可见像素,也可以是另一阶段的输入协议。
Renderer 分离改变了“组件复用”的含义
跨平台复用不是把 <div> 自动变成 <View>。真正可共享的是不依赖宿主的部分:
- 状态机与 reducer。
- 数据选择、验证和业务规则。
- 自定义 Hook。
- 由平台组件参数化的组合逻辑。
- React 对 props、state、context、Suspense 的语义。
平台特定的布局、导航、输入、无障碍与动画往往仍需独立实现。React Native 的价值因此不是消除平台,而是把“业务组件如何随状态重新计算”统一起来。
代价:core 与 renderer 的协同复杂度
当 React 只有 DOM renderer,新增核心能力只需考虑一个宿主。多 renderer 后,每项设计都要问:
- commit 前后平台能否安全暂停?
- 宿主是否支持隐藏而非卸载?
- 文本、事件和资源加载语义是否相同?
- hydration、streaming 与恢复适用于哪些环境?
- DevTools 如何映射不同宿主节点?
React Native 还拥有自己的 bridge、新架构和发布节奏。相同 component model 并不意味着相同 runtime constraints。
本文判断:多 renderer 让 React 从 DOM helper 升级为 UI runtime,也把项目从一个库变成一组必须保持语义一致的执行器。Fiber 的重要性因此不只在并发,它还提供了能被不同宿主共同消费的工作表示。
到 2015 年,React 已经证明组件模型可以跨平台,但旧 reconciler 仍依赖同步递归。下一章进入项目史上最危险的工程决策:不改变大多数公共 API,重写负责协调整棵组件树的核心。