渲染、提交和挂载
本文档指的是正在积极推广的新架构。
React Native 渲染器经历一系列工作,将 React 逻辑渲染到宿主平台。这一系列工作称为渲染管道,发生在初始渲染和 UI 状态更新时。本文档将介绍渲染管道以及这些场景中的差异。
渲染管道可以分为三个主要阶段
- 渲染: React 执行产品逻辑,在 JavaScript 中创建 React 元素树。渲染器从这棵树创建 C++ 中的 React 影子树。
- 提交:在完全创建 React 影子树后,渲染器触发提交。这将提升 React 元素树和新创建的 React 影子树,使其成为要挂载的“下一棵树”。 这也会安排计算其布局信息。
- 挂载: React 影子树现在具有布局计算结果,被转换为 宿主视图树。
渲染管道的各个阶段可能发生在不同的线程上。有关更多详细信息,请参阅线程模型文档。
初始渲染
假设您要渲染以下内容
function MyComponent() {
return (
<View>
<Text>Hello, World</Text>
</View>
);
}
// <MyComponent />
在上面的示例中,<MyComponent />
是一个 React 元素。React 递归地将这个React 元素 简化为终端 React 宿主组件,方法是调用它(或者如果使用 JavaScript 类实现,则调用其 render
方法),直到每个 React 元素 都无法进一步简化为止。现在,您有了一个 React 宿主组件的React 元素树。
阶段 1. 渲染
在元素简化的过程中,当每个React 元素被调用时,渲染器也会同步创建一个 React 影子节点。这仅发生在React 宿主组件上,而不是 React 复合组件上。在上面的示例中,<View>
导致创建 ViewShadowNode
对象,<Text>
导致创建 TextShadowNode
对象。值得注意的是,永远不会有直接表示 <MyComponent>
的React 影子节点。
每当 React 在两个React 元素节点之间创建父子关系时,渲染器也会在相应的React 影子节点之间创建相同的关系。这就是 React 影子树 的组装方式。
更多细节
- 操作(创建React 影子节点,在两个React 影子节点之间创建父子关系)是同步且线程安全的操作,从 React (JavaScript) 到渲染器 (C++) 执行,通常在 JavaScript 线程上。
- React 元素树(及其组成的React 元素节点)不会无限期存在。它是 React 中“fiber”物化的临时表示。每个代表宿主组件的“fiber”都存储一个指向 React 影子节点 的 C++ 指针,这由 JSI 实现。在此文档中了解有关“fiber”的更多信息。
- React 影子树 是不可变的。为了更新任何 React 影子节点,渲染器会创建一个新的 React 影子树。但是,渲染器提供克隆操作以使状态更新更高效(有关更多详细信息,请参阅React 状态更新)。
在上面的示例中,渲染阶段的结果如下所示
在 React 影子树 完成后,渲染器会触发 React 元素树 的提交。
阶段 2. 提交
提交阶段包括两个操作:布局计算 和 树提升。
- 布局计算: 此操作计算每个 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 影子节点 创建一个相应的 宿主视图,并将其挂载到屏幕上。在上面的示例中,渲染器为 <View>
创建 android.view.ViewGroup
的实例,为 <Text>
创建 android.widget.TextView
的实例,并用“Hello World”填充它。类似地,对于 iOS,创建一个 UIView
,并通过调用 NSLayoutManager
填充文本。然后配置每个宿主视图以使用其 React 影子节点的 props,并使用计算出的布局信息配置其大小和位置。
更详细地说,挂载阶段包括以下三个步骤
- 树差异比较: 此步骤完全在 C++ 中计算“先前渲染的树”和“下一棵树”之间的差异。结果是要在宿主视图上执行的原子突变操作列表(例如
createView
、updateView
、removeView
、deleteView
等)。此步骤也是扁平化 React 影子树以避免创建不必要的宿主视图的地方。有关此算法的详细信息,请参阅视图扁平化。 - 树提升(下一棵树 → 渲染的树):此步骤原子地将“下一棵树”提升为“先前渲染的树”,以便下一个挂载阶段计算与正确树的差异。
- 视图挂载:此步骤将原子突变操作应用于相应的宿主视图。此步骤在宿主平台的 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'})
- 可以为任何当前挂载的树和任何新树计算差异。渲染器可以跳过树的一些中间版本。
- 在上面的示例中,操作包括:
- 视图挂载:此步骤将原子突变操作应用于相应的宿主视图。在上面的示例中,只会更新 View 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 状态更新的挂载阶段 相同。渲染器仍然需要重新计算布局、执行树差异比较等。有关详细信息,请参阅上面的部分。