【翻译】第一部分:如何让 React Native 里的纯 JSI 代码更快

1 阅读10分钟

第一部分:如何让 React Native 里的纯 JSI 代码更快

文章头图

写 JSI 代码本身已经很快了,但一些小的架构选择,依然可能让它慢 2 到 10 倍,或者快 2 到 10 倍。

如果你在用纯 JSI 构建模块,了解哪些模式能让代码尽可能高效,会很有帮助。

这在模块处于热路径时尤其重要。

在深入前,请记住一点:JSI 很快,但它并不是零成本。即使你的原生实现已经很快,总成本依然可能被以下因素主导:

  • JS ↔ C++ 边界穿越
  • 属性访问解析
  • 虚调用分发
  • 字符串转换与比较
  • 堆分配

所以在实践里,最大的收益通常先来自正确的架构选择,然后才是微优化。


HostFunction vs HostObject:避开隐藏的属性访问开销

在写模块时,达到同样目标往往有多种方式。

例如,我们想创建一个执行某些逻辑的 JSI 函数。一种方式是创建 HostObject,然后在 JS 里像这样调用:MyModule.process(...)

class MyModuleHostObject: public HostObject {
public:
  Value get(Runtime &rt, const PropNameID &prop_name) override {
    const std::string name = prop_name.utf8(rt);

    if (name == "process") {
      return Function::createFromHostFunction(..., [](...) -> Value {
        // 做一些事情
      });
    }
    ...
  }
};

但这种方式并不是性能最优,因为会产生几类额外开销:

  • 通过 HostObject::get 的虚调用分发
  • PropNameIDstd::string 的转换
  • 属性名的字符串比较
  • 并且在很多实现里,会在属性访问时创建新的 HostFunction

一个很容易忽略的细节是:

HostObject::get() 会在每一次属性访问时执行,不是只在初始化时执行一次。

实际上,每次调用更像这样:

JS -> 属性访问 -> HostObject::get()
                -> 虚调用
                -> PropNameID -> utf8 字符串
                -> 字符串比较
                -> 创建 HostFunction

我们可以用更低开销暴露同样的 API:

Function process = Function::createFromHostFunction(..., [](...) -> Value {
  // 做一些事情
});

rt.global().setProperty(rt, "process", std::move(process));

从 JavaScript 侧看,这两种写法几乎一样:做的是同样的有效工作,也返回同样结果:

global.MyModule.process(...)
global.process(...)

但在内部,它们行为差别很大,直接暴露 HostFunction 的版本避免了每次调用时的属性解析开销。

如果做一个循环调用的基准(函数内部不做事,只返回一个数字),差异会很明显。

基准(1,000,000 次迭代)

HostObject: 181.09ms
HostFunction: 26.87ms

结果很清楚:简单的 HostFunction 调用,比通过 HostObject 暴露的同等函数大约快 5 倍。在一些场景里,这种差异非常可观。

另一个重要细节是:即使你继续使用 HostObject,属性分发本身也还能优化。

对于稳定的属性集合,可以缓存并复用 jsi::PropNameID,而不是每次访问都把属性名转为 UTF-8 字符串再做原始字符串比较。

这不能完全消除 HostObject 开销,但可以降低其中一部分查找成本。

NitroModules 也会默认做这件事:它会缓存重复比较用到的 jsi::PropNameID。当很多原生对象共享同一对象形状时,这尤其有用。

用哈希查找加速属性分发

如果属性集合是固定的,另一种优化是把长链路字符串比较替换为生成式或哈希分发。这样可以减少分支和重复字符串处理。

Nitro Modules 也默认应用了这种优化(通过生成的分发逻辑),因此用户无需手写查找代码就能受益。

来自 Nitro Modules 的示例:

switch (hashString(unionValue.c_str(), unionValue.size())) {
  case hashString("electric"): return margelo::nitro::test::Powertrain::ELECTRIC;
  case hashString("gas"): return margelo::nitro::test::Powertrain::GAS;
  case hashString("hybrid"): return margelo::nitro::test::Powertrain::HYBRID;
  default: [[unlikely]]
    throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum Powertrain - invalid value!");
}

NativeState vs HostObject:有状态原生对象的更快方案

如果我们需要跨调用保存原生状态呢?

这时可以用 jsi::HostObject,但还有一个很强的选择:jsi::NativeState

看一个简单例子:对象在调用间会修改原生状态(计数器),并把新值返回给 JS。

如果用 HostObject,代码可能像这样:

class HObject: public HostObject {
public:
  int counter = 10;

  Value get(Runtime &rt, const PropNameID &prop_name) override {
    const std::string name = prop_name.utf8(rt);

    if (name == "increment") {
      return Function::createFromHostFunction(..., [](...) -> Value {
        counter++;
        return counter;
      });
    }
    ...
  }
};

...

auto hostObj = Object::createFromHostObject(rt, std::make_shared<HObject>());

rt.global().setProperty(rt, "MyHostObject", std::move(hostObj));

我们也能用 jsi::NativeState 实现同样效果,而且性能显著更好:

class MyNativeState: public NativeState {
public:
  int counter = 10;
};

...

Object jsObject(rt);

jsObject.setNativeState(rt, std::make_shared<MyNativeState>());

jsObject.setProperty(rt, "increment", Function::createFromHostFunction(
  rt, PropNameID::forAscii(rt, "increment"), 0,
  [](Runtime &rt, const Value &thisVal, const Value *args, size_t count) {
    auto nativeState = thisVal.asObject(rt).getNativeState<CustomNativeState>(rt);
    nativeState->counter++;
    return nativeState->counter;
  }));

rt.global().setProperty(rt, "MyObjectNativeState", std::move(jsObject));

这个例子里,我们创建一个普通 JS 对象,把 NativeState 挂上去,再给对象加一个属性(它本质是简单的 HostFunction),并在函数内部从 thisVal 取回 NativeState

之后同样把对象挂到运行时里的目标名字下。

在 JS 侧用法如下:

MyHostObject.increment()
MyObjectNativeState.increment()

从 JavaScript 看 API 还是一样。再看基准:

基准(1,000,000 次迭代)

HostObject: 188.20ms
NativeState: 38.19ms

基准显示 NativeState 同样大约快 5 倍

Nitro 说明:

如果你在使用 Nitro Modules,这类优化默认已经内建在对象模型中。

Nitro HybridObjects 底层基于 jsi::NativeState,而不是 jsi::HostObject,因此这类性能收益大多开箱可得。

为什么 NativeState 更快

这里没有魔法。核心原因很直接:NativeState 去掉了一整层动态属性解析开销:

  • 不再经过 HostObject::get 的虚调用分发
  • 不再有 PropNameID -> std::string 的属性名查找
  • 不再做字符串比较
  • 通过 thisVal.asObject(rt).getNativeState(...) 直接拿到原生指针

所以,如果你需要一个带原生内部状态的 JSI 对象并追求极致性能,Object + NativeState 往往比 HostObject 更值得考虑。

什么时候 NativeState 更合适

这个模式在以下场景尤其有吸引力:

  • 方法集合事先已知
  • 方法名稳定
  • 状态天然驻留在原生侧
  • 你想保留对象式 JS API,但不想承担每次访问 HostObject 的成本

换句话说,如果你并不需要动态属性拦截语义,NativeState 通常是更好的匹配。


栈分配 vs 堆分配

如果字符串大小已知,或者至少上限已知(例如 < 512),并且你需要在执行时构造这个字符串,那就优先考虑在栈上工作。

先看一个简单例子。

假设我们要根据输入数据构造一个字符串。为简化起见,这里只填充字符 'A'

std::string buf;
buf.resize(256);

for(int i =0; i < 256; ++i){
  buf[i] = 'A';
}

return String::createFromAscii(rt, buf);

接着,把 std::string 换成 char 数组:

char buf[256];

for(int i =0; i < 256; ++i){
  buf[i] = 'A';
}

return String::createFromAscii(rt, buf, 256);

逻辑一样,但性能表现不同。

基准(1,000,000 次迭代)

std::string: 146.70ms
char buf[256]: 46.64ms

结果显示 char buf[256] 大约快 3 倍

为什么栈分配更快

主要原因通常是:

  • 没有堆分配
  • 没有分配器开销
  • 对短生命周期数据有更好的局部性
  • 更少的间接访问

栈缓冲区只是编译期已知固定大小的局部内存。相对地,std::string 一旦超过其小缓冲区,通常就需要堆分配。

关于 std::string 的一个重要细节

有个细节值得说明:

std::string 并不总是在堆上分配。多数标准库实现都会用 SSO(Small String Optimization,小字符串优化),让短字符串直接存放在 std::string 对象内部。

但该优化只对短字符串有效,常见阈值大约 15–23 字节(随实现和平台而变)。本例是 256 字节,远超 SSO 范围,通常会触发堆分配。

当数据是 ASCII 时,使用 createFromAscii(...)

如果字符串只包含 ASCII 字符,jsi::String::createFromAscii(...) 通常比 createFromUtf8(...) 更合适。

ASCII 是 UTF-8 的真子集,但 ASCII 专用构造器表达了更强的不变量,并且可能避免一部分 UTF-8 处理开销。所以当你的原生代码已经知道数据是 ASCII-only 时,值得显式表达这一点。

在这个例子里:

return String::createFromAscii(rt, buf, 256);

缓冲区不需要以空字符结尾,因为长度是显式给出的。这点很重要:长度已知时,直接传长度可避免扫描 '\\0'

因此,如果尺寸已知且结果字符串只含 ASCII,这种写法通常更优。

当数据本来就是 UTF-16 时,使用 createFromUtf16(...)

如果你的原生代码已经持有 UTF-16 字符串,String::createFromUtf16(...) 也可能是更好的选择。

此时你可能避免先转成 UTF-8,再让 JS 运行时重复解码。实践里,最佳构造器通常就是“与当前内存表示一致”的那个:

  • ASCII-only 数据 -> createFromAscii(...)
  • UTF-8 数据 -> createFromUtf8(...)
  • UTF-16 数据 -> createFromUtf16(...)

一个更小但依然有用的优化

再看一个小优化示例。

假设我们有个函数,会做一些工作并创建一个新字符串:

std::string hexdigest(){
  static const char hex[] = "0123456789abcdef";

  char buf[33];

  for (int i = 0; i < 16; i++) {
    buf[i * 2]     = hex[(i >> 4) & 0xF];
    buf[i * 2 + 1] = hex[i & 0xF];
  }

  buf[32]=0;
  return std::string(buf);
}

...

std::string res =  hexdigest();

return String::createFromAscii(rt, res);

尝试一个小优化后,结果如下:

void hexdigest_char(char* buf){
  static const char hex[] = "0123456789abcdef";

  for (int i = 0; i < 16; i++) {
    buf[i * 2]     = hex[(i >> 4) & 0xF];
    buf[i * 2 + 1] = hex[i & 0xF];
  }
  buf[32]=0;
}
...

char buf[33];

hexdigest_char(buf);

return String::createFromAscii(rt, buf, 32);

基准(1,000,000 次迭代)

hexdigest: 60.36ms
hexdigest_char: 45.95ms

这里仍有可测的提升:约 1.3 倍。

底层发生了什么变化

优化版避免了:

  • 创建临时 std::string
  • 把局部缓冲区数据复制进字符串
  • 字符串离开局部作用域后额外的堆相关开销

这不像 HostFunction vs HostObject 那样属于架构级优化,但在热路径里依然值得做。


减少 JS ↔ C++ 边界穿越次数

即使原生实现已经优化得很好,JS 与 C++ 之间过于频繁的往返调用,依然可能主导总耗时。

每次调用通常都包含:

  • 进入原生运行时
  • 读取并校验参数
  • 在 JS 与 C++ 表示之间转换值
  • 把返回值再包装回 JS

所以在实践中,很多小调用通常不如少量大调用,即使每次调用都很快。

这意味着在做完底层优化后,下一步更值得看的往往是 API 形状:

  • 能否把多次调用合并成一次?
  • 能否做批处理?
  • 能否避免中间态 JS 可见对象?

这并不是 JSI 独有技巧,而是现实系统里的主要成本来源之一。


结语:优化真正重要的点

JSI 已经很快,但要把性能拉满,关键仍是架构决策和内存策略。

最大的收益通常来自这些简单选择:

  • 无状态场景优先用 HostFunction 而非 HostObject
  • 需要原生状态时优先 Object + NativeState 而非 HostObject
  • 大小已知时优先栈缓冲区
  • 避免不必要的临时分配
  • 在热路径里减少 JS ↔ C++ 穿越

如果你的代码只偶尔运行,这些优化可能不重要。

但如果代码位于热路径,这些小决策会带来非常明显的差异。

如果你不想手工处理这么多底层 JSI 优化,Nitro Modules 可以开箱提供其中很多优化。

术语表(本篇命中)

术语英文释义
JSIJavaScript InterfaceReact Native 中 JS 与原生交互的接口层。
HostFunctionHostFunction以函数形式暴露给 JS 的原生实现入口。
HostObjectHostObject通过对象属性访问触发原生逻辑的 JSI 宿主对象。
NativeStateNativeState挂载在 JS 对象上的原生状态载体,可直接从 thisVal 访问。
SSOSmall String Optimization小字符串优化,将短字符串内联存储在 std::string 对象中。