React Native延迟的真正根源:边界跨越,而非缓慢的代码
了解为何在 React Native 中,快速的 API 和缓存组件仍可能显得迟缓,以及如何通过批量处理、节流机制和 JSI 时代的设计来降低跨运行时开销。
来源: The Hidden Latency Cost in React Native Bridge Architectures
即便各项指标都显示正常,React Native 应用界面仍可能显得迟缓:API 响应时间约为 100 毫秒,查询已建立索引,组件被缓存,图片也来自缓存。然而点击操作的反应时间却过长,底部面板加载不畅,地图标记与实际位置不同步,旗舰手机上流畅的动画在廉价安卓设备上却出现卡顿,而大量传感器事件的涌入还会导致界面冻结。在许多这类情况下,缺失的时间都耗费在了团队很少进行监控的环节——即 JavaScript 运行时与原生代码之间的交互过程。
本指南阐述了在旧版 Bridge 模式下这种数据传递机制的运作方式,为何其成本会累积而非体现在某一次单独调用中,新架构(JSI、TurboModules、Fabric以及重新设计的渲染器)究竟带来了哪些变化,以及无论使用何种架构,哪些设计习惯都能保持跨运行时通信的低成本。自 React Native 0.76 版本起,新架构已成为默认选择,但了解旧版 Bridge 依然很有必要:许多实际应用中仍存在由它形成的旧模块、互操作层、过时的库以及相关设计模式。更重要的是,即便移除 Bridge,那些设计不佳的运行时间通信方式也不会因此变得免费。
一个应用中的两种执行环境
一个有用的思维模型是:React Native 应用至少在两个环境中运行。你的组件、状态、业务规则以及网络相关功能都存在于 JavaScript 环境中;而所有与操作系统交互的功能则运行在原生环境中。
┌──────────────────────────────┐
│ JavaScript / TS │
│ │
│ React Components │
│ State Management │
│ Business Logic │
│ API Handling │
└──────────────┬───────────────┘
│
│ JS ↔ Native communication
│
┌──────────────▼───────────────┐
│ Native Side │
│ │
│ Android / iOS APIs │
│ Camera │
│ Bluetooth │
│ GPS │
│ Storage │
│ Native Views │
│ Sensors │
└──────────────────────────────┘
React 代码可以在 JavaScript 中运行,但几乎所有有实际意义的功能最终都需要借助平台能力。需要跨环境调用的典型原因包括:
- 渲染原生 UI 视图,
- 启用摄像头,
- 读取 GPS 坐标,
- 访问文件系统,
- 触发触觉反馈,
- 与蓝牙设备通信,
- 使用安全存储功能,
- 调用第三方原生 SDK,
- 接收传感器数据,
- 调用其他平台专有的 API。
因此,数据会不断在两个环境之间传输。在传统架构中,所有这些数据流都要经过 React Native Bridge。
旧版 Bridge 是如何处理调用请求的
最初的设计刻意将 JavaScript 执行与原生代码执行分开。一次完整的往返流程大致如下:
JavaScript
↓
Prepare parameters
↓
Serialize data
↓
Queue bridge message
↓
Cross execution boundary
↓
Native receives message
↓
Native performs operation
↓
Prepare result
↓
Serialize result
↓
Queue response
↓
JavaScript receives result
其核心特点就是整个数据交换过程是异步的。参数会被序列化后放入队列,跨边界传输,在原生端再次反序列化并处理,最终结果也会通过同样的路径传回。React Native 团队在其新架构的设计文档中将序列化与异步桥接视为旧模式的核心缺陷。
对于偶尔发生的调用来说,这种开销并无大碍。将字符串写入剪贴板就是一个很好的例子:
await Clipboard.setString("Hello");
无论这里的跨接成本是多少,用户都永远感知不到。问题在于当该边界变成应用持续依赖的高频通道时。
单次调用成本不高,问题在于使用模式
关于Bridge性能的讨论往往认为每次调用成本都高得不可接受。这种看法偏离了重点。真正的性能下降通常是由多个因素共同作用导致的:
频率 × 数据量大小 × 调度压力 × 同步要求
假设单次跨接的成本几乎为零。现在增加调用次数:
1 call
10 calls
100 calls
1,000 calls
10,000 calls
要么增大每个数据包的大小,在用户滚动时持续发送更新,要么在绘制下一个视觉状态之前要求先进行一次原生层→JavaScript层→原生层的往返处理。原本单独来看可以忽略的成本,现在却出现在了关键的渲染路径上。在性能分析工具中,没有哪个单独的函数显得缓慢;延迟分散在整个系统中,这正是它难以被察觉的原因。
将交互延迟视为一个处理流程
把交互所经历的每一个阶段都记录下来会有帮助:
Total Latency =
JS Work
+ Serialization
+ Queue Waiting
+ Context Boundary Cost
+ Native Work
+ Return Communication
+ Rendering Delay
团队往往倾向于单独调整某个环节,通常是原生层的处理时间:
Native Work
或是JavaScript层的处理时间:
JS Work
然而,用户实际体验到的只是所有这些时间的总和:
TOTAL LATENCY
假设某项原生操作的完成时间为:
2 ms
单独来看,这已经是个相当不错的数值了。但完整的拆解可能是:
JS preparation 2 ms
Serialization 2 ms
Queue waiting 4 ms
Native operation 2 ms
Response communication 3 ms
React update 4 ms
--------------------------------
Total 17 ms
原本的“2毫秒操作”实际上变成了大约17毫秒的交互时间。React Native的文档指出,在60 FPS下,每帧的可用时间为16.67毫秒;如果超过这个时间,就可能会有帧被丢弃。目前许多手机的刷新率高于60赫兹,这进一步缩短了每帧的可用时间。
每帧的预算几乎留不出多余空间
在60赫兹的情况下,计算结果为:
1000 ms / 60 ≈ 16.67 ms
这样大致就能得出
16.67 ms
生成每一帧所需的时间。在120赫兹时,这个时间会减半:
1000 ms / 120 ≈ 8.33 ms
大约8毫秒的时间实在非常少。如果其中有部分时间被用于在运行时之间无谓地传输数据,那么架构本身就会占用掉渲染预算。其表现症状很常见:
- 动画卡顿,
性能文档还将JavaScript线程的FPS与UI(主线程)的FPS区分开来。当JS线程被占满时,它无法及时处理触摸事件或由JavaScript驱动的UI更新,而那些由系统原生处理的操作,比如滚动原生列表,仍能保持流畅。这种区分解释了一个常见的调试难题:屏幕的某一部分运行流畅,而另一部分却显得卡顿。
场景:将位置信息流式加载到地图中
想象一下,某个打车应用正在接收连续的GPS定位数据。其简单的处理流程如下:
Native GPS
↓
Bridge
↓
JavaScript
↓
State Update
↓
React Render
↓
Bridge
↓
Native Map
↓
Marker Position Updated
现在为每个修复项运行该循环。单独计算位置或移动标记的操作本身并不耗时,但这种设计导致了重复的往返操作:
Native → JS → Native
只要屏幕保持打开状态,它就会不断重复运行。如果同时显示多个驱动器,数据流量会呈指数级增长:
Driver 1 position
Driver 2 position
Driver 3 position
Driver 4 position
...
Driver 100 position
逐个发送一百次微小的更新,其成本可能远高于一次性发送一个包含所有更新的精心准备的批量数据。根本原因在于数据流的架构,而非 React 的渲染机制。
场景:传感器流
加速度计和陀螺仪的数据也遵循相同的模式。假设原生代码会发送:
60 events / second
每个事件都会进入 JavaScript 进行处理,随后可能会触发新的更新并返回到原生界面。这样就形成了贯穿边界的全实时处理流程:
Sensor
↓
Native
↓
JS
↓
Calculation
↓
React
↓
Native UI
在事件频率较低时,这种方式可以完美运行。但在高频率下,尤其是在性能较弱的硬件上,事件的生成速度可能会快于应用程序的处理速度,从而产生另一项隐性成本。
增长速度超过处理速度的队列
假设事件每隔一定时间就会到达一次
10 ms
但有效处理每一个事件需要
15 ms
这已不仅仅是一个操作缓慢的问题,而是会导致队列无限积压。新的数据不断到来,而旧的数据仍在等待处理,因此用户界面始终显示的是过去的数据。对于实时界面而言,这是最糟糕的情况。用户通常并不需要所有的中间数据,他们需要的是最重要的最新数值
交互式界面增加跨边界操作次数
这一原则并非 React Native 所特有;构建分布式系统的工程师每天都在面对这一问题。以那种每次调用只设置一个字段的模块 API 为例:
await nativeModule.setName(name);
await nativeModule.setEmail(email);
await nativeModule.setAge(age);
await nativeModule.setCountry(country);
await nativeModule.setLanguage(language);
与那种一次性接受全部更新数据的 API 相比:
await nativeModule.updateProfile({
name,
email,
age,
country,
language,
});
为了获得相同的结果,第二种版本所需的跨边界操作次数要少得多。其核心原则很简单:
每次跨边界操作处理更多数据,减少操作频率。
这一原则同样适用于:
- HTTP API,
- 微服务调用,
- 数据库访问,
- 工作线程,
- 进程间通信,
- 移动端桥接技术,
- 整个分布式系统。
在网络技术中,这被称为频繁通信。传统的桥接机制只是将分布式系统中的这一经典问题引入到了单个移动应用中。
数据载荷大小是另一个重要因素
传输的频率只是问题的一半,携带的数据量同样重要。试想一下需要跨传统边界传输类似以下内容的情况:
{
users: [...10000Users]
}
发送方必须对数据结构进行编码,而接收方则需重新构建并处理这些数据。大型数组、深度嵌套的对象、图像元数据、二进制数据以及庞大的配置结构都会带来可测量的额外开销。最容易受到这种影响的工作负载包括:
- 摄像头帧,
- 音频缓冲区,
- 图像处理,
- 机器学习输入数据,
- 庞大的查询结果,
- 复杂的动画效果,
- 实时地图数据,
- 传感器流数据。
React Native的架构文档使用了一个极端但具有启发性的案例:实时摄像头帧缓冲区,每帧的数据量可达数十兆字节,每秒产生的数据量更为巨大。通过异步桥接反复序列化这类数据显然是不合适的做法。
为何摄像头处理流程会凸显成本问题
单帧图像就可能包含数兆字节的像素数据。现在再以
30 FPS
甚至
60 FPS
这样的速度传输帧数据。如果每一帧都必须转换为适合JavaScript序列化的格式并通过异步层发送,那么传输过程就会成为瓶颈。应避免在每帧处理时都采用这种方式:
Native Camera
↓
Huge serialized payload
↓
JavaScript
↓
Process
↓
Serialized result
↓
Native
正因如此,高性能库越来越倾向于将繁重的处理任务放在原生内存附近,并为 JavaScript 提供一个简洁高效的接口。这一发展方向直接带来了现代 React Native 最重要的变革之一。
JSI 的变化
JSI(JavaScript Interface)使 JavaScript 能更直接地与原生代码及 C++ 代码进行交互。它不再完全依赖旧版 Bridge 的序列化与队列处理方式:
JavaScript
↓
Serialize
↓
Bridge
↓
Deserialize
↓
Native
新架构可以通过直接接口来呈现原生功能:
JavaScript
↓
JSI
↓
C++ / Native
根据 React Native 的文档,JSI 允许 JavaScript 持有对 C++ 对象的引用,同时允许 C++ 持有对 JavaScript 对象的引用,因此无需借助桥接层的序列化机制即可调用方法。从概念上讲,调用原生方法更像是调用宿主对象上的函数,而非向另一个进程发送消息。这代表了架构上的根本性转变。
为何 JSI 无法解决性能问题
一种常见的误解是:
“JSI 消除了桥接层,所以性能问题就解决了。”
事实并非如此。虽然传输效率有了显著提升,但糟糕的系统设计依然会导致延迟。通过 JSI 进行十万次同步调用:
100,000 synchronous native calls
虽然每种方式都可能比使用 Bridge 调用更便宜,但大部分工作仍然是不必要的。反复传递巨大对象、在 JavaScript 线程上执行耗资源的逻辑、阻塞主线程 UI,或是渲染数千个本不必渲染的组件,情况也是如此。同步访问还会带来新的风险:缓慢的同步原生调用会直接阻塞 JavaScript 线程,而无法在之后再处理。
因此,结论并非“Bridge 慢,JSI 快”。更准确的表述是:
不同运行时之间的交互总会带来成本。应合理设计应用程序,避免出现不必要的交互。
TurboModules:现代的原生模块系统
TurboModules取代了传统的Native Modules系统。旧模块是通过Bridge暴露的;而新模块则通过JSI和Codegen更紧密地集成,后者能根据规范生成带类型的绑定。React Native列出的优势包括:
- 在需要时实现同步的本地访问,
- 跨越边界保持类型安全,
- 与C++互操作,
- 在多个平台间共享代码,
- 延迟加载模块。
延迟加载值得重视。在旧模型中,启动时初始化本地模块可能会在第一屏显示之前就增加工作量。而采用延迟加载后,模块仅会在首次被使用时才进行初始化。原本的情况是:
Application starts
↓
Initialize everything
↓
Eventually show UI
启动过程现在则更像是这样:
Application starts
↓
Initialize required components
↓
Show UI
↓
Load additional modules when needed
这样的改动对用户的影响远大于基准测试数据——它决定了应用多快能准备好供使用。
Fabric:重新设计中的渲染部分
并非只有通信层被重建。新的渲染系统Fabric将更多渲染基础设施移至共享的C++环境中,从而实现了在旧异步模型下难以实现的功能,包括:
- 在需要时进行同步布局访问,
- 更好的互操作性,
- 多种更新优先级设置,
- 对现代React特性的支持,
- 更灵活的调度机制。
优先级很重要,因为不同界面对每次更新的处理方式并不一致:
User presses button → HIGH priority
Background data refresh → LOWER priority
Analytics event → LOW priority
Animation → HIGH priority
Prefetching → LOW priority
一个优秀的渲染器不应将按钮点击和分析数据上报视为同等紧急的任务。优先级划分是响应式用户界面的基础,而 Fabric 正是让 React Native 能够融入 React 的并发调度模型的关键。如果你想了解 React 自身是如何编码更新优先级的,文章 讲解了 React 如何利用位掩码来编码优先级,其中详细阐述了相关机制。
真正的敌人是边界频繁变动
如果要用两个词来概括整个问题,那就是边界频繁变动:即信息的小片段在不同的执行环境之间不断来回传递。
JS
↓
Native
↓
JS
↓
Native
↓
JS
↓
Native
当看到这种结构时,通常意味着某个功能的职责被错误地划分了。需要思考的问题是这些功能实际上应该放在哪里。以音频处理为例,设计不合理的情况如下所示:
Native microphone
→ JS audio frame
→ calculate
→ native
→ next frame
→ JS
→ calculate
→ native
更好的划分方式是将高频处理保留在原生层,仅将需要进一步处理的结果传递给 JavaScript:
Native microphone
↓
Native processing
↓
Native processing
↓
Native processing
↓
Meaningful result
↓
JavaScript
虽然计算量可能相似,但通信方式完全不同,两条线程所承担的负载也各有差异。
降低交互成本的实用技巧
将批量操作合并为一次调用
那种为每个项目都等待原生调用的循环是导致性能问题的常见原因:
for (const item of items) {
await NativeModule.saveItem(item);
}
当有500个元素时,可能会产生数百次独立的交叉操作,每次操作都需要额外的调度开销。更好的原生API能够直接接收整个集合,让原生代码负责遍历处理:
await NativeModule.saveItems(items);
这两种结构的差异很容易说明:
❌ 500 boundary crossings × small operation
✅ 1 boundary crossing × 500 native operations
批量处理是最简单且最有效的架构优化手段之一。需要考虑的一个设计细节是:批量调用必须具备明确的错误处理机制,比如告知哪些元素处理失败,这样才能避免因为一个异常元素而导致其余元素被 silently丢弃。
限制或合并高频事件
假设某个原生模块每秒会发出120次更新。JavaScript很少需要处理所有这些更新。对于许多界面而言,每秒约20次有意义的更新在屏幕上呈现的效果几乎相同。与其逐一处理
120 events / second
不如直接进行批量处理
20 events / second
或者将事件累积在原生层,仅转发最新的状态。在原生层面进行节流处理通常比在 JavaScript 中处理更好,因为通过 JavaScript 发送的事件已经付出了跨层传输的成本。这种方法适用于:
- 位置跟踪,
- 滚动事件,
- 传感器数据,
- 进度报告,
- 音频音量,
- 网络统计信息,
- 地图移动,
- 实时仪表板。
最省事的处理方式就是根本不发送事件。
传输含义而非原始数据
与其将某些内容从原生层传递到 JavaScript 并让 JavaScript 来生成结果,不如在数据所在的位置直接计算结果。请替换现有的处理流程:
10 MB raw dataset
从原生层传递到 JavaScript,再让 JavaScript 推导出结果,而应在数据所在的位置直接计算结果。请替换当前的流程:
Native
↓
10 MB data
↓
JavaScript
↓
find average
为以下流程:
Native
↓
calculate average
↓
JavaScript receives: 42.7
简而言之,就是进行传输。
meaning
而不是
raw information
在架构允许的情况下。其代价是逻辑会被移至本地代码中,而这类代码必须针对每个平台单独编写和测试,或者使用通用的C++实现;仅将此方法用于真正庞大或频繁处理的数据。
网络延迟并非交互延迟
快速的后端可以掩盖糟糕的用户体验。假设API的响应时间为
120 ms
且监控面板显示为绿色状态。不过在响应到达后,仍有很多工作需要完成:
API Response
↓
JSON Parsing
↓
State Transformation
↓
Redux / Zustand / Context
↓
Component Re-rendering
↓
JS ↔ Native interaction
↓
Layout
↓
UI Commit
等到结果显示在屏幕上时,用户可能已经等待了相当长的时间
200+ ms
后端监控显示一切运行迅速;而用户则反馈应用速度很慢。两者其实都说的是实话,因为他们测量的指标不同。后端延迟与交互延迟是两个独立的指标,对于移动应用而言,通常正是后者决定了用户体验的好坏。
在用户实际使用的设备上进行测试
性能问题往往喜欢隐藏在开发者的硬件上。旗舰手机拥有快速的CPU、充足的内存、高速的存储设备、强大的GPU以及良好的散热能力,因此一切看起来都很正常。但当实际用户使用资源远少得多的手机运行同一版本应用时,
small inefficiency × thousands of operations
这些问题就会突然显现出来。仅在高端设备上测试存在风险。一个完善的设备矩阵至少应包括:
High-end device
Mid-range device
Lower-end supported device
当你的目标用户群体中包含大量使用经济型安卓手机的地区时,这一点尤为重要。
应测试发布版本,而非开发版本
开发版本会为警告提示、调试功能及工具支持添加额外开销,官方性能指南也明确建议使用发布版本进行测试。在断定React Native本身速度慢之前,先对其性能进行分析
Release / Production build
而非仅仅依赖
Development build
否则你可能会花费数小时去解决那些用户根本不会遇到的性能问题。
屏幕运行缓慢的诊断清单
在性能不佳的界面中随意使用useMemo()往往并非正确的解决方式,建议先思考以下问题。
1. JavaScript线程是否处于繁忙状态?
需检查:
- 耗时的循环代码,
2. UI线程是否处于繁忙状态?
需检查:
- 成本较高的原生渲染,
- 复杂的布局结构,
- 过大的图像文件,
- 深度的视图层次结构,
- 在错误线程上运行的动画。
3. 是否频繁跨越原生边界?
需查找此类往返操作,尤其是高频发生的情形:
Native → JS → Native
4. 数据载荷的规模有多大?
仅统计调用次数是不够的,还需测量每次调用传递的数据量。
5. 能否对调用进行批量处理?
在可能的情况下,将
many small operations
转换为
one meaningful operation
6. JavaScript真的需要这些数据吗?
如果原生代码生成了1,000个数据点,而JavaScript仅使用它们的平均值,则发送该平均值。
7. 是否需要立即执行?
保留
User-critical work
除了
background work
分析功能绝不应与同一帧内的触觉反馈发生冲突。
8. 是否仍存在对旧架构的依赖?
当前的React Native文档已将Legacy Native Modules和Legacy Native Components标记为过时版本,并建议在适当情况下使用Turbo Native Modules和Fabric Native Components。在选择用于对性能要求较高的功能的库时,需确认它支持哪种架构;通过互操作层运行的库可能无法带来预期的性能提升。支持状态变化很快,因此请根据该库的最新发布说明进行核实。
任务分配规则
对于任何对性能敏感的功能,首先需要确定应用程序的哪一部分实际负责执行该任务。
- 如果任务属于 JavaScript 范围(业务规则、UI 状态、协调逻辑),则让其在该环境中执行,避免每一步都调用原生代码。
- 如果任务属于原生代码范围(传感器循环、帧处理、音频缓冲区),则让这些高频执行的路径在原生环境中运行。
- 只有当有重要内容需要传递时才跨越边界,而非让每一步都在两侧都可见。
合理的划分通常如下所示:
┌───────────────────────┐
│ JavaScript │
│ │
│ Business decisions │
│ Application state │
│ UI orchestration │
└───────────┬───────────┘
│
Meaningful calls
│
┌───────────▼───────────┐
│ Native │
│ │
│ Platform APIs │
│ High-frequency work │
│ Hardware interaction │
└───────────────────────┘
而非像这样,每秒数百次:
JS ↔ Native ↔ JS ↔ Native ↔ JS ↔ Native
新架构实际解决了什么问题
新架构消除了旧模型中的若干结构限制。现代 React Native 不再依赖强制性的、序列化的异步 Bridge 来实现所有功能,而是通过 JSI 提供直接接口:
JSI
↓
Direct JS/native interfacing
此外还有其他构建在其之上的组件:
TurboModules
↓
Modern native module system Fabric
↓
Modern rendering architecture Codegen
↓
Typed interfaces between environments Modern scheduling
↓
Better prioritization of work
React Native 团队表示,去除 Bridge 能降低序列化开销、加快启动速度,并实现 JavaScript 与原生运行时之间更直接的通信。更深层次的变革在于灵活性——该框架现在能够更好地控制:
- 任务运行的时间,
- 任务运行的位置,
- 如何调用原生功能,
- 哪些更新具有优先级。
这一原则适用于所有边界场景
暂且放下 React Native,这个规律是通用的:每一种架构边界都会带来一定的代价。想想这些跨越边界的操作:
Browser → API
Service → Service
Application → Database
Process → Worker
JavaScript → Native
Application → Redis
Backend → Message Broker
系统设计时常常假设跨越这些边界的调用行为类似于普通的进程内函数调用,但实际上并非如此。每个边界都会增加以下几方面的开销:
- 序列化,
- 复制,
- 调度,
- 排队,
- 网络传输,
- 同步,
- 上下文切换,
- 错误处理,
- 内存管理。
Bridge 正是让这一原则在手机应用中变得尤为明显。
先优化架构,再做微观优化
人们很容易首先想到使用这类工具:
useMemo(...)
useCallback(...)
React.memo(...)
它们确实有实际用途。但如果应用程序在每次运行之间都要传输5 MB的冗余数据,且频率高达每秒30次,那么使用useMemo()节省的0.2毫秒根本无关紧要。性能优化最有效的做法是按照以下顺序进行:
Architecture
↓
Data flow
↓
Scheduling
↓
Rendering
↓
Algorithms
↓
Micro-optimizations
而不是反过来说。
关键要点
旧版的 Bridge 并非单纯的工程缺陷。它让 JavaScript 与原生代码能够在异步通信的同时保持解耦,也为 React Native 的发展奠定了基础。随着应用开始支持实时消息传递、复杂动画、摄像头与机器学习功能、地图服务、音频处理、高级手势操作以及其他高频原生交互,基于序列化异步通信的局限便很容易显现。JSI、TurboModules、Fabric 以及重新设计的调度与渲染模型虽能消除许多这类限制,但并未改变其根本的效率问题。
- 需衡量整个交互流程,而不仅仅是原生调用或 API 响应。
- 需根据帧率来规划时间:60 Hz 时约为 16.67 毫秒,120 Hz 时约为 8.33 毫秒。
- 应对操作进行批处理,在源头限制或合并事件流,并发送处理结果而非原始数据。
即使每个组件的运行速度都很快,但应用仍显得缓慢时,应检查这些组件之间的数据传递过程。通常造成性能问题的并非任务本身,而是数据为到达该任务所在位置所需经过的传输距离。