ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

React Native Bridge 原理与代价:从消息队列到新架构演进

React Native Bridge 原理与代价:从消息队列到新架构演进 如果你在简历上写“熟悉 React Native”面试官大概率会问一句为什么 React Native 要用 Bridge 通信这个问题乍看是送分题实际上是个分水岭。背过八股的人能说出“JS 和原生之间有一座桥”但问到底层消息怎么传、为什么设计成异步、这个设计有什么代价、新架构为什么又要干掉它很多人就卡住了。这篇文章我把 Bridge 这个点彻底讲透。不只在面试层面让你能接住追问更重要的是理解 RN 运行时究竟长什么样这样你在实际项目里遇到启动白屏、列表卡顿、原生模块调用异常也能顺着这套机制找到根因。无论你刚学 RN还是已经写了一阵子业务代码这部分内容都值得静下心看完。1. 为什么 JS 和原生不能“直接对话”很多人有个误区觉得 React Native 是“用 JS 写原生应用”就好像 JS 代码能直接操作原生控件一样。实际上不是。JS 和原生之间的关系更像是两个国家的人打电话中间必须有一个翻译官来回传话。这个翻译官就是 Bridge。为什么不能直接对话因为两边存在三个层面的“不通”。1.1 语言层面的障碍JavaScript 是动态类型语言函数是一等公民对象可以随时增删属性。而 iOS 端的 Objective-C、Android 端的 Java/Kotlin 都是静态类型语言编译期就要确定方法签名、参数类型、返回类型。你没法把一个 JS 函数直接塞给 Java 方法当参数Java 编译器根本不认识这种类型反过来也一样。就算不考虑类型语法层面的鸿沟也足够大。JS 的闭包、原型链、事件循环、Promise 异步模型和 OC 的消息转发机制、Java 的线程模型完全是两套思维体系。要让它们互相直接调用等于让一个只说中文的人和一个只说西班牙语的人不用翻译就聊哲学基本不可能。1.2 运行时层面的障碍JS 代码跑在 JavaScriptCoreiOS或 V8/HermesAndroid等 JS 引擎里原生代码跑在 Objective-C Runtime 或 Android RuntimeART里。这两个运行时各自管理自己的内存堆、对象模型、垃圾回收机制。这是两个完全隔离的虚拟机世界。JS 引擎里创建的对象对原生运行时来说只是一块看不懂的内存区域原生运行时里的对象引用对 JS 引擎来说同样没有意义。跨运行时传递数据和调用方法只能通过某种“互通协议”来完成不可能直接拿对方的对象来操作。1.3 线程模型的冲突RN 的 JS 代码默认跑在一个独立的 JS 线程上而 UI 操作必须在主线程执行。JS 引擎本身是单线程的事件循环驱动原生端却有主线程、网络线程、IO 线程等一堆线程。如果 JS 能直接调用原生方法就等于在 JS 线程上跨线程操作主线程的资源线程安全直接崩盘。所以 Bridge 的本质就是一套“跨语言、跨运行时、跨线程”的安全通信协议。它不追求“快”而追求“稳”——让两个完全异质的世界能一条条地把消息递过去。从这个角度看React Native 用 Bridge 不是“选择”了 Bridge而是当时条件下“只能”用 Bridge。你没法让 JS 引擎和原生运行时共享内存没法让两套对象模型互相识别那剩下最稳妥的方案就是消息传递。理解了这一层后面所有的设计细节都顺理成章了。2. Bridge 的通信机制消息队列、序列化与四线程模型既然要传消息就得定义消息长什么样、怎么编码、走什么通道、谁在哪个线程收发。RN 的 Bridge 在这次通信里设计了一套完整的机制理解这套机制才算真正理解 RN 的架构。2.1 Bridge 不是一条“管道”而是一个协议栈很多文章把 Bridge 画成一条简单的双向箭头其实远没那么简单。RN 的 Bridge 至少在三个层面上做了工作模块注册与发现原生模块比如 ToastAndroid、StatusBar、各种原生 SDK 封装在启动时注册到一个全局模块表里JS 端通过这个表知道“有哪些原生能力可以调”。方法调用编码一次方法调用会被编码成一个结构化的调用消息包含模块 ID、方法 ID、参数列表。这里用的是自增 ID 而不是字符串名字为的是减少传输体积。批量消息队列JS 到原生的调用不会立刻发送而是先攒在队列里在一个批次结束时统一 flush 给原生线程。原生到 JS 的回调也走类似的机制保证主线程不会被频繁的跨线程消息打断。2.2 一次完整的调用从 JS 到原生 Toast 的旅程举个最直观的例子你在 RN 里调用ToastAndroid.show(Hello, ToastAndroid.SHORT)表面上就是一行代码实际上内部经历了一段很长的旅程。第一步JS 端模块匹配。RN 通过NativeModules对象找到 ToastAndroid 模块。这个模块对象是运行时动态生成的“代理”——它本身不包含任何原生逻辑只知道模块 ID 和方法 ID。第二步编码调用消息。应用层逻辑会把这个调用序列化成 JSON 格式[moduleID, methodID, arguments]比如[14, 1, [Hello, 0]]。注意这个动作是发生在 JS 线程上的。第三步消息进入队列等待批量发送。调用先 push 到一个 messagesQueue 里不会立刻发出。RCTBridge 在每次 JS 事件循环 tick或帧循环结束时会统一 flush把这个批次里攒下的所有调用一次性通过postMessage或者消息队列发给原生侧。第四步原生端拆包并分发。原生端的消息处理器从队列里取出消息解析 JSON根据 moduleID 找到对应的原生类根据 methodID 找到对应的方法然后把参数依次强转成 OC/Java 类型最终在主线程或模块指定的线程上真正执行Toast.makeText(...)。第五步回调与事件回传。如果原生方法有返回值或回调会以同样的方式编码成 JSON 消息通过一个 callbackID 找到 JS 端的回调函数在 JS 线程上触发执行。一次方法调用就这么“绕了一圈”。每次调用都有编码、传输、解码、转类型、调度这几步开销。单次调用微乎其微但大量调用叠在一起就能量变引起质变。2.3 四个线程各司其职RN 在运行时通常涉及四个线程理解它们分别干什么能帮你解决很多奇怪的运行时问题。线程职责备注JS Thread执行所有 JS 代码包括业务逻辑、React 渲染协调单线程绝大多数 JS 调用都发生在这里Native Modules Thread执行原生模块的方法调用有些模块会切到自己的线程执行耗时任务Shadow Thread处理布局计算生成 Shadow Tree涉及 Yoga 引擎的布局运算Main ThreadUI Thread执行所有 UI 操作只能在这个线程操作原生视图JS 线程和 UI 线程之间最重要的通路就是 Bridge。RN 渲染的本质是 JS 计算出 UI 描述React 元素树转成 Shadow Tree最终由原生端在 UI 线程落地成真实的原生视图。这中间的每一步传递都经过 Bridge。2.4 为什么要“批量”而不是“即时”很多人会问既然 JS 调原生是异步的为什么不每次调用立即发送还要攒着批量发这和性能有直接关系。跨线程通信的成本不在“发送”本身而在“线程切换”和“消息唤醒”。如果每次调用都立刻从 JS 线程切到原生线程结果就是两个线程频繁唤醒、频繁加锁、频繁触发 IPC性能会急剧下降。批量处理把 N 次消息合并成一次线程切换把通信成本摊薄。RN 的默认策略是在每个帧循环内JS 执行完当帧任务后统一 flush 所有积压消息这样原生侧一帧最多被唤醒一次。这种设计的代价是“延迟”——JS 的调用不会立即被原生感知而是要等到下一帧。大多数场景下这个延迟十几毫秒内用户无感知但碰上需要高频同步的场景比如手势、动画、连续滚动中的视图更新批量延迟就会被放大变成肉眼可见的掉帧。3. 面试追问为什么是异步、JSON、不可变数据“Bridge 是异步消息队列”这句话很多人背得下来但面试官通常会在后面追问几个“为什么”这几个追问才是真正筛人的地方。我在这里把常见的追问和底层逻辑一次讲清。3.1 为什么必须是异步的不能让 JS 同步等待原生返回这个问题很好回答直接看 iOS 的例子就明白了。JS 跑在单独的 JS 线程上如果 JS 同步等待原生返回意味着 JS 线程会被阻塞。而原生方法很可能跑在 UI 线程上如果 UI 线程又被原生方法的同步逻辑占住两个线程互相等待死锁就发生了。即使不死锁同步等待也意味着整个帧循环被拉长。RN 的渲染是一次消息往复如果每次往复都同步卡住界面必然卡死。所以异步调用、队列化传输既是线程安全的保底也是性能的下限。3.2 为什么用 JSON 传递数据性能明明不好JSON 的序列化和反序列化开销在频繁调用场景下确实是个明显的性能瓶颈。但 Bridge 选 JSON 有它的历史原因。RN 诞生于 2015 年当时要考虑的第一优先级是“实现跨端能力”而不是“极致的性能”。JSON 有几个不可替代的好处语言无关JS、OC、Java 都原生支持 JSON 解析不需要引入额外的序列化框架。可调试报文是纯文本开发者可以直接把传输内容打印出来看定位问题非常直观。实现简单早期的 RN 是 Facebook 内部快速迭代的项目用 JSON 能在最短时间内打通跨语言通信链路。到了新架构时代RN 换用 JSIJavaScript Interface实现直接引用传递不再需要 JSON 序列化这正是因为性能问题已经盖过了开发便利性。所以追问到这里你可以顺势引出新架构——面试官会知道你真的理解这层演进逻辑。3.3 为什么传的数据必须是可序列化的普通对象JavaScript 的对象模型远比 JSON 丰富。函数的闭包、对象的隐式原型链、Date对象、Map/Set、循环引用的结构这些东西在 JSON 序列化时都会出问题。RN 的 Bridge 要求跨端传输的数据必须是“可序列化”的普通对象就是因为它只能通过 JSON 承载数据不带任何运行时类型信息。所以很多 RN 初学者踩过的坑把一个包含函数字段的对象传给原生模块原生端收到后函数字段消失了把一个Date对象传过去原生端拿到的是一个字符串尝试传一个带循环引用的对象直接运行时报错。这些问题的根源都在于 Bridge 的数据通道只认 JSON 可表达的东西。理解原理之后你就知道该怎么处理这些场景函数要用回调 ID 代替Date 要手动转成时间戳或字符串复杂的类实例要在原生侧预先把字段拆成普通对象。3.4 老张的比喻Bridge 像一个传真机我一直觉得 Bridge 最贴切的类比不是“桥”而是传真机。你在自己这边把内容写在纸上传过去对方收到的是传真复印出来的副本。整个过程有几条天然限制一次传一页纸批量传过去的是图片副本不是原件值拷贝不共享引用如果两边对同一份数据同时修改修改不会同步无双向绑定传真机吐出来的纸你只能在上面写字跨线程不共享内存。理解了传真机这个类比很多 RN 的怪异行为就说得通了为什么改了 JS 端的数组原生端不感知因为传过去的本来就是一份拷贝。为什么传大对象那么慢因为传一份 10MB 的对象等于传真 10MB 的纸整个过程有成本。为什么新架构能提升性能因为没有传真机了两边直接共享一个磁盘内存你改我就能看到。3.5 一个隐藏的问题类型安全Bridge 时代还有一个常被忽略的问题——类型安全。JS 是动态类型原生是静态类型Bridge 在序列化时不会做类型校验所以类型错误要等到运行时才暴露。你在 JS 端传错了参数类型原生端收到后要么抛异常要么静默失败错误信息又难懂排查十分痛苦。新架构里的 Codegen 就是在解决这个问题从原生模块的接口定义自动生成 TS 类型声明让 JS 端在编译期就知道类型对不对错误前置。这既是 DX开发者体验的提升也是架构演进的一个核心原因。4. Bridge 的现实代价说了这么多 Bridge 的“为什么”该说它的问题了。任何架构都有取舍Bridge 的取舍在早期还能接受但到了大规模应用、复杂交互场景代价就越来越明显。这一章我用实际开发中会遇到的几个现象来拆解。4.1 启动白屏Bridge 是首屏链路里的关键瓶颈热词里提到“react native 启动白屏”这是 RN 项目的经典问题根因就和 Bridge 的串行链路有关。RN 启动要依次经历几个阶段下载/加载 JS Bundle 文件创建 JS 引擎并执行 BundleJS 端完成首次渲染计算把 UI 描述通过 Bridge 发给原生端原生端解析后在 UI 线程创建真实视图。这几个阶段是串行的任何一环慢白屏时间就长。其中 Bridge 传输阶段有两个隐性开销一是 JS 代码在首屏要注册大量模块、建立模块表这些模块信息都要通过 Bridge 同步到原生侧二是首屏渲染产生的 UI 指令往往是上百条消息即使批量发送原生端解析 JSON、查找模块、创建视图也需要时间。在低端 Android 机上这一过程可能长达几十毫秒到几百毫秒。实际操作中我见过团队以为加了启动屏就能解决白屏实际上只是把白屏“藏”起来了。真正的解法要么是架构层面减少首屏工作拆 Bundle、按需加载要么是等待新架构的同步渲染能力Fabric 允许 JS 直接创建原生视图省去部分 Bridge 等待。理解 Bridge 在启动链路中的位置你才知道从哪个角度优化是有效的。4.2 大数据传输一次 ListView 卡死的完整复盘讲一个我实际踩过的坑。早期项目里有个页面要一次性展示 200 条带图片的消息列表图片 URL 是后端算好的完整 URL每条消息还带一行描述文字。按常规做法这些数据应该由原生侧通过 Promise 一次性传给 JS再由 JS 渲染成列表。第一次压测就发现数据一过来页面直接卡死 2-3 秒然后才弹出来。通过 Profiler 一看卡死的源头不是渲染而是 JS 端接收数据之后RN 内部要把这 200 条数据从原生侧“拷贝”到 JS 侧——每一条都要经过 JSON 序列化和反序列化。数据总量才几 MB但序列化和跨线程拷贝的时间被放大到了秒级。这个案例完美展示了 Bridge 的价值上限它适合传小数据、低频调用不适合传大数据、高频调用。后来的优化方案是图片 URL 列表由原生侧先缓存到本地文件JS 只拿一个索引数组消息描述内容只在第一次加载时全量传一次后续走增量更新。这些都是顺着 Bridge 的传输模型去设计数据流而不是硬顶着它的短板。4.3 类型不一致的隐蔽 Bug另一个经典事故是原生模块返回了一个long类型的值比如数据库自增 IDJS 侧拿到的却是一个精度丢失的数字。原因是 JSON 序列化时超长整数被转成了浮点数精度被截断。这类 Bug 只在数据量大到一定程度时才会暴露调试起来极其隐蔽。后来团队定了条规矩所有跨 Bridge 的超大整数一律先转成字符串再传输。这个规范说明了 Bridge 时代的核心工作方式——你不能假设数据过去之后还是原来的样子必须自己在两端约定好格式。4.4 调试地狱跨线程断点的困境还有调试体验。UI 线程和 JS 线程各自跑各自的你没法在一条调用链路上同时断住两端的代码。原生模块抛了异常JS 端拿到的只是一个泛泛的错误对象错误组信息经常对不上排查难度陡增。这其实是 Bridge 异步模型不可避免的代价消息传过去之后就脱离了发起方的调用上下文错误处理只能靠回调错误堆栈是断裂的。新架构里 JSI 的直接调用方式让跨语言错误追踪有了更好的基础但还没有完全解决。5. 新架构为什么“干掉”了 Bridge聊完了 Bridge 的代价自然就引出了新架构。RN 从 0.68 开始逐步引入 New Architecture核心目标就是把 Bridge 换掉。但这不是简单的“换个名字”而是一整套底层通信哲学的转变。5.1 JSI从“消息传递”到“共享内存”Bridge 时代的哲学是“消息传递”——数据必须被序列化、拷贝、跨线程投递。新架构的核心是 JSIJavaScript Interface它允许 JS 引擎直接持有原生对象的引用并在 JS 侧直接调用原生方法不再需要 JSON 序列化。怎么做到的呢JSI 设计了一套 C 层的接口统一定义了 JS 值和原生对象之间的互操作能力。原生模块可以在 C 层暴露真正的对象和方法JS 引擎通过 JSI 直接访问这些对象的方法。参数传递变成了引用传递不再有值拷贝方法调用变成了函数调用不再有消息队列。这一套设计把一个“传真机”变成了“共享数据库”。5.2 TurboModule懒加载与按需初始化Bridge 时代有一个很蠢的设计所有原生模块在 App 启动时就全部注册并初始化。哪怕你只用了 10 个模块里的 2 个剩下 8 个也要被创建。这直接拖慢了启动速度。新架构的 TurboModule 改成了懒加载——只有 JS 端真正 import 并调用某个模块时这个原生模块才被初始化。模块接口定义用 Codegen 从原生侧自动生成 TypeScript 类型类型安全也顺带解决了。这条改动和 4.1 里的启动白屏问题直接相关默认加载的模块少了启动时间自然下降。5.3 Fabric渲染链路的同步化Bridge 时代JS 计算出的 UI 描述要先转成 JSON、进入消息队列、再由原生端解析后重建每一步都有延迟。Fabric 渲染器让 JS 可以直接通过 JSI 调起原生端的组件创建接口绕过 Bridge 的异步队列渲染链路大幅缩短。Fabric 还引入了一个重要能力同步渲染。在原生需要的信息已经备好的时候JS 可以同步等待渲染结果而不是每次都异步等下一轮消息。这对于手势动画、键盘弹起、视图切换等需要即时响应的场景提升非常明显。5.4 Codegen类型安全前置到编译期新架构原生模块的接口定义是一份平台无关的规范描述.h文件或turbo module定义Codegen 从这份描述自动生成 JS/TS 类型声明和原生侧实现骨架两边共用同一份“合同”。JS 端用错了方法名、传错了参数类型编译期直接报错再也不会等到运行时才崩。这个变化对工程化意义重大。团队协作时原生模块的接口变更可以通过类型声明在 JS 侧暴露问题而不是靠“联调跑一下”发现。它让跨端协作的边界变得更清晰也让 RN 的工程基础越来越接近传统原生开发的体验。5.5 新平台适配的意义从 Bridge 到 JSI移植成本在下降热词里有个“react native for openharmony”这恰好能说明 Bridge 架构的适配成本问题。在 Bridge 时代要支持一个新的宿主平台比如 OpenHarmony核心工作是重写一套原生侧的桥接层新平台的模块注册、消息队列、线程模型都要从 Bridge 的 C 层适配起工程量巨大。JSI 时代事情变简单了只要新平台提供符合 JSI C 规范的原生对象绑定JS 端基本不用改。这也是为什么我们看到 OpenHarmony、Windows 等新平台的 RN 适配速度在加快——底层通信层从“平台绑定的定制桥接”变成了“跨平台统一的 JSI 层”。这是架构演进带来的真实红利。5.6 遗留问题新架构也不是银弹老项目切新架构要小心如果原生端还依赖 Bridge 时代的某些行为比如经常在 JS 与原生之间互调、高度依赖 JSON 转换中的隐式类型转换可能会在新架构下踩到兼容性坑。新架构的同步调用也带来新问题——如果在渲染期间做了重的同步操作会直接卡住 UI 线程比 Bridge 时代的“慢”更明显。所以在我个人看来“干掉 Bridge”不是没有代价但从整体架构演进的趋势看方向是对的。6. 把 Bridge 学明白我的建议与经验这篇是“碎片八股文”的第一篇。很多人觉得八股文就是死记硬背面试答案但真正有价值的是把八股问题背后的“为什么”摸透达到能给别人讲清楚的程度。配合这个系列我给自己定了个原则每个问题不光能讲是什么还要能讲底层机制、能举出实际案例、能说明代价与取舍。这篇 Bridge 就是按这个思路展开的。实操经验上给你几个我觉得最有效的下钻方向读源码RN 工程里的Libraries/Bridge/NativeModules.js和Libraries/Bridge/MessageQueue.js值得仔细看。前者管模块注册与查找后者管消息队列的入队与批量发送逻辑把这两份文件读懂比背十篇博客都有用。看日志验证打开 RN 的调试模式开启RCTBridge打点日志你能直接看到一次方法调用产生的消息内容这是理解 Bridge 传输格式最直观的方式。用 Performance Monitor 看帧率做任何涉及大数据传输或频繁原生调用的场景先打开性能监控快速确认是不是被 Bridge 的批量发送拖累了帧率。帧率掉大概率是高频调用导致的线程唤醒成本堆叠。我自己在项目里踩过不少 Bridge 的坑最深的一点体会是RN 的性能优化一半是降复杂度一半是懂运行时。不懂 Bridge 的时候遇到卡顿只会盲目加PureComponent懂了之后你会先问“是不是跨 Bridge 传数据太大了”、“这些调用能不能合并”方向对了再谈具体技术手段。后面这个系列我打算继续按“为什么 追问 实战踩坑 新架构对比”的框架来拆解经典八股问题。如果你有特别想让我先写的话题也可以留言。下一篇见。
返回列表