渲染、提交和挂载
本文档引用了新架构,该架构正在积极推广中。
React Native 渲染器通过一系列工作将 React 逻辑渲染到宿主平台(Host Platform)。这一系列工作被称为渲染管线(render pipeline),它在初始渲染和 UI 状态更新时触发。本文将详细介绍渲染管线,并探讨其在不同场景下的差异。
渲染管线可大致分为三个阶段:
- 渲染(Render):React 执行业务逻辑,在 JavaScript 中创建 React 元素树(React Element Tree)。渲染器基于此树在 C++ 中创建一个 React 投影树(React Shadow Tree)。
- 提交(Commit):在 React 投影树完整创建后,渲染器会触发提交。这会将 React 元素树和新创建的 React 投影树提升(promote)为即将挂载的“下一棵树”。同时,它还会调度布局信息的计算。
- 挂载(Mount):包含布局计算结果的 React 投影树被转换为宿主视图树(Host View Tree)。
渲染管线的不同阶段可能在不同的线程上执行。更多详情请参阅线程模型(Threading Model)文档。

初始渲染
设想你想要渲染以下内容:
function MyComponent() {
return (
<View>
<Text>Hello, World</Text>
</View>
);
}
// <MyComponent />
在上述示例中,<MyComponent /> 是一个 React 元素。React 通过递归调用它(如果是基于 JavaScript 类的实现,则调用其 render 方法),将该 React 元素缩减为最终的 React 宿主组件(React Host Component),直到所有的 React 元素都无法进一步缩减为止。现在,你就拥有了一棵由 React 宿主组件组成的 React 元素树。
阶段 1. 渲染

在元素缩减的过程中,每当一个 React 元素被调用时,渲染器也会同步创建一个 React 投影节点(React Shadow Node)。这仅针对 React 宿主组件发生,而不针对 React 复合组件。在上述示例中,<View> 导致创建了一个 ViewShadowNode 对象,<Text> 导致创建了一个 TextShadowNode 对象。值得注意的是,不存在直接代表 <MyComponent> 的 React 投影节点。
每当 React 在两个 React 元素节点之间创建父子关系时,渲染器也会在对应的 React 投影节点之间创建相同的关系。这就是 React 投影树的组装方式。
更多细节
- 这些操作(创建 React 投影节点、创建两个 React 投影节点之间的父子关系)是同步且线程安全的操作,它们从 React (JavaScript) 执行到渲染器 (C++) 中,通常在 JavaScript 线程上完成。
- React 元素树(及其构成的 React 元素节点)不会永久存在。它是由 React 中的“纤维(fibers)”物化而成的临时表现形式。每个代表宿主组件的“fiber”都存储一个指向 React 投影节点的 C++ 指针,这是通过 JSI 实现的。在此文档中详细了解“fibers”。
- React 投影树是不可变的。为了更新任何 React 投影节点,渲染器会创建一个新的 React 投影树。然而,渲染器提供了克隆操作,以使状态更新更具性能优势(详见 React 状态更新)。
在上述示例中,渲染阶段的结果如下所示:

React 投影树完成后,渲染器会触发 React 元素树的提交。
阶段 2. 提交

提交阶段包含两个操作:布局计算(Layout Calculation)和树提升(Tree Promotion)。
- 布局计算:该操作计算每个 React 投影节点的位置和大小。在 React Native 中,这涉及调用 Yoga 来计算每个 React 投影节点的布局。实际计算需要每个 React 投影节点的样式(源自 JavaScript 中的 React 元素)。它还需要 React 投影树根节点的布局约束,这决定了结果节点可以占据的可用空间大小。

- 树提升(新树 → 下一棵树):该操作将新的 React 投影树提升为即将挂载的“下一棵树”。此提升表明新的 React 投影树已具备挂载所需的所有信息,并代表了 React 元素树的最新状态。“下一棵树”将在 UI 线程的下一个“tick”进行挂载。
更多细节
- 这些操作在后台线程上异步执行。
- 大部分布局计算完全在 C++ 内部执行。然而,某些组件的布局计算依赖于宿主平台(例如
Text、TextInput等)。文本的大小和位置特定于每个宿主平台,需要在宿主平台层进行计算。因此,Yoga 会调用宿主平台中定义的函数来计算该组件的布局。
阶段 3. 挂载

挂载阶段将 React 投影树(现已包含来自布局计算的数据)转换为屏幕上显示渲染像素的宿主视图树。作为回顾,React 元素树如下所示:
<View>
<Text>Hello, World</Text>
</View>
从宏观上看,React Native 渲染器为每个 React 投影节点创建一个对应的宿主视图(Host View)并将其挂载到屏幕上。在上述示例中,渲染器为 <View> 创建 android.view.ViewGroup 的实例,为 <Text> 创建 android.widget.TextView,并将其填充为“Hello World”。同样,在 iOS 上会创建一个 UIView,并通过调用 NSLayoutManager 来填充文本。然后,每个宿主视图都被配置为使用其 React 投影节点的 props,并使用计算出的布局信息配置其大小和位置。

具体来说,挂载阶段包含以下三个步骤:
- 树差异计算(Tree Diffing):此步骤完全在 C++ 中计算“先前渲染的树”与“下一棵树”之间的差异。结果是一个要在宿主视图上执行的原子突变操作列表(例如
createView、updateView、removeView、deleteView等)。这一步也是 React 投影树进行平铺(flattening)的地方,以避免创建不必要的宿主视图。有关此算法的详细信息,请参阅视图平铺(View Flattening)。 - 树提升(下一棵树 → 已渲染树):此步骤原子地将“下一棵树”提升为“先前渲染的树”,以便下一次挂载阶段针对正确的树计算差异。
- 视图挂载:此步骤将原子突变操作应用于相应的宿主视图。此步骤在 UI 线程上的宿主平台中执行。
更多细节
- 这些操作在 UI 线程上同步执行。如果提交阶段在后台线程执行,挂载阶段将被调度到 UI 线程的下一个“tick”。另一方面,如果提交阶段在 UI 线程上执行,挂载阶段会在同一线程上同步执行。
- 挂载阶段的调度、实现和执行在很大程度上取决于宿主平台。例如,Android 和 iOS 之间的挂载层渲染器架构目前有所不同。
- 在初始渲染期间,“先前渲染的树”为空。因此,树差异计算步骤的结果将仅包含创建视图、设置 props 以及将视图相互添加的操作列表。在处理 React 状态更新时,树差异计算对于性能变得更加重要。
- 在当前的生产测试中,一个 React 投影树通常包含约 600-1000 个 React 投影节点(视图平铺前),平铺后树被缩减为约 200 个节点。在 iPad 或桌面应用中,此数量可能会增加 10 倍。
React 状态更新
让我们探索当 React 元素树的状态更新时渲染管线的每个阶段。假设你在初始渲染中渲染了以下组件:
function MyComponent() {
return (
<View>
<View
style={{backgroundColor: 'red', height: 20, width: 20}}
/>
<View
style={{backgroundColor: 'blue', height: 20, width: 20}}
/>
</View>
);
}
应用在初始渲染部分描述的内容,你可以预期会创建以下树:

注意,节点 3 映射到一个带有红色背景的宿主视图,节点 4 映射到一个带有蓝色背景的宿主视图。假设作为 JavaScript 业务逻辑中状态更新的结果,第一个嵌套 <View> 的背景从 'red' 变为 'yellow'。新的 React 元素树可能如下所示:
<View>
<View
style={{backgroundColor: 'yellow', height: 20, width: 20}}
/>
<View
style={{backgroundColor: 'blue', height: 20, width: 20}}
/>
</View>
React Native 如何处理此更新?
当发生状态更新时,渲染器需要从概念上更新 React 元素树,以便更新已挂载的宿主视图。但为了保持线程安全, React 元素树和 React 投影树都必须是不可变的。这意味着 React 不会突变当前的 React 元素树和 React 投影树,而是必须创建每棵树的新副本,其中包含新的 props、样式和子节点。
让我们探索状态更新期间渲染管线的每个阶段。
阶段 1. 渲染

当 React 创建包含新状态的新 React 元素树时,它必须克隆受更改影响的每个 React 元素和 React 投影节点。克隆后,新的 React 投影树被提交。
React Native 渲染器利用结构共享来最小化不可变性的开销。当为了包含新状态而克隆 React 元素时,路径上直到根节点的所有 React 元素都会被克隆。只有当 React 元素需要更新其 props、样式或子节点时,React 才会克隆它。任何未受状态更新影响的 React 元素都由旧树和新树共享。
在上面的示例中,React 使用这些操作创建新树:
- CloneNode(节点 3,
{backgroundColor: 'yellow'}) → 节点 3' - CloneNode(节点 2) → 节点 2'
- AppendChild(节点 2', 节点 3')
- AppendChild(节点 2', 节点 4)
- CloneNode(节点 1) → 节点 1'
- AppendChild(节点 1', 节点 2')
这些操作之后,节点 1' 代表了新 React 元素树的根。让我们将 T 分配给“先前渲染的树”,将 T' 分配给“新树”:

注意 T 和 T' 如何共享节点 4。结构共享提高了性能并减少了内存使用。
阶段 2. 提交

React 创建新的 React 元素树和 React 投影树后,必须提交它们。
- 布局计算:类似于初始渲染期间的布局计算。一个重要的区别是布局计算可能会导致共享的 React 投影节点被克隆。这是因为如果共享的 React 投影节点的父级发生了布局更改,共享的 React 投影节点的布局也可能发生更改。
- 树提升(新树 → 下一棵树):类似于初始渲染期间的树提升。
阶段 3. 挂载

- 树提升(下一棵树 → 已渲染树):此步骤原子地将“下一棵树”提升为“先前渲染的树”,以便下一次挂载阶段针对正确的树计算差异。
- 树差异计算:此步骤计算“先前渲染的树”(T)与“下一棵树”(T')之间的差异。结果是需要在宿主视图上执行的原子突变操作列表。
- 在上述示例中,操作包括:
UpdateView(**节点 3**, {backgroundColor: 'yellow'}) - 差异计算可以针对任何当前已挂载的树与任何新树进行。渲染器可以跳过树的一些中间版本。
- 在上述示例中,操作包括:
- 视图挂载:此步骤将原子突变操作应用于相应的宿主视图。在上述示例中,只有视图 3 的
backgroundColor会被更新(变为黄色)。

React Native 渲染器状态更新
对于投影树中的大多数信息,React 是单一所有者和单一事实来源。所有数据源自 React,并且存在单一方向的数据流。
然而,有一个例外和重要的机制:C++ 中的组件可以包含未直接暴露给 JavaScript 的状态,且 JavaScript 并非事实来源。C++ 和宿主平台控制此 C++ 状态。通常,这仅在你正在开发需要 C++ 状态的复杂宿主组件时才相关。绝大多数宿主组件不需要此功能。
例如,ScrollView 使用此机制告知渲染器当前的偏移量。该更新由宿主平台触发,具体来说是从代表 ScrollView 组件的宿主视图触发。关于偏移量的信息被用于类似 measure 的 API 中。由于此更新源于宿主平台,且不影响 React 元素树,因此此状态数据由 C++ 状态持有。
从概念上讲, C++ 状态更新类似于上述描述的 React 状态更新。但有两个重要区别:
- 它们跳过了“渲染阶段”,因为不涉及 React。
- 更新可以在任何线程上发起和发生,包括主线程。
阶段 2. 提交

执行 C++ 状态更新时,一段代码请求更新 ShadowNode (N) 以将 C++ 状态设置为值 S。React Native 渲染器将反复尝试获取最新提交版本的 N,用新状态 S 克隆它,并将 N’ 提交到树中。如果在此期间 React 或另一个 C++ 状态更新进行了另一次提交,则 C++ 状态提交将失败,渲染器将多次重试 C++ 状态更新,直到提交成功。这防止了事实来源的冲突和竞争。
阶段 3. 挂载

挂载阶段实际上与 React 状态更新的挂载阶段几乎相同。渲染器仍然需要重新计算布局、执行树差异计算等。详情请见上述章节。