1. 从一次白屏排查说起:React Native 为什么要引入 Bridge
前阵子同事在生产环境遇到一个挺诡异的问题:App 启动后首页偶尔白屏三四秒,接着才刷出内容。日志打出来一看,JS 侧早就执行完了,原生侧也在干等,中间隔了一层若隐若现的通信延迟。排查到最后,问题还是落到了 React Native 的 Bridge 机制上。这已经不是第一次遇到和 Bridge 有关的问题了,所以我决定把这块内容彻底梳理一遍,写成这个“碎片八股文”系列的第一篇。
网上聊 React Native Bridge 的文章不少,但多数停留在“JS 和原生通过 Bridge 通信”这句话上。真正让人困惑的是:为什么偏偏要搞一个 Bridge?为什么不能像普通 API 那样直接互相调用?它到底在通信过程中扮演了什么角色,又为此付出了什么代价?这些问题如果只看官方文档,往往得不到特别直白的答案。
这篇博文面向的读者,是已经在用 React Native 做业务开发、遇到过白屏或卡顿问题、或者准备面试需要把架构讲清楚的人。我会从三个层面展开:第一,回顾 Bridge 诞生的背景和设计动机;第二,拆解一条消息从 JS 到原生的完整流程,说清楚每一步的取舍;第三,分析 Bridge 的性能代价,以及新架构里 JSI、TurboModule、Fabric 是怎么替代它的。最后附上我自己的白屏排查实录和面试答题思路。
很多人以为理解 Bridge 只需要搞清楚“JS 调用原生模块”这一个场景就够了,实际上不是。启动阶段的大量初始化、图片缓存、日志上报、事件回调,全部在走这条通道。你写业务代码时感觉不到它的存在,但它始终在那里,角色有点像一个包揽了整栋楼快递业务的物业中心——业主(JS)和租户(原生)互不认识,所有东西都得经过物业中转。
React Native 是 2015 年开源的,当时的移动端开发处于原生与 Web 技术激烈碰撞的时期。React Native 想把 React 的声明式 UI 开发体验带到移动端,同时保留原生性能。但一个很现实的问题是:JavaScript 引擎和原生代码运行在两个完全不同的世界,怎么让它们配合干活?Bridge 就是当时给出的答案。更准确地说,不是“设计者选择了 Bridge”,而是在当时的架构条件约束下,Bridge 几乎是唯一可行的方案。
1.1 启动白屏问题背后的通信链路
先说白屏问题。React Native 启动时,从用户点击 App 图标到首帧内容呈现,中间要经过一条很长的链路:原生容器启动、创建 JSCore(JavaScriptCore)或 Hermes 引擎、加载 JS Bundle、执行 JS 代码、通过 Bridge 把组件树信息传给原生层、原生层再执行布局计算和渲染。这条链路上任何一个环节卡住,都表现为“白屏”。
在我遇到的那个案例里,问题出在启动阶段有大量模块注册和事件订阅。每个模块的注册要通过 Bridge 走一遍消息通道,而且 Bridge 是异步的,消息要排队、序列化、跨线程传递。启动瞬间大量消息堆积,通信通道挤满了,业务 UI 的渲染指令只能排在后面。这就是“JS 执行完了但界面还没出来”的原因之一。
如果你只把 Bridge 理解成“一座桥”,很容易误解为它的开销只是“走个桥而已”。实际上,一次 Bridge 通信的开销包括:JS 侧打包参数、JSON 序列化、跨线程发送、原生侧反序列化、方法查找、参数校验、回调注册。每一步都有成本,叠加起来就很可观。启动场景下,消息数量可能达到几千条,这些成本会被成倍放大。
1.2 没有 Bridge,JS 和原生怎么对话?(当时的架构约束)
2015 年前后,移动端要想在同一个 App 里同时跑 JavaScript 和原生代码,可选的方案非常少。JavaScriptCore 虽然是苹果系统内置的引擎,但它只提供一个 C 接口,开发者可以把 JS 对象暴露给原生世界,也可以通过 JSContext 调用原生函数。问题在于,这种做法是同步的、单线程的,而且 JS 对象和原生对象之间的引用关系非常脆弱,稍有不慎就会造成循环引用或内存泄漏。
当时主流方案是 WebView JavaScript Bridge,比如 Cordova 的插件机制。它的原理是把原生能力封装成接口,通过 URL Scheme 或 JavaScript 注入的方式暴露给 WebView 里的 JS 调用。这个方案成熟度很高,但性能上限很低,每一次调用都要经过 WebView 的解析和拦截,延迟明显,无法满足 React Native 对 UI 渲染帧率的要求。
React Native 团队最终选择了“在原生侧跑一个独立的 JS 引擎,并通过消息机制通信”的架构。这个选择有几个关键考量:第一,JS 引擎可以独立于 UI 线程,JS 执行和原生渲染互不阻塞;第二,消息通信是异步的,天然适配 JS 的单线程事件循环模型;第三,消息用 JSON 序列化,数据结构简单,跨语言解析成本可控。 Bridge 的设计正是围绕这三点展开的。
2. Bridge 到底是怎么工作的:一条消息的完整旅程
要理解“为什么用 Bridge”,最好的办法是跟一条消息走完全程。假设你写了一句代码,调用一个原生模块的方法,比如读取设备电量:
import { NativeModules } from 'react-native'; const { BatteryModule } = NativeModules; BatteryModule.getBatteryLevel().then(level => { console.log('当前电量:', level); });这段代码不是直接调用原生方法的,它只是创建了一个调用请求。真正发生的是下面这条链路。
2.1 调用一次原生模块,消息经历了什么
第一步,JS 侧的方法调用会被封装成一个消息对象,里面包含模块 ID、方法 ID、参数数组以及回调 ID。模块 ID 和方法 ID 是编译期静态分配的编号,运行时通过查表定位;参数数组则是你传入的实际数据。
第二步,消息进入 MessageQueue,也就是 JS 侧的队列。React Native 不会每产生一条消息就立刻发送,而是把多条消息合并成一个批次,通过一次性批量传输,减少跨线程通信次数。这个设计在启动阶段尤其重要,因为启动时往往有几百条初始化消息,批量传输能显著降低开销。
第三步,批次消息经过 JSON 序列化,从 JS 线程传递到原生线程。注意,这里传递的是一个字符串,不是二进制对象。这就是最关键的设计决策之一:通过序列化把跨语言的调用变成“数据交换”,双方不需要知道对方的内存结构。
第四步,原生侧收到消息后,在 NativeModules 注册表中查找对应的模块和方法,反序列化参数,调用真实的原生实现。调用完成后,如果前面带了回调 ID,原生侧会构造一个返回消息,通过同样的通道回传给 JS 侧,JS 侧再根据回调 ID 触发对应的 Promise 或 callback。
这一步一步看起来不复杂,但每一步都有隐含的成本。为了更直观地看这成本,用一个真实场景来算笔账:假设你加载一个列表,每个 cell 需要调用一次原生方法获取本地缓存数据。每调用一次,大约产生一次 JSON 序列化和反序列化,加上两次线程间拷贝。列表有 20 个 cell,总共 40 次序列化。如果列表滚动时反复触发,这些开销就会变成肉眼可见的掉帧。
2.2 队列、批处理和 JSON 序列化:Bridge 的三大设计决策及其原因
这三个设计不是随意的,每一个都对应一个具体的约束。
第一,队列是异步的,因为 JS 是单线程模型。JS 的事件循环机制决定了它不能同步阻塞等待原生返回结果,所以 Bridge 必须设计成异步的。这也解释了为什么 React Native 里很多原生调用是异步返回的,Promise 就是这种设计的外在表现。
第二,批处理是为了减少线程切换。JS 线程和原生模块线程之间需要同步,每次同步都有锁的开销。如果每一条消息都做一次线程切换,高频调用场景下性能会急剧恶化。合并成批次后,一次线程切换可以传递几十条甚至上百条消息,均摊成本就低得多。
第三,JSON 序列化是为了让两种语言能“看懂”同一个数据。JavaScript 的对象和 Objective-C / Java 的对象在内存布局上完全不同,直接共享内存不现实。JSON 作为中间表示,两边都能解析,而且格式固定,便于调试。缺点也很明显——序列化和反序列化本身要消耗 CPU,还会丢失一些类型信息,比如 Date、Map 这类特殊对象,传递后需要额外处理。
从这三个决策能看出一件事:Bridge 的本质是个协议,而不是一条管道。它定义了双方通信时的语言、规则和格式,让两个独立运行的世界可以优雅地协作。代价是每次协作都要“翻译”一遍,翻译的效率和准确度直接决定了整个框架的上限。
还有一个容易被忽略的细节:Bridge 的队列不仅存在于 JS 侧,原生侧也有对应的队列。调用请求到达原生侧后,会被放到原生消息队列里,等待原生模块线程处理。如果原生模块处理速度慢,消息堆积会在原生侧发生,反过来影响 JS 侧的回调。这也是很多性能问题的根源。
3. Bridge 的代价:为什么后来大家都在喊“干掉 Bridge”
聊完 Bridge 的工作原理,很多人会有一个疑问:既然 Bridge 能工作,为什么新架构要引入 JSI、TurboModule、Fabric 一堆东西去替代它?原因很简单:Bridge 性能不够好,而且它的问题不是修修补补能解决的。
3.1 性能瓶颈的根源分析
Bridge 最主要的性能瓶颈是序列化和线程切换。先说序列化。JS 侧的对象要变成 JSON 字符串,原生侧要把 JSON 字符串解析成原生对象,这个过程在每次通信时都会发生。一个简单的字符串hello传过去,光序列化的成本可能不大;但一张图片的 base64 字符串、一大段日志文本、一个复杂嵌套对象,序列化的开销就不可忽视了。
再细看一个场景:JS 调用原生方法获取通讯录列表,返回值是一个包含几千个联系人的数组。序列化这个数组可能要耗费几十毫秒,然后在 JS 线程和原生线程之间传递一次,再反序列化。如果这个操作在列表滚动时频繁触发,性能问题就会非常明显。
线程切换的成本同样不可小觑。Bridge 的消息队列需要跨线程同步,每次同步要加锁、拷贝数据、唤醒目标线程。这种上下文切换本身不是大问题,但频次高起来以后,开销就会成倍累积。特别是在帧渲染的关键路径上,一次额外的线程切换可能导致掉帧。
启动白屏问题也跟这个有关。启动阶段,JS Bundle 需要被解析、执行,同时要注册大量原生模块,每一个模块的注册都需要经过 Bridge 通信。多条消息在队列里排队,再加上序列化开销,启动时间很容易被拉长几百毫秒甚至更多。
3.2 类型不安全与调用约定
除了性能,Bridge 在开发体验上也有明显短板。因为通信靠的是 JSON 序列化和 ID 查表,所以模块 ID、方法 ID、参数类型完全靠约定,没有编译期的类型检查。如果你在 JS 侧传错了参数类型,通常要等到运行时报错才能发现,有时甚至静默失败——原生方法收到一个空值,函数继续执行,返回一个默认结果,你根本不知道数据已经丢了。
类型安全问题在团队协作时尤其让人头疼。假设原生侧改了方法签名,把参数从字符串类型改成了数字类型,JS 侧没同步改,通信时序列化没有问题,但原生方法内部可能出现意外的行为。这种问题排查起来非常痛苦,因为报错信息往往不会指向契约变更的地方。
为了解决这个问题,React Native 社区引入了 codegen(代码生成器)的概念,通过统一的 schema 定义自动生成 JS 和原生两端的接口代码。但这个方案是后补的,Bridge 时代并没有这么好的工具链支持,很多团队还是靠手写文档维持双方的接口契约。
另外一个容易踩的坑是回调的时机。Bridge 的异步机制意味着原生方法执行完,要主动触发回调,JS 侧才能拿到结果。如果原生侧忘记触发回调,JS 侧的 Promise 就会一直 pending,表现出来就是某个功能“卡住了”,但实际上原生侧已经执行完了,只是结果没有回传。这类 bug 的排查方向很容易走偏,因为你在 JS 侧看不到任何异常。
3.3 从“为什么用 Bridge”到“为什么要抛弃 Bridge”
站在现在的视角看,Bridge 是 React Native 早期架构设计的一个合理选择,但它不是最优解。随着 React Native 的普及,社区对性能的要求越来越高,Bridge 的固有缺陷被放大。最核心的问题是:通信成本太高,无法在关键路径上“频繁调用”原生能力。
一个典型例子是手势系统。手势处理需要每一帧都读取原生侧的位置信息,如果通过 Bridge 通信,每帧都要序列化和线程切换,性能根本扛不住。所以 React Native 早期的 Gesture Responder 系统被吐槽很多,直到后来引入 JSI,原生侧可以直接把数据暴露给 JS 侧读取,这个问题才得到缓解。
另一个例子是图片加载。图片加载涉及磁盘缓存、网络请求、解码,原生侧要做的操作很多。如果每一步都要通过 Bridge 通信,光是回调嵌套就能把代码写得非常难看,而且性能也不行。新架构下,图片的解码结果可以通过 JSI 直接暴露给 JS 侧,省去中间多次序列化和线程切换,加载速度提升明显。
所以,Bridge 被替代不是因为它“做错了”,而是因为它“做多了”。它的核心问题在于:它为每一次通信都套上了一层厚重的协议开销,而实际场景中,很多通信根本不需要这么重的处理。新架构的思路是:把通用协议重的地方去掉,改成按需、尽量同步、尽量直接的方式来通信。
4. 新架构下 Bridge 的替代者:JSI、TurboModule 与 Fabric
React Native 新架构从 2018 年开始规划,2021 年后逐步在版本中落地。它并没有完全删除 Bridge 的概念,但在核心链路上引入了三样新东西:JSI(JavaScript Interface)、TurboModule、Fabric。这三者协同工作,解决了 Bridge 最核心的痛点。
4.1 JSI:共享对象替代消息复制
JSI 是整个新架构的基石。它定义了一套 C++ 接口,让 JS 引擎和原生代码可以共享对象,而不是像 Bridge 那样靠 JSON 字符串来传递数据。
用生活化的类比解释:Bridge 时代,JS 想把一个数据给原生,得把数据写在一张纸上,拍照发给原生,原生再照着照片把数据重建出来。JSI 时代,两边直接坐在同一张桌子前,共同看一份原件,谁需要谁直接看。
JSI 的核心能力是:JS 引擎可以通过 JSI 持有原生对象的引用,原生代码也可以持有 JS 对象的引用,双方可以直接调用对方的方法、读取属性。这样做的好处是,序列化和反序列化不再需要了,线程切换也大幅减少,很多场景下可以做到真正的同步调用。
JSI 还支持不同的 JS 引擎后端。以前 React Native 默认绑定 JavaScriptCore,JSI 把引擎解耦之后,可以换成 V8、Hermes 等。Hermes 是 Facebook 专门为 React Native 开发的引擎,启动速度比 JavaScriptCore 快得多,这也是新架构下白屏问题显著改善的原因之一。
4.2 TurboModule 与 Codegen:按需加载和类型安全
TurboModule 是对原生模块系统的重新设计。Bridge 时代,所有原生模块在启动时一次性全部注册,无论你是不是真的用到它们。这就是启动白屏的一个关键原因:模块越多,启动越慢。
TurboModule 的做法是懒加载。JS 侧第一次调用某个原生模块时,才去初始化对应的原生对象。这样启动阶段就完全不需要等待模块注册了,只加载必要的模块,启动速度自然提升。
Codegen 则是从工具链上解决类型安全问题。开发者在 schema 文件里定义原生模块的接口,由 codegen 自动生成 JS 侧的 TypeScript 类型声明和原生侧的接口代码。这样两边的调用不再依赖手写契约,类型错误在编译期就能暴露出来。
这一套组合的实际体感是:调用原生模块时的代码提示更智能了,参数类型不对会直接报错,而且调用效率明显提升。对于做原生模块封装的同学来说,写一个 TurboModule 的工作量比写老的 NativeModule 略高,但需要手写的重复代码少了很多。
4.3 Fabric:渲染管线的去 Bridge 化
Fabric 是新的渲染系统,主要替代老的 UIManager。老的渲染管线上,JS 侧把组件树序列化后发给原生侧,原生侧再创建对应的原生视图。这个过程要经过 Bridge,更新视图时也要走同样的通道,性能开销很大。
Fabric 把渲染链路改成了基于 JSI 的直接通信。JS 侧可以直接操作原生视图的句柄,调用原生视图的更新方法,不需要再走消息队列。加上 React 18 的并发特性,渲染优先级也可以更精准地控制,紧急更新(比如输入框的文本)可以插队,不再被其他批次的消息阻塞。
对于开发者来说,Fabric 带来的最直接变化是:列表滚动更流畅、更新视图的延迟更低、启动时白屏的几率明显下降。但要注意,Fabric 是逐步落地的,很多老版本 React Native 还是跑在老架构上。如果你遇到性能问题,第一步先确认自己用的是不是新架构,再排查其他原因。
5. Bridge 与启动白屏:高频问题的排查实录
回到开头那个白屏问题。经过前面的分析,你已经知道 Bridge 在启动阶段的角色是关键瓶颈之一。下面是我在实际项目里排查白屏问题的一套方法,比较通用,值得收藏。
5.1 白屏问题排查思路
第一步,确认 JS Bundle 的加载时间。检查 Metro 打包日志,看 Bundle 文件大小和执行时间。如果 Bundle 特别大,考虑做分包和按需加载,让首屏只加载必要代码。
第二步,确认原生侧模块注册耗时。在原生侧打点,统计所有自动注册模块的初始化时间。如果某个第三方原生模块初始化特别慢,考虑把它改成懒加载,或者换一个更轻的方案。
第三步,确认 Bridge 消息队列的堆积情况。打开 React Native 开发者菜单里的性能监控,观察 JS 线程和原生线程的占用情况。如果 JS 线程长时间处于忙绿状态,说明 Bundle 执行耗时太长;如果 JS 线程空闲但页面没渲染,大概率是 Bridge 消息队列堆积或者渲染链路阻塞。
第四步,检查是否有不合理的同步操作。Bridge 时代有个常见错误:在原生模块初始化方法里做耗时操作,比如同步读取大量用户数据、初始化数据库等。这些操作会把原生线程拖住,导致后续所有 Bridge 消息都排队。优化方式很简单,把耗时操作放到子线程或者异步队列里。
第五步,升级到新架构。如果业务场景允许,建议直接升级到 React Native 新架构(0.72 以上版本),开启 Fabric 和 TurboModule,很多 Bridge 时代的性能问题会自然消失。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 启动白屏 3-5 秒 | Bundle 过大或执行慢 | 检查 Bundle 体积和 JS 执行耗时 | 分包、按需加载、使用 Hermes |
| 启动时偶发白屏 | 原生模块注册阻塞 | 打点统计模块初始化时间 | 懒加载模块、减少自动注册 |
| 列表滚动卡顿 | Bridge 高频通信 | 分析调用频次和数据量 | 合并调用、改用 JSI 直通、开启 Fabric |
| JS 调用原生模块无响应 | 回调未触发 | 检查原生模块实现 | 确认回调路径完整、打印日志 |
| 新架构下功能异常 | 模块不兼容 | 检查模块是否支持新架构 | 使用 codegen 重写或替换模块 |
这个表格不是教条,是根据实际踩坑总结出来的。实际排查时,先按表格里的思路走一遍,能解决八成问题。剩下两成,就得靠打日志、拆调用链来逐步定位了。
6. 面试被问到 Bridge,怎么答才能不被问倒
这个话题在 React Native 相关的技术面试里出现频率很高。面试官爱问的不仅仅是你知道 Bridge 是什么,而是你能不能把原理讲明白、把问题分析清楚。
建议的回答结构分四层。第一层,概述:Bridge 是 JS 和原生通信的桥接层,通过异步消息队列传输数据。第二层,讲清机制:消息经过模块 ID、方法 ID、参数数组封装,经过 JSON 序列化、跨线程传递、原生侧反序列化、回调返回,强调批处理机制和异步特性。第三层,讲问题的本质:Bridge 的序列化和线程切换成本高,不适合高频通信。第四层,讲演进:新架构中 JSI 替代了序列化通信,TurboModule 解决了模块懒加载,Fabric 优化了渲染链路。
如果面试官追问“为什么当初不直接用 JSI”,你可以回答:JSI 依赖 C++ 层实现,早期 React Native 的架构主要基于 Objective-C 和 Java,没有统一的 C++ 层支撑。后来团队重构了底层架构,才可能把 JSI 引入。这个回答能体现你对架构演进的理解深度。
还有一个容易加分但很多人忽略的细节:Bridge 的消息通道是 FIFO 的,也就是说,消息会按照顺序依次执行。如果有一条消息特别卡顿,后续所有消息都会跟着排队。这个特性在新架构中也有,但 JSI 的直调可以绕过部分队列约束。能提到这一点,说明你真读过源码。
面试最忌讳的就是背文档。你可以从自己项目里的一个问题讲起,带着问题去讲原理。比如开头那个白屏问题,就是很好的引子。面试官想听到的不是完美答案,而是你碰到问题时的分析过程,以及你能不能在原理层面解释清楚。
写在最后
这期“碎片八股文”先聊到这里。写这篇文章时,我刻意把每个技术的“为什么”都尽量讲透,而不是只列结论。因为无论是面试还是实际调优,只有理解了背后的设计动机,你才能在遇到具体问题时找到正确的排查方向。
React Native 的架构还在快速演进,Bridge 这个曾经的核心概念,在新架构里已经逐渐退居幕后。但理解它,依然是理解 React Native 设计哲学的钥匙。下一篇我打算聊聊新架构下 JSI 的更多细节,尤其是它在跨端场景中的应用边界。如果你有感兴趣的方向,欢迎在评论区聊聊。