第一部分:如何让 React Native 里的纯 JSI 代码更快
- 原文链接:blog.margelo.com/make-jsi-ru…
- 原文作者:Alex Shumihin
写 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的虚调用分发 PropNameID到std::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 可以开箱提供其中很多优化。
术语表(本篇命中)
| 术语 | 英文 | 释义 |
|---|---|---|
| JSI | JavaScript Interface | React Native 中 JS 与原生交互的接口层。 |
| HostFunction | HostFunction | 以函数形式暴露给 JS 的原生实现入口。 |
| HostObject | HostObject | 通过对象属性访问触发原生逻辑的 JSI 宿主对象。 |
| NativeState | NativeState | 挂载在 JS 对象上的原生状态载体,可直接从 thisVal 访问。 |
| SSO | Small String Optimization | 小字符串优化,将短字符串内联存储在 std::string 对象中。 |