从零实现 React Native(1): 桥通信的原理与实现

前言

开始之前,为啥有这篇文章呢,这是一个自问自答的问题。

我的职业生涯几乎一直在和 React Native(RN) 打交道。故事开始于读研期间,我做为前端开发和教研室两个后端师兄一起为导师做一个 C 端的项目,受到 React 的生态和 RN 的 Slogn 「write once run anywhere」的感召,我使用了基于 Expo 的 RN 技术栈来完成了 App 项目的开发与上线。

而找实习时也凭借了 PC 和 App 的 React 技术栈经验得到了前司鹅厂的 offer,实习期间使用了 Hippy-vue(公司的类 RN 框架,JS 侧使用 Vue 框架)实现了王者营地的战绩详情页。回想起那段实习的日子也是额外的充实,大部分工作时间投入在了 Coding 上,披星戴月下班回家在夜深没人的路上唱着自己喜欢的歌,觉得自己有无限可能。

在正式进入工作后,我的第一份工作中大量使用了 Hippy-react(🐧 的类 RN 框架,JS 侧使用 React)来构建王者营地的页面。到现在我的第二份工作中也使用了 MRN(美团基于 RN 改造的框架,API 兼容)。

可见我的职业生涯贯穿了 RN 这个框架,在大量应用实践后也明白了 RN 桥架构的优势和局限,明白了 RN 适合用于什么场景。但是,我还想更进一步探索 RN 的原理,既是满足自己的好奇心,也是给自己的职业生涯的一个回应。

桥架构通信的本质

虽然现在 RN 已经重构并演进到基于 JSI 的新架构了,但出于学习的目的,我还是从经典的桥架构出发,对 RN 进行最小还原。在搞清楚桥架构的原理后,再进入到新架构的深入学习中。

我们知道,RN 提供了用 React 写 JS 代码渲染 Native 应用的能力,我们前端开发写 RN 应用时最常打交道的就是 JS 代码,而最终运行在 Native 的产物其实也是 JS 代码。在 RN 中 JS 显然不是在端侧的 WebView 运行的,而是在嵌入 JS 引擎中引擎运行的,故而 JS 和端侧通信不是和 WebView 打交道而是需要和 Native 打交道。那么关键问题来了,JS 是怎么和 Native 通信的呢?下面我们以 RN 获取屏幕/视窗尺寸为例,进行说明。

typescript 复制代码
// JS 侧从 Native 提供的模块获取数据
// 调用关系: JS -> Native
const windowDimensions = Dimensions.get('window');
const screenDimensions = Dimensions.get('screen');

const App = () => {
  const [dimensions, setDimensions] = useState({
    window: windowDimensions,
    screen: screenDimensions,
  });

  useEffect(() => {
    const subscription = Dimensions.addEventListener(
      'change',
      ({window, screen}) => {
        // Native 尺寸变化通知 JS 侧
        // 调用关系:Native -> JS
        setDimensions({window, screen});
      },
    );
    return () => subscription?.remove();
  });
  
  // ...
}

从代码可以看见,JS 和 Native 的数据流向是双向的,JS 既可以调用 Native 模块的方法获取数据,Native 也可以主动推送数据给 JS。刚刚我们有提到,RN 中的 JS 的运行环境是嵌入 JS 引擎,具体的在桥架构版本中的 JS 引擎是 JavaScriptCore (JSC)。Native 用 JSC 加载了 JS 代码,再通过 C++ 桥这个中间层进行双向通信,这套核心代码是用 C++ 编写的,C++ 是一个高性能跨平台语言,用 C++ 来处理和 JSC 的交互以及进行桥实现来在 iOS 和 Android 双端复用是再适合不过了。

稍微展开讲讲,RN 选择 C++ 实现 Bridge 层的原因。首先,C++ 作为编译型语言具有接近原生的性能,这对于频繁的跨语言通信至关重要 ------ 每次 JS 调用 Native 都涉及数据序列化、消息队列管理等操作,C++ 的高性能可以最小化这些开销。其次,C++ 可以直接调用 JSC 的 C/C++ API,无需额外的语言绑定层。最后,一套 C++ 代码可以同时编译到 iOS 和 Android,确保两端桥通信的行为完全一致,避免平台差异带来的 bug。

回到这节的主题,我们关注的是桥通信,先贴下经典的桥架构图如下所示,大家可能已经在其他地方见到过很多次这张图了。我也会继续使用这张图来说明桥架构通信的本质是什么。

刚刚有提到 JS 和 Native 的通信是双向的,我们先看看 JS 是如何调用 Native 的,在这里我们其实可以和浏览器进行类比。不同于在浏览器中运行的 JS 其宿主是浏览器,而 RN JS 的运行宿主是嵌入在手机 OS 的 JSC 引擎。就如浏览器给 JS 环境提供了 window 对象及其方法供 JS 调用,嵌入 JS 引擎也是如此,为 JS 环境 注入了 global 对象及其方法供 JS 调用,而其中的方法就包含了 global.nativeCallSyncHook 这个关键方法(简称 callSync)。callSync 的入参包含了 JS 需要调用 Native 侧具体什么方法的信息,而每次 JS 侧调用 callSync 就可以触发 C++ 侧提前为该方法注册的回调函数,可以理解为 JS 调用该方法时就会把入参输入经过桥给 C++ 的对应函数并执行,而这一切是通过 JSC 的 API 做到的。再以之前的用 Native 模块获取尺寸的代码为例,JS 侧调用 Dimensions.get('window') 时最终会调用 callSync 方法,入参包含了模块信息为 Dimensions、方法为 get、参数为 'window',到 C++ 回调后再交由 C++ 转发给具体模块的实现进行处理。

再看看 Native 是如何通知 JS 数据更新的,还是以之前的代码为例: JS 监听了 Dimension 模块的 change 事件,Native 是如何触发该事件的呢。JS 在初始化时在 global 对象上初始化了供 Native 调用的方法,即 global.__fbBatchedBridge.callFunctionReturnFlushedQueue 方法,简称 callFn。在 Native 尺寸发生变化时,C++ 环境通过获取 JS 方法的句柄并调用,这样 JS 环境的 callFn 方法就会被调用,再通过 JS 侧的提前注册好的事件监听触发了 Dimension 模块的 change 事件。C++ 调用 JS 方法,也是通过 JSC 的 API 做到的。

由此我们可以发现,C++ 环境创建并管理了 JS 环境,既可以为 JS 环境注入全局变量和方法供 JS 调用,也可以主动调用 JS 环境中的方法,C++ 环境对 JS 上下文有着极高的控制权。这也是桥通信的奥秘所在。

正式开始前:任务概览

本项目的桥架构实现参考 react-native@v0.57.8 版本,该版本只包含 Bridge 代码不包含 JSI 代码,便于学习和参考。

在了解完跨语言间是如何相互调用实现通信之后,我们接下来就来看看完整实现 JS ↔ Native 双向通信需要做些什么。

首先,需要在 C++ 中管理 JS 上下文,这是我们出发的基础中的基础,有了 JS 可运行的环境后,再在 JS 环境中实现消息的发送和接收,实现的载体即是消息队列,然后再完成模块系统,实现一个示例模块的方法调用和消息接收。经历这一连串的路程,我们便实现了刚开始那段代码中 JS ↔ Native 双向调用的能力。

为了轻装上阵,初始阶段只在 macOS 系统中运行该项目。一步一步来,把 macOS 跑通后迁移到 iOS 并不复杂,而支持 Android 端还有一些适配工作需要做,不过受篇幅限制这些工作并不在本阶段完成。

C++ 环境集成 JS 引擎

在一切开始之前,我们需要搭建在 macOS 中运行 JS 的环境,除此之外,在为 JS 环境注入日志函数,供 JS 输出日志便于调试,这便是第一步需要做的。值得一提的是,JS 的核心规范是 ECMAScript ,这意味着 console 对象并不是 JS 语言本身的一部分,而是由宿主环境(如 Chrome, Node)提供的,单纯的 JSC 引擎并不包含 console 对象。

好的,现在我们开始用 C++ 以及 JavaScriptCore 来管理 JS 上下文。首先,我们把 C++ 的头文件定义好,我摘取了其中两个重要成员变量和两个重要方法如下。C++ 的 .h 文件用于声明接口,其实和 TypeScript 的 .d.ts 文件有着相似的作用。

c++ 复制代码
// JSCExecutor.h
class JSCExecutor {
 private:
  /**
   * JSXXX 都是 JavaScriptCore 的 API,一些是类型(如
   * JSObjectRef)一些是方法(如 JSGlobalContextCreate) 通过使用
   * JSXXX,可以用 C++ 调用/管理 JS 引擎,可以理解为跨语言的桥梁和安全句柄
   * JavaScriptCore API Reference:
   * https://developer.apple.com/documentation/javascriptcore
   */
  // JavaScript 运行环境(全局上下文)
  JSGlobalContextRef m_context;
  // JS 运行环境的全局对象 global
  JSObjectRef m_globalObject;
 public:
  /**
   * 加载并执行 JavaScript 应用代码
   * @param script JavaScript 代码内容
   * @param sourceURL 代码来源 URL(用于调试)
   */
  void loadApplicationScript(const std::string &script,
                             const std::string &sourceURL);
  /**
   * 向 JavaScript 环境注入全局函数
   * @param name 函数名称
   * @param callback Native 回调函数
   */
  void installGlobalFunction(const std::string &name,
                             JSObjectCallAsFunctionCallback callback);
};

有了 JSCExecutor.h 接着我们需要实现 JSCExecutor.cpp。我们还是关注关键变量的初始化和关键方法的实现,先对 JS 运行上下文和 global 对象进行初始化,代码如下。初始化都是通过了 JavaScriptCore API 来完成的。PS:Apple 平台(如 iOS、macOS)的系统内置了 JSC,引用/链接 JSC 库相对于 Android 平台更为便捷,这也是用 JSC 作为 RN JS 引擎的一个优势,即 iOS 只需引用系统内置的 JSC 引擎即可,对包大小友好。

C++ 复制代码
// JSCExecutor.cpp
JSCExecutor::JSCExecutor() : m_context(nullptr), m_globalObject(nullptr) {
  // 调用 JSC API 初始化 JS 运行上下文
  m_context = JSGlobalContextCreate(nullptr);
  if (!m_context) {
    throw std::runtime_error("Failed to create JavaScript context");
  }
  // 获取 global 全局对象
  m_globalObject = JSContextGetGlobalObject(m_context);
}

然后再来实现加载 JS 脚本和向 JS 环境注入方法,代码如下。加载 JS 脚本的关键在于用 JSEvaluateScript 在 JS 上下文中加载 JS 脚本的字符串,向 JS 环境注入方法关键在于用 JSObjectSetProperty 在 JS 上下文的全局变量上设置属性。而这些都是借助 JSC API 实现的。

c++ 复制代码
// JSCExecutor.cpp
void JSCExecutor::loadApplicationScript(const std::string &script,
                                        const std::string &sourceURL) {
  // 将 C++ 的字符串转换成 JSC 可识别的字符串类型
  JSStringRef scriptStr = JSStringCreateWithUTF8CString(script.c_str());
  JSStringRef sourceURLStr =
      sourceURL.empty() ? nullptr
                        : JSStringCreateWithUTF8CString(sourceURL.c_str());

  JSValueRef exception = nullptr;
  // 核心调用:执行 JS 脚本
  JSValueRef result = JSEvaluateScript(m_context, scriptStr, nullptr,
                                       sourceURLStr, 0, &exception);

  if (exception) {
    handleJSException(exception);
  }

  // 释放 JSC 字符串占用的内存
  JSStringRelease(scriptStr);
  if (sourceURLStr) JSStringRelease(sourceURLStr);
}

void JSCExecutor::installGlobalFunction(
    const std::string &name,
    JSObjectCallAsFunctionCallback callback) {
  JSStringRef funcName = JSStringCreateWithUTF8CString(name.c_str());
  // 创建一个 JS 函数对象,并指定它的 C++ 回调函数实现
  JSObjectRef func =
      JSObjectMakeFunctionWithCallback(m_context, funcName, callback);
  // 将刚创建的函数对象挂载到全局对象 global 上
  JSObjectSetProperty(m_context, m_globalObject, funcName, func,
                      kJSPropertyAttributeNone, nullptr);

  JSStringRelease(funcName);
}

然后在 JSCExecutor 构造器中调用 installGlobalFunction 给 JS 引擎注入 log 方法,供 JS 脚本调用端上方法进行输出日志,便于调试。

c++ 复制代码
// JSCExecutor.cpp
JSCExecutor::JSCExecutor() : m_context(nullptr), m_globalObject(nullptr) {
  // ...
  installGlobalFunction(
      "nativeLoggingHook",
      [](JSContextRef ctx, JSObjectRef function, JSObjectRef thisObject,
         size_t argumentCount, const JSValueRef arguments[],
         JSValueRef *exception) -> JSValueRef {
        // 获取 JSCExecutor 实例
        auto *executor = JSCExecutor::getCurrentInstance();
        
        if (argumentCount >= 2) {
          // JS 类型转 C++ 类型
          std::string levelStr = jsValueToString(level);
          std::string messageStr = jsValueToString(message);
          // 使用 C++ 标准输出流
          std::cout << "[" << levelStr << "] " << messageStr << std::endl;
        } else {
          std::cout << "[Bridge] Warning: nativeLoggingHook called with "
                       "insufficient arguments"
                    << std::endl;
        }
        return JSValueMakeUndefined(ctx);
      });
}

实现了 JSCExecutor.cpp 后,就可以新建一个测试文件 test.cpp 来试试加载并运行 JS 脚本了。经过一番编译运行,可以发现运行测试程序成功输出了 JS 调用并打印的内容,这样就测试成功了。

C++ 复制代码
// test.cpp
int main() {
  JSCExecutor executor;
	// C++ 原始字符串字面量语法,支持多行编写字符串
  std::string bridgeTestScript = R"(
            if (typeof global.nativeLoggingHook === 'function') {
                global.nativeLoggingHook('INFO', 'This is a test log from JavaScript');
                global.nativeLoggingHook('DEBUG', 'Bridge logging is working!');
            }
        )";

  executor.loadApplicationScript(bridgeTestScript, "bridge_test.js");

  return 0;
}

总结一下,这节我们构建了一个可以加载并管理 JS 上下文的 C++ 程序 JSCExecutor。有了这一步我们才有了 JS ↔ Native 双向通信的可能。

JS 侧桥核心方法实现

有了 JS 可运行的环境后,接下来进入到 JS 侧桥的实现,JS 侧桥的本质是一个消息队列,本节的任务是 JS 侧通过消息队列完成和 Native 的通信。

Why Messagequeue(MQ)? 主要原因有两:异步通信和优化性能。

  • 异步通信:JS 引擎和 Native 代码运行在不同线程上,由于线程隔离,JS 无法直接调用 Native 的函数,必须通过某种跨线程通信机制,消息队列是实现这种跨线程异步通信的经典方案
  • 优化性能:队列包含了一批消息信息,有了 MQ 后 JS 可以一次批传输多个消息给 Native,相比于每个消息单个传输给 Native 这可以减少跨线程通信的开销。

接下来,来看看 MessageQueue.js 的接口设计,我摘取了关键成员变量和方法,代码如下。PS:文章开头代码中 global.nativeCallSyncHook同步方法,不经过 MQ 调用,MQ 只承载异步方法的调用。

typescript 复制代码
class MessageQueue {
  // [moduleIDs, methodIDs, params, callbackIDs]
  private _queue: [number[], number[], number[], number[]] = [[], [], [], []];
  // 回调 id,递增记录
  private _callID: number = 0;
  // 回调 map,key 为 回调 id
  private _callbacks: { [key: number]: { onSucc: Function; onFail: Function } } = {};
  // JavaScript → Native 调用(异步)
  public enqueueNativeCall(moduleID, methodID, params, onFail, onSucc);
  // Native → JavaScript 调用,场景为模块的事件通知
  public callFunctionReturnFlushedQueue(module, method, args);
  // Native → JavaScript 调用,场景为模块的异步回调
  public invokeCallbackAndReturnFlushedQueue(cbID, args);
}

本节中只展开 JS 调用 Native 的实现,Native 调用 JS 的实现会随模块系统章节展开。那么现在来实现 JS 调用 Native:JS 侧会先调用 MQ 的 enqueueNativeCall 方法,往内部 _queue 队列追加当前消息。注意 _queue 是一个二维数组,每行是一条消息,四列分别存储模块 id、方法 id、参数、回调 id 的数据。然后再调用 _flushQueue ,其中的核心是通过 C++ 注入的 nativeFlushQueueImmediate 方法来触发 C++ 方法回调,处理 JS 传入的队列数据。为了快速验证,C++ 的 nativeFlushQueueImmediate 方法的回调先简单实现为输出 JS 调用的消息信息,这个方法在下一节再完善其实现。

js 复制代码
class MessageQueue {
  enqueueNativeCall(moduleID, methodID, params, onFail, onSucc) {
    // 生成回调ID并注册回调函数
    let callbackID = null
    if (onFail || onSucc) {
      callbackID = this._callbackID++
      this._callbacks[callbackID] = {
        onFail: onFail,
        onSucc: onSucc,
      }
    }

    // 将调用添加到队列中
    this._queue[0].push(moduleID) // moduleIds
    this._queue[1].push(methodID) // methodIds
    this._queue[2].push(params || []) // params
    this._queue[3].push(callbackID) // callbackIds

    // 立即刷新队列(简化版实现,实际 RN 会做批量优化)
    this._flushQueue()
  }
 
  _flushQueue() {
    if (this._queue[0].length === 0) {
      return // 队列为空,无需刷新
    }
    const queue = this.flushedQueue()
    // 重要:调用 Native 注入的方法,会触发 C++ 的回调
    global.nativeFlushQueueImmediate(queue)
  }
}

实现了 MessageQueue.js 后,再参考 RN 的实现把 BatchedBridge.js 完成,这个文件的作用是在 JS 环境中把桥实例注册到 global 上的变量上供 C++ 调用并把桥实例导出供 JS 调用,精简代码如下:

js 复制代码
// BatchedBridge.js
// JSC 环境没有 require 的实现,这里为了便于理解原理先用 require 来演示代码
const MessageQueue = require('./MessageQueue.js')
const BatchedBridge = new MessageQueue()

// 注册到 global 上,供 C++ 调用
Object.defineProperty(global, '__fbBatchedBridge', {
  configurable: true,
  value: BatchedBridge,
})

module.exports = BatchedBridge;

完成了 JS 侧批量桥(本质是消息队列)的实现后,就可以新建一个测试文件 test_mq.cpp 来测试下 JS 调用 Native 的功能了,代码如下。经过一番调试,C++ 日志中成功输出了 JS 侧调用并输入的参数。

c++ 复制代码
// test_mq.cpp
int main() {
  JSCExecutor executor;

  // 由于现在还没接入打包工具
  // 需要先手动加载 BatchedBridge.js 到 JS 环境中
  std::cout << "Loading BatchedBridge.js..." << std::endl;
  std::string batchedBridgeJS = readFile("src/js/BatchedBridge.js");
  executor.loadApplicationScript(batchedBridgeJS, "BatchedBridge.js");

  // 运行测试
  std::string messageQueueTestScript = R"(
            // 没有 require 方法,为了方便理解先这样实现
            const BatchedBridge = require('BatchedBridge')
            BatchedBridge.enqueueNativeCall(
              2,
              1,
              ['test_param'],
              function (error) {
                // JSC 环境没提供 console
                // JS 侧封装 global.nativeLoggingHook 方法得到,过程省略
                console.log('Error callback executed with:', error)
              },
              function (result) {
                console.log('Success callback executed with:', result)
              },
            )
        )";

  executor.loadApplicationScript(messageQueueTestScript,
                                 "test_messagequeue.js");

  return 0;
}

总结一下,本节我们定义好了 JS 侧桥关键方法的接口并实现了其中 JS 调用 Native 的方法。有了消息队列,便有了 批量&异步 调用的可能。

Native 模块系统实现

上一节,我们实现了 JS 调用 Native,但是 Native 回调 JS 还没有打通。本节中,我们将借助模块系统来实现 Native 调用 JS 这条链路,由此我们还需要实现一个简单的 Native 模块供 JS 调用。我们以一个设备信息 DeviceInfo 模块为例进行展开,这个模块包含了同步和异步接口,JS 侧可调用的方法和使用示例如下:

typescript 复制代码
interface DeviceInfo {
  static getUniqueId: () => Promise<string>; // 获取设备 UUID(异步)
  static getSystemVersion: () => string; // 获取系统版本(同步)
}

import { DeviceInfo } from 'react-native'
async function foo() {
  const uniqueId = await DeviceInfo.getUniqueId()
  const systemVersion = DeviceInfo.getSystemVersion()
}

模块的实现与注册

设备模块需要读取 Native 侧的系统信息。当前阶段 Native 运行在 macOS 平台,因此我们需要使用 Objective-C++(OC++) 结合 Apple 提供的 macOS SDK 来访问系统和设备相关的底层能力。 由于 macOS 平台的原生开发主要使用 Objective-C ,而我们的核心逻辑是以 C++ 实现的,为了在两者之间进行无缝互调,我们采用 OC++ 作为桥接语言。

OC++ 语言的文件是 *.mm 后缀的,我们新建一个 macOS 平台专属的设备模块文件 DeviceInfoModule.mm 并实现所需要的两个关键方法,摘要代码如下。由于是 OC++ 实现且调用了 Apple 提供的 macOS SDK,前端工程师看不懂也没关系,只需要知道我们实现了 macOS 模块的相关方法就好。

objective-c 复制代码
// DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
  @autoreleasepool {
    io_registry_entry_t ioRegistryRoot =
        IORegistryEntryFromPath(kIOMasterPortDefault, "IOService:/");
    CFStringRef uuidCf = (CFStringRef)IORegistryEntryCreateCFProperty(
        ioRegistryRoot, CFSTR(kIOPlatformUUIDKey), kCFAllocatorDefault, 0);
    IOObjectRelease(ioRegistryRoot);

    NSString* uuid = (__bridge NSString*)uuidCf;
    std::string result = [uuid UTF8String];
    CFRelease(uuidCf);
    return result;
  }
}

std::string DeviceInfoModule::getSystemVersionImpl() const {
  @autoreleasepool {
    NSProcessInfo* processInfo = [NSProcessInfo processInfo];
    NSOperatingSystemVersion version = [processInfo operatingSystemVersion];

    std::ostringstream oss;
    oss << version.majorVersion << "." << version.minorVersion << "." << version.patchVersion;

    return oss.str();
  }
}

macOS 平台的设备信息模块及其方法实现后,JS 就需要想方设法调用它,那么怎么做呢?本节开始时,我们给到了使用 DeviceInfo.js 模块上方法的 JS 片段,不难看出 JS 侧调用的方法其实和 Native 侧最终实现的方法其实是对应上的,现在的问题就在于 JS 和 Native 两侧的模块和方法如何建立对应关系。

在上一章节中,MQ 的接口中有一个方法 public enqueueNativeCall(moduleID, methodID, params, onFail, onSucc); 是专门用于 JS 异步调用 Native 的,可以看到 JS 调用时需要告诉 Native 自己要调用哪个模块(moduleID)的哪个方法(methodID)。而这些 id 是 int 类型的而非 string,JS 显然无法预设 Native 模块的模块名和方法的对应 id 是多少,需要 Native 侧把模块们的信息告诉 JS,JS 完成注册,方可被使用。这个过程便是模块的注册

用下面的数据转换图来说明下模块注册的流程(为了便于理解,图内的代码使用类似 JS 的伪代码)。首先 Native 侧存储了模块及其方法的信息(包含了模块名、模块 id、方法名、方法 id 信息),两个数据格式均为 Array<string>。然后再把模块信息进行数据组装,格式为 Array<[ModuleName, Constants, Methods, PromiseMethods, SyncMethods]> 二位数组,其中数组的下标为模块 id,数组内单行为模块的信息,单号元素的信息依次为模块名、常量、方法名、promise 方法下标、同步方法下标。得到了该信息后,再将数组注入到 JS 环境的全局变量 global.__fbBatchedBridgeConfig.remoteModuleConfig 上。这时 JS 就可以通过遍历模块配置,动态生成模块方法的调用了。

这样就完成了 Native 方法到 JS 调用的信息对齐,其关键在于定义好配置数据格式,让 Native 可以方便地组装配置数据,让 JS 可以方便地解析配置数据。

JS ↔ Native 双向调用流程

常见的模块调用方法类型包含同步异步 的(DeviceInfo 模块刚好两种都有,便于演示和学习)。开篇"桥架构通信的本质"这章节中其实已经稍微讲解了同步调用是如何运作的,例子是 Dimensions.get('window'),可以再来用 DeviceInfo 模块进行展开并温习下。以 JS 侧调用 DeviceInfo.getSystemVersion() 为例,在调用方法前 Native 已经提前给 JS 环境注册好了模块信息和 global.nativeCallSyncHook(moduleId, methodId, args) 方法,在调用方法时的调用 global.nativeCallSyncHook(0, 1, null)。然后同步等待 C++ 匹配具体模块的方法并调用并返回数据。整个过程是同步的,不经过桥(桥的消息队列机制主要用于异步通信)。同步调用直接通过 JSC API 在当前线程完成,避免了跨线程通信的开销,但也意味着会阻塞 JS 线程直到 Native 返回结果,因此同步方法应当尽量避免耗时操作。

同步调用的时序图如下。

再贴一个 C++ 同步处理并返回结果的简化代码如下(模块注册器如何分发的实现略)。

c++ 复制代码
// JSCExector.cpp
JSValueRef JSCExecutor::nativeCallSyncHook(JSValueRef moduleID,
                                           JSValueRef methodID,
                                           JSValueRef args) {
  // 将 JSValue 参数转换为 C++ 类型
  double moduleIdDouble = JSValueToNumber(m_context, moduleID, nullptr);
  double methodIdDouble = JSValueToNumber(m_context, methodID, nullptr);
  unsigned int moduleIdInt = static_cast<unsigned int>(moduleIdDouble);
  unsigned int methodIdInt = static_cast<unsigned int>(methodIdDouble);
  // 将参数转换为 JSON 字符串
  std::string argsJson = jsValueToJSONString(args);
	// 模块注册器进行分发并调用具体方法,同步返回结果
  std::string result = m_moduleRegistry->callSerializableNativeHook(
      moduleIdInt, methodIdInt, argsJson);
	// C++ 类型数据转 JS 类型
  return stringToJSValue(result);
}

另一种是异步调用,比同步调用要稍微复杂些,可以用 await DeviceInfo.getUniqueId() 为例。调用该方法后会先走到 MessageQueue.jsenqueueNativeCall 方法(在"JS 侧桥核心方法实现"有展开讲这个方法的实现),前面的铺垫就是为了这里能够串联 JS 到 Native 的调用。再提一嘴,enqueueNativeCall 的实现实际是简化的,当前的实现是把消息推入队列后直接 flush 没有接入 Timer 做异步批处理,导致每次调用 enqueueNativeCall 事实上都只会给 Native 侧发一条消息,性能一般般。真实的 RN 实现中会引入 Timer 将多个调用累积到一帧内批量发送,从而减少跨线程通信次数。当前这么做是为了便于理解,不引入太多其他内容。

然后 JS 侧最终会调用到 global.nativeFlushQueueImmediate(queue) 方法,参数和同步调用的差别是多了 callbackID 参数,这个新增的参数是连接 Native 到 JS 异步回调的桥梁。每次异步调用时,MessageQueue 都会生成一个唯一的递增 callbackID,并将对应的成功/失败回调函数存储在 _callbacks map 中。当 Native 异步返回结果后,会携带这个 callbackID 回调 JS,JS 侧再通过 callbackID 查找并执行对应的回调函数,执行完成后会从 map 中删除该回调,避免内存泄漏。

接着我们来到 Native 侧,先看看处理调用的方法,代码如下。其实和同步调用处理的思路是类似的,都是用模块 id、方法名 id 定位到具体方法并调用。关键的不同的是结果没有同步立即的返回。

c++ 复制代码
// JSCExector.cpp
void JSCExecutor::nativeFlushQueueImmediate(JSValueRef queue) {
  // Step 1: JSValue -> JSON字符串 (对齐RN: queue.toJSONString())
  std::string queueStr = jsValueToJSONString(queue);

  // Step 2: JSON字符串 -> BridgeMessage (替代 folly::parseJson)
  std::cout << "[JSCExecutor] Parsing JSON with SimpleBridgeJSONParser..."
            << std::endl;
  mini_rn::bridge::BridgeMessage message =
      mini_rn::utils::SimpleBridgeJSONParser::parseBridgeQueue(queueStr);

  // Step 3: 处理消息
  for (size_t i = 0; i < message.getCallCount(); i++) {
    unsigned int moduleId = static_cast<unsigned int>(message.moduleIds[i]);
    unsigned int methodId = static_cast<unsigned int>(message.methodIds[i]);
    const std::string &params = message.params[i];
    int callId = message.callbackIds[i];

    // 通过 ModuleRegistry 调用 Native 模块方法
    m_moduleRegistry->callNativeMethod(moduleId, methodId, params, callId);
  }
}

由于调用 C++ 后数据不是同步返回的,所以得来看看异步回调 是怎么实现的,代码如下。核心还是通过 JSC 的 API 获取到 JS 环境上全局变量上的回调方法,即 global.__fbBatchedBridge.invokeCallbackAndReturnFlushedQueue,然后组装参数:把 C++ 类型的参数转为 JS 类型的,最后调用并传参。

c++ 复制代码
// JSCExector.cpp
void JSCExecutor::invokeCallback(int callId, const std::string &result,
                                 bool isError) {
  // 实现将结果返回给 JavaScript 的机制
  // 调用 JavaScript 的 invokeCallbackAndReturnFlushedQueue 方法

  // 获取全局 __fbBatchedBridge 对象
  JSObjectRef bridgeObject = JSValueToObject(
      m_context,
      JSObjectGetProperty(m_context, m_globalObject,
                          JSStringCreateWithUTF8CString("__fbBatchedBridge"),
                          nullptr),
      nullptr);

  // 获取 invokeCallbackAndReturnFlushedQueue 方法
  JSValueRef methodValue = JSObjectGetProperty(
      m_context, bridgeObject,
      JSStringCreateWithUTF8CString("invokeCallbackAndReturnFlushedQueue"),
      nullptr);

  // 准备回调参数
  // React Native 回调约定:第一个参数是错误,后续参数是结果
  // 错误情况:[error]
  // 成功情况:[null, result]
  std::string argsJson =
      isError ? "[\"" + result + "\"]" : "[null, " + result + "]";

  // 创建参数数组
  JSValueRef arguments[2];
  arguments[0] = JSValueMakeNumber(m_context, callId);  // callbackID
  // 解析 JSON 参数
  arguments[1] = JSValueMakeFromJSONString(
      m_context, JSStringCreateWithUTF8CString(argsJson.c_str()));  // args

  // 调用 JavaScript 回调方法
  JSValueRef exception = nullptr;
  JSValueRef callResult =
      JSObjectCallAsFunction(m_context, (JSObjectRef)methodValue,
                             bridgeObject,  // thisObject
                             2,             // argumentCount
                             arguments,     // arguments
                             &exception);
}

异步调用的时序图展示了更复杂的流程:

  1. JS 调用后立即返回 Promise,不阻塞线程
  2. 消息经过 MessageQueue 批量发送(本实现简化为立即发送)
  3. Native 处理完成后,通过 invokeCallbackAndReturnFlushedQueue 回调 JS
  4. JS 根据 callbackID 找到对应的 Promise resolve/reject 函数并执行

再贴一张异步调用的时序图如下。

彩蛋

经过了一路旅途,很高兴你可以看到最后!🎉 文章篇幅有限,代码也只是贴了相关片段,如果想要了解完整的实现,可以详参项目源码。

项目地址 : github.com/zerosrat/mi...

当前项目中包含了本篇文章中的全部内容:

  • ✅ C++ 集成 JavaScriptCore 引擎的完整实现
  • ✅ JS 侧消息队列 (MessageQueue) 的实现
  • ✅ Native 模块系统和注册机制
  • ✅ 同步/异步双向调用的完整流程
  • ✅ DeviceInfo 示例模块 (macOS 平台)
  • 📝 详细的注释和构建说明

【后续计划】当前项目的进展到只是其开始阶段,接下来我计划继续完善:

  1. 多端支持: 当前只支持 macOS 平台,后续将适配 iOS 和 Android 平台
  2. 渲染系统: 当前只支持逻辑通信,后续将接入渲染层,能够渲染简单的组件
  3. 新架构探索: 当前只支持桥架构,后续将研究 JSI、TurboModules 等新架构特性

项目从开始到完成这篇博客前前后后花了一个月左右的时间,都是用打工后和周末的业余时间完成的,来之不易~希望大家可以多多给项目点赞支持 star ⭐。有其他的想法也可以多多交流喔。

参考

  1. React Native在美团外卖客户端的实践
  2. github.com/facebook/re...

📝 本文首发于个人博客: zerosrat.dev

相关推荐
zhenryx15 小时前
React Native 自定义 ScrollView 滚动条:开箱即用的 IndicatorScrollView(附源码示例)
javascript·react native·react.js·typescript
GISer_Jing15 小时前
2025年Flutter与React Native对比
android·flutter·react native
Java追光着2 天前
React Native 自建 JS Bundle OTA 更新系统:从零到一的完整实现与踩坑记录
javascript·react native·react.js
努力往上爬de蜗牛2 天前
react native 运行问题和调试 --持续更新
javascript·react native·react.js
一头小鹿2 天前
【React Native】如何在开发中使用Appwrite
react native
GISer_Jing2 天前
跨平台Hybrid App开发实战指南
android·flutter·react native
努力学前端Hang3 天前
移动端跨平台开发深度解析:UniApp、Taro、Flutter 与 React Native 对比
前端·javascript·react native·react.js
浪遏4 天前
好久不见 ,甚是想念 | vibe coding 一个react native 全栈项目| 小账兜
react native·全栈·vibecoding
GISer_Jing5 天前
跨端框架对决:React Native vs Flutter深度对比
flutter·react native·react.js