2026/8/30 2:29:08

React Native Bridge原理详解:从异步通信到JSI新架构

React Native Bridge原理详解:从异步通信到JSI新架构 我发现一个特别奇怪的现象React Native 相关面试题里Bridge 是出现频率最高但也是被回答得最敷衍的问题。网上搜答案来来回回就一句——“Bridge 是 JS 和原生之间的一座桥负责异步通信和消息转换。” 你接着问一句“为什么一定要用桥直接让 JS 调原生函数不行吗异步又是为什么” 大多数人就卡住了。我打算开一个“碎片八股文”系列每期只围绕一个看起来很小、但背后牵扯很广的问题展开。第一期就把 Bridge 这件事彻底聊透顺带把启动白屏、性能优化、新架构这些经常一起出现的话题串起来。不管你是准备面试、接手老 RN 项目还是自己写原生模块这篇文章都会给你一个能落地的理解框架。我会先用 RN 的架构演进还原“为什么需要通信”再拆解 Bridge 的内部机制最后聊聊它的代价和新架构的变化。理解完这些你再回头看那道面试题会发现它问的并不是“桥是什么”而是“你懂不懂跨运行时协作背后的权衡”。1. 先回到最初React Native 到底在解决什么问题React Native 的起点并不是“我要造一个桥”而是“我要用 JavaScript 写出接近原生体验的 App”。在 2015 年前后移动端开发有一个非常现实的痛点iOS 和 Android 两套原生代码一套业务逻辑要写两遍线上出了问题要等应用商店审核而 Web 团队和后端团队都是 JS想切入移动端却要重新学 Objective-C 和 Java。于是就有了一个朴素的想法能不能用 JS 写业务逻辑让 UI 仍然使用原生控件去渲染这个想法之所以有吸引力是因为它同时解决了几个问题开发成本降低团队可以复用 JS 技术栈业务代码可以跨平台复用不需要维护两套逻辑更重要的是JS 生态里有大量成熟工具和库可以直接用。但问题也随之而来——原生 UI 是 UIKit 和 Android View它们是 Objective-C / Java / C 对象和 JavaScript 引擎完全是两套运行时。JS 对象生活在 JSC 或 Hermes 的堆里原生 View 生活在进程的 native 内存里两者不能直接互相赋值或者“看到”对方。要让两边协作必须有一个可靠的通信方案。1.1 原生开发的两难为什么非要用 JS如果你把 RN 理解成“套了个壳的网页”那这个通信问题就完全不成立因为网页里 JS 和 DOM 本来就在同一个运行时。我见过不少人犯这个误区觉得 React Native 就是 WebView 套壳。但 RN 里的View不是 HTML 的div它最终会映射成原生的 UIView 或 ViewGroupText也不是span它对应的是原生文本控件。整个渲染树由原生控件组成不是浏览器内核在画。所以 RN 实际上是一个“逻辑用 JS 写渲染交给原生”的混合运行时。既然是两个运行时就必然存在“JS 对象和原生对象如何互相引用”“如何调用对方方法”“如何同步数据”这三类问题。而 Bridge 就是回答这些问题的核心。它不是可选项而是这套架构里的必经之路。RN 团队选择 JS 的另一层原因是生态和社区。React 本身就提供了完整的组件模型和单向数据流RN 可以直接复用“组件树 状态”的思路npm 上海量的 JS 库也能降低业务开发的成本。对团队来说一套代码跑两端、用共同的组件模型能显著降低招聘、维护和协作成本。1.2 JS 负责逻辑原生负责渲染必须拆分RN 的运行时可以简化成两条主线JS 线程执行 React 组件代码负责计算“这一帧应该渲染成什么”原生主线程负责布局和绘制管理真正的 UI 控件。两条线之间还有一条 Shadow 线程专门计算 Flexbox 布局生成树状结构传给原生。为什么要拆成这么多线程因为原生 UI 只能在主线程访问而 JS 运算如果也放在主线程就会抢占 UI 资源导致卡顿掉帧。所以 JS 线程和主线程必须各干各的不能混在一起。这里有一个关键约束JS 线程不能直接去触碰原生 View 对象。如果允许两个线程同时操作同一个控件数据竞争会带来不可预期的崩溃JS 引擎的内存管理和原生内存管理又是两套机制谁负责释放、谁来防止对象被提前回收都是问题。所以唯一可行的方向就是“隔离 消息传递”。RN 的做法是JS 虚拟树通过消息命令通知原生“创建、更新、删除节点”原生再把触摸、滚动等事件通过消息回传给 JS。通信层因此成了整个架构的命脉Bridge 就是这一层在旧架构里的具体实现。2. Bridge 是什么它为什么长这样Bridge 的老架构定义很简单它是 JS 与原生之间的一条异步消息通道。但“消息通道”这四个字很少有人解释到“长什么样”。实际上它由一张模块注册表、一个消息队列、一套序列化格式和两端的调度逻辑组成。理解了这个结构你才能理解为什么它选择“异步”和“批量”这两个设计。2.1 Bridge 是异步消息队列不是函数调用在 JS 侧调用原生模块的代码是这样写的import { NativeModules } from react-native; NativeModules.MyToast.show(Hello Bridge);原生侧注册模块时需要这样写RCT_EXPORT_MODULE(MyToast); RCT_EXPORT_METHOD(show:(NSString *)message) { // 这里最终在原生线程执行 }看起来像是直接函数调用对吧但在旧架构里RN 不会真让 JS 引擎直接调用 Objective-C 方法。它会把这次调用“编码”成一条指令模块叫 MyToast、方法叫 show、参数是字符串。这条指令被塞进MessageQueue然后在一个合适的批次里连同这一帧内其他指令一起发给原生模块管理器。原生侧解析出模块和方法再执行真正的实现。整个过程对调用方完全异步——JS 发完消息不会原地等待而是通过回调或 Promise 接收结果。“异步 批量”是 Bridge 性能和稳定性的关键。为什么是异步因为 JS 线程和原生主线程不能互相阻塞。如果每次都同步等待原生返回单个调用可能还好但手势、动画过程中每帧有几十次调用UI 线程立刻会被卡死。异步让 JS 可以“发完消息继续干自己的事”原生线程按自己的节奏处理。批量则是进一步摊薄跨线程开销一次一批比一次一条高效得多。2.2 为什么不能同步调用一台本地的“跨洋电话”我们可以做个生活化类比。JS 线程和原生线程像是两个不同国家的同事Bridge 是翻译。同步调用相当于 JS 一直拿着电话等翻译把话说完整、对方再回复如果翻译还在处理大量别的请求JS 就只能干等。更糟的是如果原生那边也需要等 JS 的结果两边就会互相掐住这就是死锁。把整个 UI 交互过程放进去看问题会更严重。假设用户滑动列表onScroll事件可能在几百毫秒内触发几十次JS 每次处理事件都可能调用原生 API。同步方案下每次调用都涉及 JS 和原生线程的互相等待卡顿是必然的。异步加批量则让每条调用只是“写入一个队列”JS 线程算完一帧再统一发出去。这也是为什么 RN 老架构的跨桥调用虽然比直接调原生慢但在合理设计下不会卡死主线程。2.3 Bridge 传的不是 JSON而是数字数组有个误区要纠正Bridge 里并不是用 JSON 字符串传数据而是用结构紧凑的数组。模块名、方法名会通过注册表映射成数字 id重复字符串会被去重保存目的是减少序列化和反序列化的开销。老 RN 内部有RemoteModuleTable和StringTable就是避免每次复制巨大的字符串。如果你看过 Bridge 的调用日志会发现消息长得像下面这样[ [0, MyToast, show, [hello]], [1, AppRegistry, runApplication, [...]] ]数组格式解析很快也方便嵌套还天然支持“批量发送”——所有消息放进一个大的 batch 里原生模块管理器一次遍历处理。实际开发中你不需要手动构造这种结构但理解它对调试原生模块、看日志很有帮助。很多性能问题本质上就是有人在 JS 侧高频调用原生方法导致 batch 队列膨胀单次处理时长超预算。3. 为什么选 Bridge而不是别的方案既然 Bridge 有这么多限制为什么不从一开始就采用更好的方案要回答这个问题得看当时的技术约束。任何技术选型都是约束条件下的最优解Bridge 也不例外。3.1 对比 WebView JSBridge渲染层的本质区别React Native 出现之前开发者已经用过 Cordova、Ionic 这类 Hybrid 方案它们同样有一个“桥”。比如 Cordova 的 JS 可以通过桥调用原生插件但页面 UI 是 WebView 里的 HTML/CSS。桥负责的是“网页调用原生能力”UI 渲染、事件交互归根结底还是浏览器内核在管。RN 的 Bridge 承担的任务完全不同它不仅要让 JS 调用原生模块还要把 JS 侧生成的 UI 命令发过去让原生控件创建视图、更新属性、绑定事件。这就不只是“加一个 bridge 插件”那么简单。只要 UI 是原生的、逻辑在 JS 侧通信层就一定会存在差别只在于通信层长成什么样。所以“为什么 React Native 要用 Bridge 通信”这个问题本质上是在问“为什么一个跨语言、跨线程的应用需要通信协议”而不是在问“为什么不能没有桥”。3.2 对比 Cordova 插件桥桥的“职责范围”完全不同我们可以用一张表格来对比维度Cordova / HybridReact Native 旧架构渲染层WebView 内 DOM原生控件JS 引擎WebView 内嵌 JS 引擎JSC / Hermes独立 JS 线程桥的职责调用系统能力UI 命令 原生能力 事件回调性能瓶颈渲染主要在 WebView通信主要在 Bridge 序列化这张表放在面试里能明显拉开差距。很多人只记住“Bridge 是通信”却说不清它到底传什么、为什么需要批量。通过对比可以看到RN 的桥不是 Cordova 桥的简单加强版而是一条核心数据通道。通道一旦有问题不管是启动白屏、列表卡顿还是导航切换卡顿都会表现出来。所以排查问题时如果发现消息队列积压严重第一反应就应该是“过桥太频繁”。3.3 为什么不能直接暴露原生对象给 JS从工程直觉看最直接的方法不是搞个桥而是把原生对象直接暴露给 JSview.setColor(#fff)不是挺好吗技术上后来的 JSI 正在做类似的事但 Bridge 诞生于 2015 年当时要面对三个很现实的问题。第一跨语言引用会带来内存管理难题。JS 的垃圾回收能感知 JS 对象但它不理解 Objective-C 的引用计数和 Java 的 GC Roots。如果 JS 持有一个原生 View谁负责释放很容易出现内存泄漏或野指针。第二线程安全。原生 View 不是线程安全的JS 线程如果直接访问结果不可预期。消息传递的好处是让“实际拥有对象的线程”来处理请求其他线程只发送请求等待结果。第三跨平台一致性。如果暴露原生对象iOS 的UIView和 Android 的View方法完全不同JS 代码就得写两套“一套代码两端跑”的愿景就没了。Bridge 用统一消息协议封装了平台差异JS 侧只需要关心业务方法。所以 Bridge 的设计哲学可以概括为不稳定因素不共享只传递快照。你传给我的是参数快照、返回结果快照而不是实时对象引用。这在复杂运行时里更安全代价就是性能上多了一层序列化和线程切换。4. Bridge 的代价启动白屏、性能瓶颈和调试之痛Bridge 不是没有代价。理解了代价你才能理解新架构为什么要改掉它也才能在老项目里避开那些常见的坑。4.1 启动白屏Bridge 初始化链路在拖后腿RN 应用启动时会经过一条非常典型的链路App 启动 → 创建 JS 引擎 → 加载 JS bundle → 注册 Native Modules → 初始化 MessageQueue / Bridge → 执行业务 JS → 渲染第一帧。在这条链路里Bridge 必须在业务代码执行前“就绪”。如果 bundle 很大或者某个模块在注册时做了耗时操作用户看到的就是白屏。排查启动白屏时推荐按这个顺序查先看 bundle 体积和解析耗时在 Metro 打包配置里开启inlineRequires: true把开始时不需要的小模块内联延迟加载再看原生模块初始化耗时避免在模块构造方法和init里做重活必要时拆成 TurboModules 按需加载然后看首帧渲染时序确认AppRegistry.runApplication是否在 bridge 就绪后立刻调用根组件里避免做大量同步数据处理最后看图片和字体资源首屏加载大量本地大图也会加剧白屏。记住一个核心点白屏不只是因为“加载资源慢”很多时候是“Bridge 还没准备好业务 JS 根本跑不起来”。4.2 性能瓶颈一次过桥调用到底有多贵一次跨桥调用的开销至少包含四步JS 侧把参数编码进消息队列消息通过 C 层提交到原生模块管理器原生侧解码并调用实际方法如果需要返回值或回调再重复一遍。每一步都有线程切换和内存拷贝。我实际调试过一个低端安卓机上的案例列表滑动时JS 侧每次onScroll都调用原生模块读取一个内存缓存值导致每秒几十次过桥。结果滑动一开始就掉帧甚至出现白屏式停顿。后来改成每 100ms 集中读取一次并用节流控制频率问题直接消失。这个案例说明一个原则跨桥调用不应该出现在高频路径里。动画一定要用Animated并开启useNativeDriver让动画在原生侧执行JS 不需要每帧去算数据量大的场景尽量一次过桥传一个大对象而不是拆成几十个小调用列表优化时把图片请求、复杂逻辑放到InteractionManager.runAfterInteractions之后避免和交互抢时间。每次改完用真机上的 Profile 面板看 JS 线程唤醒频率和消息队列堆积情况立刻能感受到差别。4.3 调试体验割裂错误堆栈是两半的Bridge 带来的另一个隐形成本是调试复杂度。JS 抛异常时你看到的是 JS 堆栈原生模块抛异常时你看到的是 Objective-C / Java 堆栈。一旦调用链是 JS → Bridge → 原生 → 回调 → JS这个闭环里任何一个环节出错完整错误信息都很难拼出来。老版本的远程调试模式会把 JS 挪到浏览器里执行Bridge 消息变成 WebSocket 转发性能和离线行为都不理想。我自己的经验是给每个原生模块的方法统一加一层日志至少打印方法名、参数、耗时、异常。线上用统一日志平台收集排查问题会快很多。另一个技巧是把 Bridge 调用频率做成性能监测点如果发现某个原生方法在一秒内被调用上百次那它八成就是卡顿的根源。这不是玄学是 Bridge 机制决定的——消息太多队列就会积压每一帧的处理时间都会超预算。5. 从 Bridge 到 JSI它没有消失只是换了形态5.1 新架构为什么敢“干掉”BridgeReact Native 的新架构核心变化是 Fabric、TurboModules 和 JSI。JSIJavaScript Interface替代 Bridge 后JS 可以直接持有 C 对象引用调用原生方法时不再需要“编码成消息 → 发过去 → 解码”而是通过一层 C 接口直接调函数指针。TurboModules 也解决了老模块表“一启动就全部初始化”的问题改成真正用到某个原生模块时才加载。这两点从根本上改善了启动白屏和首次调用延迟。名字变了但问题没变。JSI 依然要处理 JS 与原生之间的通信只是不再用“复制数据”的方式而是“共享引用”。你可以把 Bridge 理解为“两个人靠信使传纸条”JSI 是“两个人直接视频通话还能共享桌面”。视频通话当然更快但它对基础设施的要求也更高C 抽象层、内存模型一致性、平台适配都得做扎实。这也是为什么新架构不是一次性推翻而是一步步迁移。5.2 学 Bridge 还有用吗我的答案非常肯定有人觉得新架构都要来了旧 Bridge 没啥可学。但实际开发中你仍然会遇到大量旧架构项目即使升级到新架构很多概念也是连续演化的。更重要的是面试官问“为什么 React Native 要用 Bridge 通信”考察的并不是那个名词而是你对以下问题的底层认知为什么 JS 和原生必须异步通信为什么传输要序列化而不是直接