先说结论:新架构解决的不是你以为的那个问题
我从 2023 年底开始跟着 RN 新架构(Fabric + TurboModules)折腾,那会儿它还叫 "New Architecture"、要手动开 flag,一堆第三方库跑不起来,社区的 issue 区基本是哀嚎一片。到 0.76 官方把它设成默认,再到 0.79,我在三个项目上做完了迁移。踩完之后最大的感受是:很多人对"那座桥"的怨恨,用错了地方。
老架构的 Bridge 本质上是一条异步的、要经过 JSON 序列化的消息队列。JS 侧调一个 NativeModule 方法,参数先 JSON.stringify,塞进队列,等 native 侧被唤醒后批量读取,再 JSON.parse。一次调用的往返,我在 iPhone 13 上测到的量级大概是 0.2ms ~ 1.5ms,取决于 payload 大小。单看不多,但你一帧只有 16.67ms,如果是在 onScroll 或者手势回调里高频调 Native,几十次就能把一帧吃干净。
新架构换上 JSI 之后是另一回事:JS 侧直接拿到一个 C++ HostObject 的引用,方法调用是同步的,没有序列化这一步。这是实打实的改变,但它改变的是"每次调用的固定开销",不是"你的业务代码本身慢"。我见过太多把 App 卡顿全赖在桥上的人,profiler 一开,大头是滚动时反复 decode 的 <Image>、没给 getItemLayout 的 FlatList、以及在 useEffect 里做同步重计算的组件。桥换成 JSI,这些一个都不会变好。这半年我最想说的一句话就是这个。
实测:我怎么量的,量到了什么
测试环境先说清楚,免得被当成通用结论:iOS 是 iPhone 13 / iOS 18.2 / Release 构建,Android 是 Pixel 6a / Android 14,另外还借了一台骁龙 665 的老机器跑低端场景。冷启动的测量方式是 JS 侧 performance.now() 打点,再和 iOS 原生的 RCTPerformanceLogger 交叉对一下,每个版本 10 次取中位数,去掉第一次(系统缓存没热)。
结果:老架构 2.42s,新架构 1.96s,省了大概 0.46s。这个提升我判断主要来自 TurboModule 的懒加载——老架构在启动时会把注册过的 NativeModule 全部 init 一遍,不管你这个页面用不用得到;新架构是按需的,首次调用才初始化。对一个塞了 IM、地图、推送、统计四个大 SDK 的项目来说,这笔账不小。
但有两个反例必须说。第一,包体积涨了:同一个项目的 iOS .ipa(未压缩)从 41.2MB 涨到 44.8MB。Codegen 生成的 C++ 胶水层,加上为兼容老库保留的 interop 代码,双份都在包里。小项目感觉不到,依赖树越深涨得越明显。第二,那台骁龙 665 的老机器上,首屏反而慢了大约 200ms。查了两天才定位到,是一个只有老 ViewManager 的库,每次 mount 都要走一遍 Legacy ViewManager Interop 的转换。所以"新架构一定更快"这句话是不成立的,得看你的依赖树里有几个库还在 interop 上跑。
迁移步骤,以及我踩到的 6 个坑
第一步,先摸清依赖树的底。npx react-native doctor 只能看个大概,更靠谱的办法是去 reactnative.directory 上逐个查库的 New Architecture 支持标记,或者直接翻库的 package.json 里有没有 codegenConfig 字段——有的基本就是原生支持,没有的就要打问号。我自己定的线是:核心依赖里如果有超过 2 个库只靠 interop 撑着,这次就先不升。
第二步,开开关。Android 改 android/gradle.properties:
newArchEnabled=true
iOS 在 ios/Podfile 顶部加:
ENV['RCT_NEW_ARCH_ENABLED'] = '1'
然后 cd ios && pod install。注意 RN 0.76 之后这两项本来就是默认开的,升级项目的时候反而要确认一下有没有被别的脚本改回去。
第三步,自己写 TurboModule 要配 codegen,在 package.json 里加:
"codegenConfig": {
"name": "AppSpecs",
"type": "modules",
"jsSrcsDir": "src/specs",
"android": {
"javaPackageName": "com.yourapp.specs"
}
}
坑一:name 不能和任何依赖重名。我第一次顺手写了 AppSpec,跟一个三方库撞了,Android 编译直接报 duplicate symbol,看了半天才发现是自己起的名字太大众。坑二:spec 文件必须是 NativeXxx.ts 这种命名,导出 TurboModuleRegistry.getEnforcing<Spec>('Xxx')。继续用老的 NativeModules.Xxx 虽然还能跑,但那是走 interop,等于白折腾。坑三:jsSrcsDir 指错目录的话,构建不会报错,只会在运行时抛找不到模块,特别难查;Android 上可以单独跑 ./gradlew generateCodegenArtifactsFromSchema 看有没有产物落下来确认。
坑四到坑六是高频报错:Unimplemented component: <RCTViewManager>,说明某个原生组件没写 Fabric 版本,interop 也没兜住,去查这个库的 ViewManager 有没有实现 RCTViewComponentView;TurboModuleRegistry.getEnforcing(...): 'Xxx' could not be found,八成是 codegen 没跑或者命名对不上;iOS 白屏且无报错,通常是 RCTHost 初始化时某个模块崩了被吞掉,得开原生日志慢慢磨。这三个我平均每个花掉半天。
顺手说说 FlatList,这比换架构有用得多
写了这么多新架构,但还是要说一句:大多数人手上 App 的卡顿,调一下 FlatList 配置就能解决,根本不需要动架构。下面这组参数是我在自己项目里调出来的,长列表、每项固定高度 72:
<FlatList
data={items}
keyExtractor={(i) => i.id}
getItemLayout={(_, index) => ({ length: 72, offset: 72 * index, index })}
initialNumToRender={8}
maxToRenderPerBatch={8}
updateCellsBatchingPeriod={40}
windowSize={7}
removeClippedSubviews={Platform.OS === 'android'}
renderItem={renderItem}
/>
几个参数为什么这么给:getItemLayout 给了之后 FlatList 不用逐项测量高度,滚动条不跳,scrollToIndex 才准;windowSize 的默认值是 21,意思是视口上下各留 10 屏内容,对内存很不友好,我改成 7 之后 1000 条带图列表的常驻内存大约省了 30MB;initialNumToRender 默认 10,首屏实际只显示 6 个的话就改成 6 到 8,首帧能快几十毫秒;removeClippedSubviews 在 Android 上默认就是 true,iOS 默认 false,别手贱去 iOS 上开,我踩过内容整块闪没的坑。
如果列表里塞的全是图,@shopify/flash-list 的回收机制确实比 FlatList 强。我拿 2000 条图片列表跑过,FlashList 的掉帧采样数大概只有 FlatList 的三分之一。但它有自己的脾气,estimatedItemSize 给错的话滚动会抖得很难看,而且它默认的 drawDistance 偏保守,图多的时候得手动往上调。
最后:什么情况下我会建议你别升
标题是标题党,我自己其实没关回去。但有三类项目我会建议先按兵不动。第一,依赖树里有超过 3 个只支持 interop 的重量级原生库(地图、播放器、IM SDK 这类),升上去大概率性能不升反降,我那台骁龙 665 上慢的 200ms 就是活生生的例子。第二,团队里没人看得懂 C++ 或者原生构建日志——新架构的报错栈比老架构深得多,经常甩给你一屏 C++ 模板报错,会看到怀疑人生。第三,项目还停在 RN 0.72 以下且不急的,没必要为了一个自己都不知道收益在哪的升级折腾两周。
新架构真正的价值,我现在的判断是:不是"更快",而是 React 18 的 concurrent 能力终于有了施展空间。startTransition、useDeferredValue 这些在 Fabric 上才有意义,因为渲染终于可以被中断和重排了;老架构那套同步的 UIManager 操作,你根本没法中断它。对搜索联想、多 tab 切换这类复杂交互来说,这个能力的价值远大于启动快 0.4 秒。
不过这个说法我目前只有手感和零散的日志,还没做出能摆出来的 benchmark。等我哪天把测量方案理顺了,再单独写一篇。