文章

口述:RN新旧架构对比深度解析

通过多维度对比和演进思路拆解,全面理解React Native从Bridge旧架构到JSI+Fabric新架构的设计升级。

口述:RN新旧架构对比深度解析

一句话概括

React Native从旧架构(Bridge + Paper渲染器)到新架构(JSI + Fabric + Turbo Modules)的升级,本质上是将通信从”异步消息队列”变为”同步对象绑定”,将渲染从”单线程串行”变为”三线程流水线并行”。

背景与意义

React Native问世的前五年,Bridge + Paper渲染器的架构支撑了数以万计的应用。但随着应用复杂度的提升,这套架构的瓶颈越来越明显:Bridge的异步通信导致高频交互场景下的延迟感,Paper的单线程渲染放大了布局计算带来的卡顿。

React Native团队在2021年发布了”New Architecture”蓝图,并在2023-2024年逐步推进稳定。这不是一次简单的修修补补,而是对框架底层通信层、渲染层和模块系统进行了全面重构。理解新旧架构的区别,对于评估项目是否迁移、诊断性能问题、以及和面试官进行深度对话都至关重要。

本文通过一张清晰的对比框架,帮助你在脑海中建立两者的结构差异,再逐层拆解演进背后的设计思想。

概念与定义

旧架构(Legacy Architecture): 以Bridge为通信枢纽,Paper为渲染器的架构方案。Bridge在JS和原生侧之间维护JSON消息队列,Paper渲染器在UI线程上完成ShadowNode布局和原生视图更新。

新架构(New Architecture): 以JSI为通信层、Fabric为渲染器、Turbo Modules为模块系统的架构方案。JSI让JS能直接操作C++对象(同步),Fabric引入了独立的Shadow线程来做布局计算,Turbo Modules实现了原生模块的懒加载和同步调用。

Codegen(代码生成器): 新架构中负责自动生成JS和原生两侧样板代码的工具。它读取开发者用Flow或TypeScript写的接口定义,自动生成C++、Java、ObjC的桥接代码,减少了手动维护Native Module绑定代码的工作量。

新旧架构全景对比

维度旧架构(Bridge + Paper)新架构(JSI + Fabric)
通信方式异步JSON消息队列同步JSI宿主对象绑定
通信延迟约0.5-3ms单次(序列化+排队)约0.01ms单次(函数指针调用)
渲染线程单线程(UI线程负责布局+绘制)三线程(JS/Shadow/UI并行)
布局引擎Yoga运行在UI线程Yoga运行在Shadow线程
Native Module启动时全部注册懒加载+通过JSI绑定
JS引擎JavaScriptCore仅有Hermes能深度绑定JSI
列表渲染每次diff重新创建Native View复用Shadow Node的C++节点
初始化速度JS Bundle加载后才能注册Module模块懒加载,减小首屏JS体积
调试体验远程调试需通过Bridge转发JSI让JS引擎能底层断点

核心知识点拆解

1. 通信层演进:从Bridge到JSI

旧架构的Bridge就像两个国家之间通过大使馆传递外交文书——JS想要调用原生方法,需要写一份”请求文”(JSON消息),等待大使馆(Bridge)传递给原生侧,原生侧处理完后再回传一份”回复文”。

1
2
3
// 旧架构:JS调用原生
NativeModules.ImageLoader.loadImage(url).then(); // 底层走异步队列
// JS无法控制何时执行,只能等Bridge排到它

新架构的JSI相当于直接为JS和C++之间开了一条”专线”——JS能直接获取C++对象的”手机号”(指针),直接拨打(同步调用):

1
2
3
// 新架构:JS调用原生
NativeModules.ImageLoader.loadImage(url); // JSI同步查找宿主对象并执行
// JS控制执行时机,立即执行

演进的关键洞见: 当JS和原生都在同一个进程中运行时(React Native是本地应用,不是Web),异步通信本身就是不必要的抽象——JS和C++运行在同一个地址空间,直接指针调用比序列化再传输高效得多。

2. 渲染层演进:从Paper到Fabric

Paper渲染器的工作模式简单直接:JS线程计算出需要渲染的视图树 → 通过Bridge发送到原生侧 → 原生侧在UI线程上一并完成布局计算和视图创建。

Fabric将其拆分为两个独立的阶段:

  • 布局阶段(Shadow线程):构建C++ Shadow Tree,执行Yoga布局
  • 提交阶段(UI线程):接收布局结果,创建/更新原生视图

这种拆分的直接收益是:UI线程不再被布局计算占用。假如一个页面有500个节点需要布局计算,耗时20ms——在Paper中这20ms会直接导致UI线程卡顿、触摸事件延迟。而在Fabric中,这20ms发生在Shadow线程,UI线程在此期间可以正常处理触摸事件和绘制操作。

3. Native Module演进:从全量注册到懒加载

旧架构中,App启动时需要遍历所有的Native Modules并注册到Bridge中。这意味着即使用户只访问了首屏页面,所有模块的JavaScript和原生端桩代码都会被初始化。一个包含50个模块的App,启动阶段光是模块注册就可能耗时200-500ms。

新架构中的Turbo Modules实现了真正的懒加载:只有JS代码显式import了某个模块,它才会通过JSI进行初始化。Codegen负责生成类型安全的桩代码,避免了反射调用,初始化速度更快。

1
2
3
4
5
6
7
// 旧架构:不需要import也会初始化
// NativeModules.ImageLoader 在启动时就注册好了

// 新架构:只有import时才初始化
import { TurboModuleRegistry } from 'react-native';
const ImageLoader = TurboModuleRegistry.get('ImageLoader');
// 在这里,JSI才会创建对应的宿主对象并绑定

4. 灵活性变化:新架构的”双刃剑”

新架构的同步能力是性能利器,但也带来了新的风险——同步调用若阻塞了线程,后果比异步更严重。

旧架构中,一个耗时原生调用阻塞的只是Native模块线程,JS线程可以继续执行。新架构中,如果某个JSI绑定方法执行了耗时操作,JS线程直接阻塞,后续所有JS任务都排不上队。

1
2
3
4
// 新架构下的危险写法
// 如果processImage内部进行了大量计算
const result = NativeModules.ImageProcessor.processImage(base64);
// 这一行会阻塞JS线程直到处理完成

最佳实践是让Turbo Module的高耗时方法自动派发到后台线程执行,执行完成后通过JSI写回结果——但这对原生模块的编写者提出了更高的线程安全要求。

实战案例:将旧架构的Native Module迁移到新架构

旧架构代码

1
2
3
4
5
6
7
8
9
10
11
// OldNativeScanner.java - 旧架构
@ReactMethod
public void scanDocuments(Callback success, Callback error) {
    // 在原生侧执行扫描
    ScanResult result = performScan();
    if (result.isSuccess()) {
        success.invoke(result.toMap());
    } else {
        error.invoke(result.getErrorMessage());
    }
}
1
2
3
4
5
// JS侧旧架构使用
NativeModules.DocumentScanner.scanDocuments(
    (result) => console.log('成功', result),
    (err) => console.error('失败', err)
);

新架构代码(Turbo Module)

首先,定义TypeScript接口(Codegen读取):

1
2
3
4
5
6
7
8
9
10
11
12
// DocumentScannerNativeComponent.ts - Codegen规范
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  readonly scanDocuments(): Promise<{
    pages: number;
    pdfUri: string;
    dpi: number;
  }>;
  readonly getScannerStatus(): string;
}

然后,原生侧实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// iOS实现 - DocumentScannerModule.mm
#import "DocumentScannerModule.h"

@implementation DocumentScannerModule
RCT_EXPORT_MODULE(DocumentScanner)

- (void)scanDocuments:(RCTPromiseResolveBlock)resolve
                reject:(RCTPromiseRejectBlock)reject {
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    // 在后台线程执行扫描任务,不阻塞JS线程
    ScanResult *result = [self performScan];
    if (result.success) {
      resolve(@{
        @"pages": @(result.pages),
        @"pdfUri": result.pdfUri,
        @"dpi": @(result.dpi)
      });
    } else {
      reject(@"SCAN_FAILED", result.errorMessage, nil);
    }
  });
}

- (NSString *)getScannerStatus {
  // 同步方法,快速返回状态
  return self.isScanning ? @"scanning" : @"idle";
}
@end
1
2
3
4
5
6
7
8
// JS侧新架构使用(和Promise调用一致,但底层是同步JSI绑定)
const { DocumentScanner } = NativeModules;

const scanResult = await DocumentScanner.scanDocuments();
console.log(`扫描完成:${scanResult.pages}页`);

const status = DocumentScanner.getScannerStatus(); // 同步!
console.log(`扫描状态:${status}`);

底层原理:Bridge消息批处理 vs JSI的即时调用

旧架构的Bridge消息处理是”定时批处理”模式。Bridge内部维护一个计时器,每隔一定间隔(约5-16ms)从队列中拉取所有待处理消息,批量反序列化并执行。这种设计的初衷是减少JNI调用频率,提高吞吐量。

但代价是不可预测的延迟——即使消息队列是空的,新消息也需要等待下一轮批处理周期才能开始执行。在快速滚动的列表场景中,每一帧的触碰事件响应和视图更新都可能因为批处理周期的边界效应而产生”一卡一卡”的视觉抖动。

JSI则完全不同。它本质上是JS引擎和C++函数之间的直接映射。JS引擎通过JSI宿主对象获取到C++函数指针后,调用就是一次普通的C函数调用——没有定时器,没有队列,没有批处理。JS侧可以精确控制”什么时候调用”和”什么时候获取结果”。

1
2
3
4
5
6
7
8
9
10
11
// JSI调用的底层本质:函数指针
// 当JS执行 calculator.add(3, 5) 时,底层展开为:

Runtime &runtime = getRuntime();
Object calculator = getGlobalProperty(runtime, "calculator");
Function addFn = calculator.getPropertyAsFunction(runtime, "add");

// 这个call内部就是函数指针调用
Value result = addFn.call(runtime, {Value(3), Value(5)});
// 没有队列,没有序列化,实时执行
double sum = result.asNumber(); // 8.0

高频面试题解析

面试题1:如果JS侧通过JSI调用了一个原生方法,但这个原生方法需要去下载一个网络文件(耗时5秒),JS线程会被阻塞5秒吗?

解析: 这取决于原生方法的具体实现。如果原生方法自身实现是同步的(就是在自身线程上执行网络请求),那么JS线程确实会被阻塞。但优秀的Turbo Module实现会在原生侧将耗时操作派发到其他线程。

关键原则:JSI的方法本体应该尽快返回。原生模块的正确做法是——方法内部启动一个后台任务,然后返回一个Promise给JS侧。JS侧可以await这个Promise,在此期间JS线程仍然可以处理其他事件。当原生侧的后台任务完成后,通过Promise的resolve将结果写回到JS引擎。

所以核心不是”JSI方法是否同步”,而是”JSI方法的C++实现是否做了线程分流”。

面试题2:旧架构迁移到新架构,哪些收益最显著?哪些场景迁移收益不大?

解析: 收益最显著的场景是高频交互复杂列表。例如手势动画跟随、快速滚动大列表、实时绘图等场景——这些场景中Bridge的消息队列延迟本就是性能瓶颈,迁移到Fabric + JSI后延迟显著降低。

收益不大的场景是纯静态内容少量交互页面。比如图文详情页、表单页面,交互频率低,Bridge的延迟在human sense中基本无感。在这些场景中,迁移的收益主要来自于Turbo Modules的懒加载带来的首屏加速。

还有一个特殊的”负收益”场景:大量使用第三方原生SDK且SDK未适配新架构。如果项目依赖的某些原生SDK(如地图、视频播放器)没有Turbo Module版本,需要团队自己维护适配层,迁移成本远高于收益。

面试题3:画图题——请描述旧架构Bridge的消息传递路径和新架构JSI的调用路径的区别。

解析: (口述类问题时可以用语言描述图画)

旧架构的路径为:JS线程 → 序列化JSON → Bridge消息队列(JS侧) → JNI跨语言调用 → Bridge消息队列(原生侧) → 反序列化JSON → 反射调用Native方法 → 结果反序列化 → 回传队列 → JS侧回调

新架构的路径为:JS线程 → JSI(C++宿主对象方法指针) → 直接执行Native方法(或通过调用桥转发到原生线程) → 结果通过JSI写回JS引擎内存

新架构路径中的”直接”二字是关键——旧架构路径中7个步骤,新架构路径只有2-3个步骤。这就是为什么新架构能将单次通信延迟从旧架构的数百微秒甚至数毫秒降低到微秒级别。

总结与扩展

React Native从旧架构到新架构的演进,本质上是在回答一个根本问题:”既然JS和原生运行在同一进程,为什么通信不能像函数调用一样直接?”

JSI给了”C++对象在JS中直接可用”的能力,Fabric给了”渲染流水线的三线程并行”能力,Turbo Modules给了”模块延迟加载”能力。三者结合,构成了React Native新架构的完整图谱。

对于团队来说,迁移到新架构不应该是一次性的”大爆炸”重构,而应该是一个渐进的过程:先让JSI + Turbo Modules跑起来,逐步替换Bridge模块,最后启用Fabric渲染器。React Native官方也提供了Interop Layer来帮助旧Module在新架构中平滑运行。

本文由作者按照 CC BY 4.0 进行授权