Skip to content

React Native 和 Expo 的渲染原理

答应大家下次要分享前端技术在移动端、跨端领域的应用,所以这次给大家带来 RN 和 Expo 的技术分享。

相信大家刚刚开始看 React Native 和 Expo 的时候,和我一样一直有一个问题:

为什么我写的是 React 组件,最后却能直接在手机上显示出来?

这个问题看起来不复杂,但真要往下追,就会牵扯到很多东西。React、React Native、Expo、Metro、iOS、Android,这些概念如果放在一起看,很容易让人混掉。

所以今天我主要想讲清楚的是:React Native 到底是怎么把前端代码渲染到手机上的,Expo 又在这个过程中负责什么。

React Native 的是干啥滴

首先,什么是 React Native 的渲染?

React Native 的本职工作,是把 React 代码变成移动端的原生视图。它不是在手机上跑一个网页,也不是把 HTML 页面包起来再显示出来,而是把 JSX 描述的结构,转成 iOS 和 Android 能直接处理的原生组件。

也就是说,开发者写出来的并不是最终画面本身,而是一份 UI 描述。React Native 负责把这份描述翻译成平台原生控件,最后再由 iOS 和 Android 把界面画到屏幕上。

这一点和 Web 里的 React 很不一样。Web React 最终面对的是 DOM,而 React Native 面对的是原生视图。两者都使用 React 的写法,但落到的目标完全不同。

React Native 里的 UI 不是 DOM!

那么其次,React Native 和 Web React 的区别到底在哪里?

最直接的区别,就是它们最终渲染的目标不同。Web 上的 React 需要把组件渲染到 DOM 里,React Native 需要把组件渲染到原生控件里。

例如一个输入框,在 iOS 上会对应到 UITextField 一类的原生控件,在 Android 上会对应到对应平台的输入组件。开发者写的是同一套 React 组件,但真正执行渲染的是不同平台自己的 UI 系统。

这也是 React Native 和 Web 最大的差异之一。React Native 并没有把移动端做成一个网页,它只是让开发者用 React 的方式去描述原生界面。

渲染流程

那么接下来就是 React Native 的渲染流程。

React Native 现在把这个过程拆成三个阶段:RenderCommitMount

如果只看这三个词,会觉得它们都很抽象。但如果把它放到一次真实的状态更新里看,事情就清楚很多了。

比如页面上有一个按钮,点击以后计数加一。这个动作发生之后,React 先会知道“状态变了”,然后开始重新计算这个组件树应该长成什么样。这个时候还没有真正去改屏幕上的内容,先改的是内部结构。

React Native 做的,就是把这次变化从 JavaScript 侧一路传到原生侧。它不是一下子把整块界面重画一遍,而是先把新的结构算出来,再把需要更新的部分提交出去,最后挂到原生视图上。

tsx
function Counter() {
  const [count, setCount] = useState(0)

  return (
    <View>
      <Text>{count}</Text>
      <Button title="add" onPress={() => setCount(count + 1)} />
    </View>
  )
}

count 变化以后,React 会重新计算这段 JSX 对应的 React Element Tree。
React Native 再把这个新树和上一次的树比较,找出需要更新的地方。最后它把这些变化翻译成原生层能执行的最小变更。

这个过程里,真正重要的不是“重新画一遍”,而是“只更新发生变化的那部分”。

Render

首先是 Render。

这一步发生在 JavaScript 侧。React 先根据组件和状态变化,生成一棵 React Element Tree。在新架构里,这棵树还会对应到 C++ 层的 Shadow Tree。

这个阶段做的是结构计算。页面里有哪些组件,它们之间怎么嵌套,属性是什么,状态更新以后应该生成什么样的新树,这些事情都在这个阶段处理。

如果页面里有多个组件,React 并不会因为某一个地方更新了,就傻乎乎地把整个页面重新拼一遍。它会先根据新的状态,算出新的结构,然后和上一次的结构做比较。这样一来,哪些地方变了,哪些地方没变,就都能被识别出来。

所以 Render 阶段更像是在整理一份新的 UI 草稿。它还没真正碰到屏幕,但已经把接下来要显示什么内容算好了。

这里还可以顺手看一下树的样子。比如下面这个结构:

jsx
<View>
  <Text>Hello</Text>
</View>

React 侧会先得到对应的元素树,C++ 侧再生成 Shadow Tree。Shadow Tree 里保存的是组件层级、样式信息和后续布局所需的数据。它不是最终的屏幕树,但它是后面布局和挂载的基础。

Commit

然后是 Commit。

当新的树准备好之后,React Native 会把这棵树提交给后续阶段处理,并触发布局计算。这个阶段还没有真正把内容画到屏幕上,但已经把下一次要挂载的内容准备好了。

这里的重点不是“有没有画出来”,而是“这份更新是不是已经准备好交给原生层了”。Commit 发生以后,React Native 会知道这次更新可以进入下一步了,接下来就是把布局结果和视图变化落到原生对象上。

如果把 Render 当成“写草稿”,那 Commit 就是“把草稿交给排版和印刷部门处理”。这一步不负责最后展示,但它决定了接下来的内容能不能顺利落到屏幕上。

Commit 阶段主要做两件事:布局计算和 tree promotion。

布局计算会把每个 Shadow Node 的尺寸和位置算出来。React Native 这里使用的是 Yoga。Yoga 处理的是一棵 layout tree,开发者在样式里写的 flexwidthheightpaddingmargin 等信息,都会被送进这套布局系统里。

例如:

tsx
<View style={{ flex: 1 }}>
  <View style={{ height: 100 }} />
  <View style={{ flex: 1 }} />
</View>

这段代码里,外层容器先拿到整块可用空间,内部两个子节点再按照各自的约束分配位置和大小。这个过程不是浏览器的 CSS 布局引擎在做,而是 Yoga 在做。

布局结果算出来以后,新的 Shadow Tree 会被提升为 next tree,等待 Mount 阶段被真正挂载。

Mount

最后是 Mount。

这一步会把已经计算好的结果真正挂到原生视图上。到了这里,iOS 或 Android 才会把界面显示到屏幕上。

Mount 阶段做的事情,已经很接近“把页面真正摆出来”了。原生视图被创建、更新、插入、删除,最后形成用户在手机上看到的那一帧界面。前面所有的计算,到了这里才真正变成了肉眼可见的内容。

也就是说,从开发者写代码,到用户看到界面,中间并不是直接“渲染一下”就结束了,而是经历了结构生成、提交、布局、挂载这一整套过程。

这套过程看起来繁琐,但它做的事情其实很明确:先把 UI 的结构算清楚,再把结构交给布局系统,最后把结果落到平台自己的原生视图上。

Mount 阶段还会做 tree diffing。React Native 会把“上一次已经挂载的树”和“这一次准备挂载的新树”做比较,然后计算出一组原子级别的 mutation,例如 createViewupdateViewremoveView 之类的操作。

这个 diff 过程是在 C++ 里完成的。它的目标很直接:不要把整棵树推倒重来,只把真正变化的部分转成原生操作。

txt
previous tree  ->  diff  ->  mutation list  ->  host views

如果一个节点只是改了背景色,React Native 不会把整页重新创建一遍,它只会生成对应的更新操作。
如果一个节点根本没有变化,diff 之后就不会产生多余的原生指令。

View Flattening 也是 Mount 阶段里很重要的优化。React 的组件树经常会比最终需要显示的原生视图树更深,其中有不少节点只是为了组织布局,并不真正负责显示内容。View Flattening 会把这类节点折叠掉,减少不必要的原生视图创建。

这也是为什么 React Native 的渲染链路里,结构树、布局树和最终的 host view tree 并不是一回事。它们的层级不同,职责也不同。

到这里,React Native 这一侧的流程已经比较完整了,但这些结果还只是“准备好”,真正落到屏幕上,还要看原生平台怎么接住这些更新。

这些更新在原生侧是怎么生效的?

如果只停留在 React 树这一侧,很多细节还是不够完整。真正把东西画到屏幕上的,还是原生平台自己的视图系统。

在 React Native 里,最基础的几个组件都对应着平台自己的原生控件。

  • View 会对应到 iOS 的 UIView,在 Android 上通常对应 ViewViewGroup
  • Text 由平台的原生文本渲染系统处理
  • TextInput 会接到平台原生输入控件
  • Image 会走平台自己的图片解码、缓存和绘制链路

所以当你写下面这种代码的时候:

tsx
<View style={{ padding: 12, backgroundColor: '#fff' }}>
  <Text>Hello</Text>
  <TextInput placeholder="type here" />
</View>

React Native 并不是把这段 JSX 变成某个中间页面对象,而是把它拆成一组原生视图操作:

  • 创建一个容器视图
  • 给容器设置 padding 和背景色
  • 创建文本节点并交给原生文本渲染
  • 创建输入框并连接键盘、焦点和输入事件

这就是 React Native 最核心的一点:JS 层写的是声明式 UI,真正执行的是原生视图系统。

原生视图管理

在原生侧,每个平台都有自己的视图管理方式。

Android 上,常见的是通过 ViewManagerSimpleViewManager 来创建和管理原生视图。这个管理器负责把 React 传来的 props 更新到原生控件上,也负责把原生事件再发回 JavaScript。

iOS 上则会通过 UIViewUIViewController、属性 setter、事件回调这些方式完成同样的工作。React Native 最终要做的事情,就是把 JavaScript 侧的属性更新,映射成平台侧的对象状态变化。

例如一个自定义图片组件,原生侧一般会做这些事:

  1. 创建一个原生图片视图
  2. 接收 sourceresizeModetintColor 之类的属性
  3. 在属性变化时更新原生控件
  4. 在点击、加载完成、失败等节点上向 JS 发事件

这样一来,JS 侧的一个 <Image /> 组件,就真正和平台图片视图接上了。

Android 侧的代码通常会长得像这样:

kotlin
class MyBoxViewManager : SimpleViewManager<View>() {
  override fun getName() = "MyBox"

  override fun createViewInstance(context: ThemedReactContext): View {
    return View(context)
  }

  @ReactProp(name = "color")
  fun setColor(view: View, color: Int) {
    view.setBackgroundColor(color)
  }
}

这个例子很简单,但结构已经很完整了:创建视图、接收属性、更新原生对象。
React 侧传来的 props,就是这样一点一点落到原生控件上的。

iOS 侧也类似,通常会创建一个 UIView 子类,然后通过属性更新方法把 JS 侧的值写进去,再把需要的事件回传给 JS。

事件是怎么回来的

原生侧不只是接收更新,还会主动把事件发回来。

比如点击、滑动、滚动、输入变化、布局完成,这些事情很多都先发生在原生侧,然后再回到 JavaScript 侧处理。常见的事件包括:

  • onPress
  • onScroll
  • onChangeText
  • onLayout

其中 onLayout 很值得提一下。它不是简单的“组件加载完成”,而是原生布局结果已经算出来以后回传给 JS。很多依赖尺寸的逻辑,比如弹层定位、动态高度列表、测量元素位置,都会用到这个事件。

如果需要主动测量一个视图,React Native 也提供了 ref 相关能力。开发者拿到一个原生组件引用以后,可以读取它的边界信息,或者在老接口里调用 measure。这类能力通常会用在手势定位、弹窗锚点、滚动吸顶这些场景里。

原生模块

除了视图,React Native 还有原生模块。

原生模块解决的是另一类问题:有些功能本来就应该交给平台原生 API 去做,比如相机、通知、定位、文件系统、加密、数据库、传感器这些东西。

如果写纯 JS,这些能力要么做不到,要么性能不够好,要么对系统集成不完整。原生模块允许 JavaScript 直接调用原生代码,让这些能力以模块的方式暴露出来。

例如一个相机模块,JS 侧可能只是调用 openCamera(),真正执行的是 iOS 的 AVFoundation 或 Android 的相机 API。React Native 只负责把调用路由到正确的平台代码上。

Expo 也提供了自己的原生模块体系。Expo Modules API 允许你用 Swift 和 Kotlin 写原生能力,然后把它暴露给 JavaScript。这样做的好处是两端写法比较统一,而且和新架构兼容得更好。

例如一个简单的原生视图模块,在 Expo 侧可以直接声明一个原生组件,并定义属性和方法。概念上和 React Native 的 native component 很接近,只是写法更现代一些。

swift
import ExpoModulesCore

public class CounterModule: Module {
  public func definition() -> ModuleDefinition {
    Name("Counter")

    Function("increment") { (value: Int) in
      return value + 1
    }
  }
}
kotlin
class CounterModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("Counter")

    Function("increment") { value: Int ->
      value + 1
    }
  }
}

这种模块很适合拿来做简单的原生能力验证。
如果你以后要把更复杂的原生能力接进来,比如传感器、图片处理、加密算法、系统设置页入口,这种方式都是基础。

讲到这里,原生侧的视图、事件和模块基本都串起来了。再往下看,就能明白为什么 React Native 不能把所有事都塞到一个线程里做。

为什么要拆线程

React Native 之所以要把这些过程拆开,一个很重要的原因,就是移动端不能把所有事情都塞到一个线程里做。

React Native 的线程模型里,最关键的是两个线程:

  • UI thread,负责真正操作原生视图
  • JavaScript thread,负责执行 React 逻辑和更新计算

这样拆开以后,JS 侧的计算不会直接阻塞原生界面,原生视图的更新也不会随便被 JS 打断。对于移动端来说,这一点很重要,因为用户对界面的响应速度要求很高。

如果所有渲染和交互都放在一个线程里,代码稍微复杂一点,界面就很容易卡住。React Native 现在的线程模型,本质上就是为了避免这个问题。

这里还有一个很重要的限制:原生视图只能在 UI thread 上修改。React Native 不能随意跳过这个规则,它必须遵守平台本身的线程约束。

线程拆开以后,问题就从“怎么画”变成了“怎么在不同层之间传递变化”。这也就是旧架构和新架构要解决的事情。

旧架构和新架构

然后再看 React Native 的旧架构和新架构。

早期的 React Native 最常提到的是 Bridge。这个阶段里,JavaScript 和原生之间的通信方式更像是消息传递。JS 侧先把操作序列化,再通过桥接发给原生,原生收到以后再执行。

这种方式在最开始能工作,也足够清晰,但它有几个很明显的问题:

  • 序列化和反序列化会消耗性能
  • JS 和原生之间的通信不够直接
  • 某些同步能力做起来比较别扭
  • 调度空间有限

所以 React Native 后来开始重构底层架构。新架构里比较核心的几个东西是 Fabric、JSI、TurboModules 和新的事件循环。

这些改动带来的变化很直接。原来的桥接方式更偏向异步消息传递,新架构则更强调 JS、C++ 和原生之间的直接协作。这样做之后,渲染链路更短,更新调度更灵活,也更容易支持现代 React 的能力。

这也是为什么现在再看 React Native 的渲染原理,不能只盯着 Bridge 看。Bridge 只是它曾经的一种实现方式,不是现在全部的样子。

讲完底层渲染和通信方式以后,Expo 的位置也就更清楚了。它不是渲染引擎,但它决定了你怎么把这些原生能力接进项目里。

Expo 在这里负责什么

说到 Expo,很多人第一反应会觉得它是不是也参与了渲染。

其实不是。Expo 不负责把 UI 画到屏幕上,这件事还是 React Native 自己做。Expo 主要负责的是开发流程和工程管理。

它管的事情包括:

  • 项目怎么初始化
  • 原生工程怎么生成
  • 原生模块怎么自动链接
  • 开发时怎么快速预览
  • 真机和模拟器怎么运行

Expo Go

先说 Expo Go。

Expo Go 可以看成一个现成的原生容器。开发者把 JavaScript bundle 连上去,就能很快看到项目效果。它很适合快速验证,但它的原生能力是固定的,不能随便加入新的原生库。

所以 Expo Go 更适合做早期开发和验证。它把启动成本压得很低,但也限制了你能改动的范围。

Development Build

如果项目需要更完整的原生能力,就需要用 development build。

这时候得到的不是公共容器,而是属于自己的 Expo 客户端。它可以包含自定义的原生配置、原生模块和更完整的开发能力。

这个阶段很重要,因为一旦项目开始依赖更多原生能力,Expo Go 就不够用了,development build 才是更接近真实产品的开发方式。

Prebuild

Expo 的 prebuild 会根据配置生成 android/ios/ 目录。

这说明 Expo 并不是在绕开原生工程,而是在把原生工程的生成过程自动化。它降低的是维护成本,不是替代 React Native 的渲染系统。

如果把 React Native 看成渲染引擎,那 Expo 更像是围绕这个引擎的一整套开发工具。

也正因为这样,Expo 的价值不在于改写渲染过程,而在于把原生工程的复杂度提前处理掉。

从代码到屏幕

前面把 Render、Commit、Mount、原生侧和线程模型都拆开看过一遍以后,再把它们合起来,链路就很清楚了。

如果把整个链路串起来,大概可以写成这样:

text
JSX
  -> React Element Tree
  -> Shadow Tree
  -> Layout Calculation
  -> Mount to Native Views
  -> Screen

更直接一点说就是:

开发者先写出界面结构,React 负责生成组件树,React Native 负责把这棵树交给布局系统和原生平台,最后由 iOS 或 Android 把结果真正显示出来。

所以 React Native 的目标不是做一个跨平台网页框架,而是让开发者用 React 的方式去驱动移动端原生 UI。

把前面的内容收回来,其实整个链路就是这件事:前端写结构,React 算树,原生平台负责把结果画出来。

这也正好把前面的所有概念扣在了一起。React Native 负责的是渲染,Expo 负责的是把这套渲染流程放进更好用的开发环境里。

React Native 和 Expo 的关系

最后再说一下 React Native 和 Expo 的关系。

这两个东西本来就不在同一层。

React Native 解决的是 UI 怎么渲染到原生平台上,Expo 解决的是开发者怎么更轻松地把项目跑起来、构建起来、维护起来。

把它们放在一起看,最容易混淆的地方其实也就在这里:一个管界面怎么落到屏幕上,一个管项目怎么落到设备上。

它们不是竞争关系,也不是替代关系,而是上下游关系:

  • React Native 决定渲染机制
  • Expo 决定开发体验

所以你用 Expo 开发 React Native 应用的时候,底层依然还是 React Native 的渲染逻辑在工作,只是 Expo 帮你把很多原生工程的细节收掉了。

把这两个东西放在一起看,就不会再把它们当成同一层的概念了。

总结

如果把前面的内容按顺序排一下,大概就是这样:

  • React 先生成 UI 结构
  • React Native 把结构交给布局、diff 和原生挂载
  • 原生平台负责把最终结果显示出来
  • Expo 负责把整个开发和构建流程管理好

如果把这件事压缩成一句话,我会这么说:

React Native 负责把 React 的声明式 UI 变成原生视图,Expo 负责把这条链路包装成更省心的开发流程。

理解了这一点之后,再去看 Expo Go、development build、prebuild、Fabric、JSI 这些名词,脑子里就不会乱了。它们不是一堆孤立的术语,而是同一条链路上的不同节点。

前面这一套如果能说明白,后面再看别的跨端框架或者原生能力接入,思路基本也就通了。

这里再给下次分享挖个坑吧,下次分享大家更想了解 React 渲染网页的原理呢?还是其他 移动端 / 跨端 技术呢?还是更想了解一些后端相关知识呢?

参考链接

Last updated: