从零实现 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

相关推荐
wszy180916 小时前
顶部标题栏的设计与实现:让用户知道自己在哪
java·python·react native·harmonyos
wincheshe1 天前
React Native inspector 点击组件跳转编辑器技术详解
react native·react.js·编辑器
墨狂之逸才2 天前
React Native Hooks 快速参考卡
react native
墨狂之逸才2 天前
useRefreshTrigger触发器模式工作流程图解
react native
墨狂之逸才2 天前
react native项目中使用React Hook 高级模式
react native
wayne2142 天前
React Native 状态管理方案全梳理:Redux、Zustand、React Query 如何选
javascript·react native·react.js
Mintopia3 天前
🎙️ React Native(RN)语音输入场景全解析
android·react native·aigc
程序员Agions3 天前
React Native 邪修秘籍:在崩溃边缘疯狂试探的艺术
react native·react.js
chao_6666664 天前
React Native + Expo 开发指南:编译、调试、构建全解析
javascript·react native·react.js
_pengliang4 天前
react native ios 2个modal第二个不显示
javascript·react native·react.js