做 React Native 混合开发,只要业务碰过原生能力,几乎都会遇到同一个问题:JS 层怎么调原生方法,原生又把结果怎么回给 JS。这篇文章基于我自己的项目经验,把“原生和 RN 互相调用”以及“事件监听”这条链路完整拆一遍,从桥接原理到两端代码,从调用姿势到实际坑点,尽量一次讲透。适合正在做 RN 原生集成、被两端通信绕晕的新手,也适合想系统梳理这块知识的老手。
先说一句我在项目中反复强调的话:React Native 和原生之间的每一次通信,本质都不是普通函数调用,而是一条异步消息通道。你要是理解了这句话,后面一半的坑都能提前避开。
1. 先搞明白“桥接”到底在桥什么
1.1 为什么互调不能当成普通函数调用
React Native 的 JS 运行在 JavaScriptCore 或 Hermes 引擎里,原生代码运行在各自平台 Runtime 中,两边不在同一个线程,也不共享内存。RN 页面调用一个原生方法,并不是 JS 直接执行原生函数,而是把模块名、方法名、参数序列化成消息,塞进一个队列,原生模块在自己的工作线程上消费这条消息并执行。原生要把数据回传给 JS,同样走消息队列或事件通道。
你可以把这座 Bridge 理解成一个快递中转站。JS 写好包裹,扔给中转站;中转站按地址派给原生模块;原生模块处理完再打包寄回去。整个过程是异步的,而且包裹内容必须能简单序列化。所以有一个老生常谈的约束:跨端传递的数据只能是字符串、数字、布尔、数组、对象这类 JSON 兼容类型,Date、Function、Map 这些不能直接传,传过去也不是原来的对象。
1.2 为什么方法只能“异步返回”
经常有人问,能不能让 JS 同步拿到原生返回值?桥接机制决定了这条路基本走不通。如果强行同步,JS 线程要一直阻塞等待原生线程返回,页面卡顿是小事,两个线程互相等还可能造成死锁。
所以 React Native 从设计上强制了异步。RN 调用原生后,原生如果要把结果返回,只能用三种姿势:Callback、Promise、事件推送。Promise 是现在最常用的,责任链清晰,配合 async/await 写出来的调用代码和普通 Promise 差不多,可读性高。Callback 在老项目中偶尔还有,iOS 的 RCT_EXPORT_METHOD 里也能按老 API 传两个回调。但事件推送更适合“原生主动通知 JS”的场景,实际上事件监听这套机制,也正是为了解决“原生自发消息,RN 被动收”这类需求。
1.3 新旧架构对通信方式的影响
老架构下所有 JS 和原生通信都通过 Bridge,消息要序列化、排队、跨线程中转,在调试模式下还能明显感觉到延迟。新架构推出后,改为用 JSI 让 JS 直接持有原生方法引用,跳过了 JSON 序列化这一步,通信延迟大幅降低,这就是 TurboModule 的核心思路。
那篇文章里我不会一上来劝所有项目都去迁移新架构。真实情况是,很多存量项目依然跑在老架构或兼容模式下,传统 NativeModules、RCT_EXPORT_MODULE 这些 API 仍然能工作。理解老桥接机制依然有价值,因为新架构下的调用方式尽管底层变了,业务层的代码长得很像,而且老代码过渡到新架构通常有兼容层。做混合开发,先把“桥接模式下事件怎么发、模块怎么暴露、参数怎么传”弄明白,新架构来了也能平滑迁移。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RN 调用原生:自定义原生模块的完整套路
2.1 Android 端:写一个模块并注册进应用
RN 调用原生,第一步是原生侧暴露模块。Android 上最基础的写法是继承 ReactContextBaseJavaModule,然后通过 @ReactMethod 注解暴露 JS 可调用的方法。
我以扫码模块为例,这是混合开发里特别典型的需求。在 Kotlin 里大致长这样:
kotlin复制class ScanModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "ScanModule"
@ReactMethod
fun openScanner(type: Int, promise: Promise) {
// 跳转到原生扫码页
// 成功后 resolve 结果
// 失败或用户取消时 reject
}
}
注意两点。第一,getName() 返回的名字就是 JS 侧 NativeModules.ScanModule 里的键名,两边的名字必须对得上。第二,@ReactMethod 标注的方法返回值必须是 void,不能直接 return 一个字符串或对象回去。如果不用 Promise 也不是 Callback,你即使写了返回值,JS 侧也拿不到。
光写模块还不够,Android 需要把模块通过 ReactPackage 注册进去。我经常见新手在这里漏掉注册,JS 端报 ScanModule is null,排查半天才发现是包没挂上。
code复制public class ScanPackage implements ReactPackage {
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new ScanModule(reactContext));
return modules;
}
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}
然后在 MainApplication 的 getPackages() 里把 new ScanPackage() 加进去。这一步做完,RN 端才可能拿到这个原生模块。
如果项目里同时有多个模块,不要图省事全丢在某个 Activity 里。建议一个独立业务模块对应一个 ReactPackage,Application 那层只做聚合。这样后面按需裁剪功能时,改动面会小很多。
2.2 iOS 端:OC 模块的导出与注册差异
iOS 侧的写法和 Android 不同,不需要手动注册,模块通过 RCT_EXPORT_MODULE() 宏自动被发现。常见的 Objective-C 写法是:
objc复制#import <React/RCTBridgeModule.h>
#import <React/RCTPromise.h>
@interface ScanModule : NSObject <RCTBridgeModule>
@end
@implementation ScanModule
RCT_EXPORT_MODULE()
RCT_EXPORT_METHOD(openScanner:(NSDictionary *)params
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
dispatch_async(dispatch_get_main_queue(), ^{
// 原生页面跳转和业务处理
if (success) {
resolve(@{@"code": scanCode, @"type": @(2)});
} else {
reject(@"SCAN_CANCEL", @"user canceled", nil);
}
});
}
@end
写的时候有个容易踩的坑:RN 的 JS 调用默认不在主线程。如果你在 RCT_EXPORT_METHOD 里直接操作 UI,或者做依赖主线程的跳转,大概率会遇到“只在主线程执行的代码”这类警告甚至崩溃。正确的习惯是:方法内部一进来就把耗时操作分线程,UI 相关操作主动切到主线程,再在主线程里处理完调用 resolve/reject。
返回值也一样:RCT_EXPORT_METHOD 修饰的方法类型不要求显式写 void,但本质上仍然只能通过 block 异步返回。旧 API 也可以传 RCTResponseSenderBlock 回调:
objc复制RCT_EXPORT_METHOD(getDeviceInfo:(RCTResponseSenderBlock)callback) {
callback(@[@{@"battery": @"100"}]);
}
这种老式回调我不太推荐在新代码中使用。它第一个参数约定俗成是报错列表,JS 侧要自己判断,代码很容易写得混乱。统一用 Promise,业务代码会干净很多。
2.3 JS 侧调用原生模块的三种姿势
模块暴露出来后,RN 侧调用非常简单。老架构下直接用 NativeModules:
javascript复制import { NativeModules } from 'react-native';
const { ScanModule } = NativeModules;
async function handleScan() {
try {
const result = await ScanModule.openScanner({ type: 1 });
// result.code
} catch (e) {
// e.code / e.message
}
}
有一种情况要特别提醒:如果你的原生方法在某些机型或状态下长时间没有 resolve,也没有 reject,JS 侧会一直挂在 await 上。尤其是涉及到弹窗授权、跳转第三方 App 的,用户可能把页面切走,导致结果迟迟不回来。我在扫码场景里通常会加超时保护,JS 侧用 Promise.race 包一层,超过指定时间就提示“操作超时并重置状态”。原生永远可靠这个假设,在业务中是不成立的。
Callback 的形式在 JS 侧长这样:
javascript复制ScanModule.getDeviceInfo((error, result) => {
if (error) {
// 处理失败
return;
}
// 处理成功
});
和老式 OC API 风格比较像,但我不建议大批量用,因为回调嵌套一旦多起来,项目代码会出现“回调地狱”。还是优先用 Promise,可读性和维护性都好很多。
3. 原生调用 RN:事件监听怎么从零打通
3.1 先分清全局事件还是模块事件
“原生主动给 RN 发消息”最常见的就是事件。RN 端接收事件有两种方式:DeviceEventEmitter 和 NativeEventEmitter。
DeviceEventEmitter 接收的是全局事件,原生端通常通过 RCTDeviceEventEmitter 发出来,JS 里凡是注册了同名监听器的组件都能收到。它的优势是简单直接,但缺点也很明显:事件名是全局的,如果页面 A 和页面 B 都监听同一个名字,页面 B 也可能收到页面 A 触发的事件,造成“串线”。
NativeEventEmitter 是和某个原生模块绑定的方式。JS 侧先拿对应原生 NativeModule,再 new 一个 emitter 实例监听。这天然把事件范围约束到了这个模块上,事件语义更清晰,也不容易和其他模块冲突。
我的建议是:如果只是某个原生功能“偶尔喊一嗓子”,全局事件能省事;但如果你在做一个完整 SDK 或者一套通信体系,尽量用模块级事件。模块级事件在 iOS 端有专门的实现方式,Android 平台靠 DeviceEventEmitter 的情况比较多,这一点需要前后端一起约定好。
3.2 Android 原生侧发送事件的写法
Android 原生侧发事件最标准的写法是拿到 DeviceEventManagerModule.RCTDeviceEventEmitter,然后调用 emit。我在封装原生模块时,一般会写一个内部的 sendEvent 方法:
kotlin复制private fun sendScanResult(reactContext: ReactApplicationContext, code: String) {
val params = Arguments.createMap().apply {
putString("code", code)
putString("source", "camera")
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("onScanResult", params)
}
写完后记得,emit 的时机一般在原生业务完成后。比如原生扫码页拿到结果,在 onActivityResult 或回调里触发。不要把它放在模块构造器或某个无关生命周期里,否则 RN 侧监听还没就绪,消息已经过去了,丢掉的消息很难追查。
Java 和 Kotlin 写法本质一致。Java 里需要写 Arguments.createMap() 生成 WritableMap,再往里塞值。有一点值得注意:Arguments.createMap() 创建的是可写结构,跨桥发出去之后 JS 拿到的是一个普通对象。如果你传的是自定义 Java 对象,RN 侧收不到完整字段,只会得到一个空对象或抛序列化错误。所以原生侧数据要提前摊平到 Map 中。
3.3 iOS 原生侧发送事件的写法
iOS 侧发送事件,老桥接方式可以通过 bridge.eventDispatcher 发送 App 级事件:
objc复制#import <React/RCTBridge.h>
- (void)sendEventToRN {
[self.bridge.eventDispatcher sendAppEventWithName:@"onScanResult"
body:@{@"code": @"123456", @"source": @"camera"}];
}
如果希望事件更模块化,可以使用 RCTEventEmitter 子类的方式。先让模块继承 RCTEventEmitter,并声明 supportedEvents:
objc复制#import <React/RCTEventEmitter.h>
@interface ScanModule : RCTEventEmitter <RCTBridgeModule>
@end
@implementation ScanModule
RCT_EXPORT_MODULE()
- (NSArray<NSString *> *)supportedEvents {
return @[@"onScanResult"];
}
- (void)sendResult:(NSString *)code {
[self sendEventWithName:@"onScanResult" body:@{@"code": code}];
}
@end
这种模块级事件的好处是 iOS 侧会向 JS 侧注册一个“这个模块支持哪些事件”的清单,RN 端使用 new NativeEventEmitter(NativeModules.ScanModule) 监听时,事件名在清单里才收得到,否则在调试下会报警告。
有一点我要提醒:如果 ScanModule 同时也要被 RN 调用,比如里面有 RCT_EXPORT_METHOD,可以继续在 RCTEventEmitter 子类里正常声明方法。模块既能被 JS call,也能主动 emit 事件,这是原生模块开发里很常见的一种复合形态。但注意导出事件时要避免事件名和其他模块重复,最好带统一前缀,比如“scan_”、“player_”。
3.4 RN 侧监听和组件生命周期的绑定
RN 侧监听事件,最标准的姿势是在组件挂载完成后注册监听器,在卸载前移除监听器。React Hooks 项目里基本都用 useEffect 收口:
javascript复制import { useEffect } from 'react';
import { DeviceEventEmitter, NativeEventEmitter, NativeModules } from 'react-native';
// 方式一:全局事件
useEffect(() => {
const subscription = DeviceEventEmitter.addListener('onScanResult', handleResult);
return () => {
subscription.remove();
};
}, []);
// 方式二:模块级事件
useEffect(() => {
const emitter = new NativeEventEmitter(NativeModules.ScanModule);
const subscription = emitter.addListener('onScanResult', handleResult);
return () => {
subscription.remove();
};
}, []);
在类组件中,对应的是在 componentDidMount 中 addListener,在 componentWillUnmount 中 remove。我记得一些老项目会在 componentWillUnmount 里调用 DeviceEventEmitter.removeAllListeners('事件名'),如果这个事件只属于当前页面,确实能清理掉。但如果多个页面或全局 store 都用同一个事件,removeAllListeners 会把别的订阅也删了,容易引发诡异问题。所以更稳妥的写法是保存 addListener 返回的 subscription,然后精确 remove。
还有一个很重要的面试题场景:如果监听器注册在 useEffect 里依赖一个会变化的 state,而你没有把依赖写进去,很容易出现回调里拿到旧 state 的问题。实际项目我建议在回调里用 ref 存一份最新值,或者把依赖数组写完整。这个坑和事件本身无关,但出现频率很高。
4. 完整走一遍:原生扫码结果回传 RN 页面
4.1 场景与通信协议设计
上面讲的都是独立知识点,我拿一个完整场景把链路串起来:RN 页面点击“扫码”按钮,调起原生扫码页;原生扫码成功后,把结果实时回传给 RN;RN 页面展示结果,然后关闭扫码页。
为什么把结果用事件回传,而不是用 Promise 返回?其实两种都能实现。如果原生扫码页面是自己 push 出来的,并且扫码结果只触发一次,用 Promise 是最合适的。但真实业务往往会有连续扫码、扫码页里多步操作、或者“扫码成功后还需要原生继续执行动作并告诉 JS 进度”这类复杂需求,一直用 Promise 去 resolve 会非常难写。所以这种场景我会优先设计事件通道:
- RN -> 原生:调用
startScan,启动扫码页面 - 原生 -> RN:发送
onScanResult事件,携带扫描结果 - 原生 -> RN:发送
onScanState事件,携带扫码页状态(打开、取消、超时等)
通信协议我会在代码外先定义清楚,事件名叫什么、参数里有哪些字段、什么情况下触发、由谁负责关闭页面,这些直接写成一个文档,避免两端程序员各写各的。
4.2 原生侧的代码组织
Android 端的模块代码我通常会分成两层:底层一个原生业务类(比如真正的扫码 Activity 或 Fragment),上层一个 ReactContextBaseJavaModule 子类只负责“桥接转发”。不要让原生模块里塞满业务逻辑,否则以后想把扫码模块抽成独立 SDK,几乎要重写。
kotlin复制class ScanModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "ScanModule"
@ReactMethod
fun startScan() {
val intent = Intent(reactContext, ScanActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
reactContext.startActivity(intent)
}
fun notifyScanResult(code: String, source: String) {
val params = Arguments.createMap().apply {
putString("code", code)
putString("source", source)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("onScanResult", params)
}
fun notifyScanState(state: String) {
val params = Arguments.createMap().apply {
putString("state", state)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("onScanState", params)
}
}
ScanActivity 拿到扫码结果后,通过一个静态方法或单例把结果通知给 ScanModule,ScanModule 再 emit 到 JS。这里有个常见需求:扫码页 getActivity 拿到结果后,既要把事件发给 JS,又要关闭原生页面。关闭页面的动作属于 UI,应该让原生页面自己维护,事件只管通知层,不要把“关闭页面”这种动作也做成一个 RN 再回调给原生的操作,来回绕一圈没有任何意义。
iOS 端结构类似。ScanModule 负责暴露 startScan,内部持有 UI 控制逻辑;扫码结束后调用 sendEventWithName 发事件。如果扫码页面本身是原生 UIViewController 或全屏 SwiftUI,注意事件发送要在页面销毁前完成,已经销毁再 sendEventWithName 大概率会丢。
4.3 RN 侧页面的联动处理
RN 页面的逻辑我一般会做一个 hook 封装,而不是在组件里裸写 addListener。这样整个扫码监听逻辑可以被多个页面复用,也方便做事件名收敛:
javascript复制// useScan.js
import { useEffect, useRef } from 'react';
import { NativeModules } from 'react-native';
import { DeviceEventEmitter } from 'react-native';
export function useScan(onResult) {
const handlerRef = useRef(onResult);
handlerRef.current = onResult;
useEffect(() => {
const sub = DeviceEventEmitter.addListener('onScanResult', (data) => {
handlerRef.current?.(data);
});
const stateSub = DeviceEventEmitter.addListener('onScanState', (data) => {
if (data?.state === 'cancel') {
// 处理用户取消
}
});
return () => {
sub.remove();
stateSub.remove();
};
}, []);
}
这样页面上调用就是一行:
javascript复制useScan((result) => {
setCode(result.code);
});
注意:我们把最新回调放到 useRef 里,让 effect 只注册一次监听,但回调始终能拿到最新的 state。这是我在写事件监听时比较常用的解法,比每次注册、取消的写法更稳。
4.4 时序问题和线程问题的检查
完整链路跑通后,我们要做几个基本检查。第一,JS 监听器是否在调用 startScan 之前注册好?如果是先点了按钮才去 useEffect 注册监听,原生扫码页如果秒出结果,事件很可能在监听器注册前已经发出去了,页面会傻等。
第二,事件发送时如果是原生子线程发出来的,参数结构必须保证是线程安全的。Android 的 Arguments.createMap() 是可以在任意线程创建的,iOS 如果要 sendEvent,尽量回到主线程再发,或者保证 body 是浅层不可变对象。否则极端情况下会出现跨线程访问可变容器崩溃。
第三,RN 端收到事件后不要直接做重度操作。如果你要在事件回调里 setState、计算、调用多个原生方法,而这些逻辑密度很高,建议包一层 requestAnimationFrame 或者丢到下一个宏任务做。事件线程和 UI 线程的节奏不一致时,直接同步做高负载操作,界面上会出现肉眼可见的掉帧。
5. 事件监听的坑与排查方法
5.1 监听器不移除会导致什么
监听器漏 remove,最直接的后果是“事件回调被重复执行”。你从页面 A 进入扫码页,原生发一个事件,页面 A 已经销毁但监听器还在订阅池里,代码还会执行;如果页面 A 又创建了一次,监听器变成两个,回调会触发两次。这时候的表现可能是“明明只扫码一次,界面却弹了两个结果提示”。
另一个后果是原生侧模块生命周期异常。如果模块持有 Activity 或 Context 引用,而且一直不被释放,页面销毁后内存依然被占。项目做大以后,很难说是哪次事件监听没清理导致的卡顿和内存上涨。
所以说“在 useEffect 里做 addListener 并 return remove”不是形式主义,是保命的。特别是那种只在某个原生功能页才注册的事件,不要在全局 store 或路由配置里随便挂长生命周期监听。宁可多写几遍 remove,也不要赌系统帮你清理。
5.2 原生事件先发、RN 后监听,丢消息怎么办
丢消息是事件通信里最头疼的问题。原生 Activity 的 onCreate 里立刻发事件,RN 还没执行到监听注册代码,事件早没了。我处理过几种方案:
第一种,原生侧做“待发消息队列”。模块收到远端消息时,如果判断 JS 侧还没 ready,先把消息暂存在内存列表;之后当某个 bridge 生命周期回调触发,再一次性补发。这种方案比较适合从原生启动的 RN 页面。
第二种,RN 侧注册监听器后主动反向拉取。比如原生扫码结果可能在启动 RN 页面之前已经存在,RN 调一个 getPendingResult 方法,原生把缓存结果返回。这种“事件 + 主动拉取”的组合在支付结果、扫码结果场景很实用,能防止因为页面切换导致通知丢失。
第三种,调整时序。原生页面在发事件之前,等 JS 通过 RCT_EXPORT_METHOD 通知“我已准备好”,再发结果。这个方案增加了往返消息,但在一对一场景最可靠。像原生扫码页要等 RN 界面完全渲染再发结果,基本不会丢。
5.3 参数类型和线程问题是坑中坑
跨桥数据只能序列化,意味着你传的 NSDictionary、Map、Array 里面不能再掺自定义对象。你在 iOS 传一个 NSDate 进去,JS 拿到后它不是一个字符串,也不会自动变成 Date,可能只是一个无法解析的对象或变成 null。我自己的习惯是把所有时间统一转成毫秒时间戳或 ISO 字符串再传;金额统一转成字符串或整数分,不要直接传浮点,避免跨语言精度问题。
还有个很隐蔽的线程问题:Android 原生 @ReactMethod 方法默认在 native modules 线程执行,iOS 的 RCT_EXPORT_METHOD 也不保证在主线程。所以任何 UI 跳转和事件发送,最好统一在方法内部 dispatch 到主线程再处理。我看到过不少新人把 doInBackground 里的网络结果直接拿去发事件,代码能跑但有概率崩溃。养成“方法入口分线程、操作 UI 回主线程”的习惯,能省一半排查时间。
5.4 常见问题速查表
| 问题现象 | 常见原因 | 排查思路 |
|---|---|---|
| NativeModules.ScanModule is null | Android 模块未注册进 ReactPackage;iOS 编译未链接库 | 检查 MainApplication.getPackages;检查 Pods 是否 include;重新 build |
| JS 调用方法后无反应 | 方法没加 @ReactMethod / RCT_EXPORT_METHOD;或原生方法抛异常被吞掉 | 原生打日志或断点,看方法是否触发 |
| 原生回调不执行或 Promise 一直 pending | 原生没有调用 resolve/reject;页面销毁导致回调丢失 | 在 resolve/reject 前后打日志,确认调用链是否完整到达 |
| 事件触发后 RN 收不到 | 监听器注册晚;事件名不一致;emit 时机在订阅前 | 检查 addListener 和 emit 的事件名;在原生 emit 前加延时验证 |
| 事件收到但回调执行了多次 | 同一个组件多次注册;页面销毁未移除监听器 | 在 addListener 时打印日志,检查 effect 是否重复执行 |
| 收到事件后数据是 null | 跨桥传了不支持的自定义对象;参数结构不对 | 原生把数据摊平成可序列化 Map/Dictionary |
| 原生调用 sendEvent 崩溃或警告 | 非主线程发送;模块没有声明 supportedEvents;bridge 已被释放 | 回主线程发送;iOS 用 RCTEventEmitter 时 implement supportedEvents |
这张表如果你是第一次做原生集成,建议直接存下来。很多问题现象很像,但根因可能完全不一样,先用日志定位是 JS 侧没收到、还是原生侧没发出,能少走很多弯路。
6. 我的一些实操心得
做原生和 RN 通信这件事,最容易翻车的不是某个 API 不会写,而是缺少一套通信约定。
第一,事件名制定命名规范。原生侧和 JS 侧的事件名在开发期可以随便写,项目一大人就麻了。我现在习惯按“模块_动作”来命名,比如 scan_result、scan_error、player_progress。监听器注册统一收口到 hook 或 utils 里,不要在几十个组件里裸调 DeviceEventEmitter.addListener。
第二,通信层单独抽文件。RN 调用原生不要散落在页面里,抽出一个 nativeBridge.js 或 ts 文件,所有原生模块调用、事件订阅、参数类型定义都集中管理。以后原生模块改了接口,改这一个文件就行,页面代码基本不动。
第三,网络请求、扫码这类容易“等了很久没结果”的操作,JS 侧要加超时保护。不要相信原生稳定回调,万一用户切后台、扫码页卡死、原生 Module 出异常,JS 界面要有兜底。我在实际项目里都是 Promise.race([callNative(), timeout(5000)]),超时后弹提示并重置按钮状态,体验和稳定性都会好很多。
第四,如果团队有原生开发同学,原生代码层也要做防腐。原生模块只做桥接和参数转换,业务逻辑放在原生独立 service 或 manager 里。这样当 RN 页面要升级成原生页面、或者原生页面要嵌入 RN 时,通信层改动很小。
混合开发做到后面你会发现,React Native 和原生互调本身不复杂,真正决定项目质量的是大家对通信边界、事件时序、数据格式的共识。把规范和工具链提前搭好,后续业务无论接入扫码、支付、定位、蓝牙,都只是往框架里填新方法而已。
