渲染、提交和挂载
本文档引用了正在积极推广的新架构。
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 线程的下一个“滴答”中挂载。
其他详细信息
- 这些操作在后台线程上异步执行。
- 大部分布局计算完全在 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 线程的下一个“滴答”中调度。另一方面,如果提交阶段在 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 必须创建每个树的新副本,其中包含新的 props、样式和子元素,而不是修改当前的React 元素树和React 阴影树。
让我们在状态更新期间探索渲染管道的每个阶段。
阶段 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 状态更新的挂载阶段 相同。渲染器仍然需要重新计算布局、执行树差异比较等。有关详细信息,请参阅以上各节。