【翻译】React Native JSI 深度解析(第 3 篇):面向 JavaScript 开发者的 C++
React Native JSI 深度解析(第 3 篇):面向 JavaScript 开发者的 C++
- 原文链接:heartit.tech/react-nativ…
- 原文作者:Rahul Garg
“抽象的目的不是含糊其辞,而是创建一个新的语义层,在这个层里你可以做到绝对精确。”
— Edsger W. Dijkstra,The Humble Programmer,1972
导读: 你不需要学完整门 C++ 才能写 JSI 原生模块。你只需要掌握 5 个概念:栈与堆、引用与指针、RAII、智能指针、Lambda。本文只讲这部分,而且会用你已经熟悉的 JavaScript 语境来解释。读完后,你看 C++ 会像看 TypeScript 一样:不必认识每个关键字,但能读懂每个意图。
系列:React Native JSI 深度解析(12 篇)
第 1 篇:React Native 架构——线程、Hermes 与事件循环 | 第 2 篇:React Native Bridge 与 JSI——到底变了什么 | 第 3 篇:面向 JavaScript 开发者的 C++(你在这里) | 第 4 篇:你的第一个 React Native JSI 函数 | 第 5 篇:HostObjects——把 C++ 类暴露给 JavaScript | 第 6 篇:内存所有权 | 第 7 篇:平台接线 | 第 8 篇:线程与异步 | 第 9 篇:实时音频管线 | 第 10 篇:存储引擎 | 第 11 篇:TurboModules vs Pure JSI vs Pure C++ | 第 12 篇:生产调试与陷阱
问题:C++ 看起来像“痛苦制造语言”
如果你一直写 JavaScript 或 TypeScript,第一次看到 C++ 版 JSI 函数可能是这样:
一个 JSI 函数长什么样:
static jsi::Value multiply(
jsi::Runtime& rt,
const jsi::Value& thisVal,
const jsi::Value* args,
size_t count) {
double a = args[0].asNumber();
double b = args[1].asNumber();
return jsi::Value(a * b);
}
你看到 &、*、const、size_t,第一反应可能是:我得再学一门语言。
但再看一眼,去掉符号后,它就是“接收两个数并返回乘积”的函数。& 和 * 本质只是在回答一件事:数据归谁管,存在哪。
这就是核心思维转换。JavaScript 用 GC 把内存管理细节藏起来;C++ 要你显式声明。除此之外,类、循环、分支、字符串这些,整体都和你预期差不多。
本文只讲你在 JSI 模块中一定会遇到的 5 个 C++ 概念:不讲模板套模板,不讲运算符重载,不讲多重继承。
概念 1:栈 vs 堆(数据住在哪里)
在 JavaScript 里你几乎不会想变量住在哪。你写 const x = 42,引擎会处理后面的事。
在 C++ 中,数据主要在两个地方:栈(stack) 或 堆(heap),而且由你决定。(JSI 场景下,理解栈和堆就够用。)
栈(Stack)
栈内存快、自动管理。函数运行时,本地变量在栈上;函数返回时自动销毁,不需要手动清理。
栈上分配:自动生命周期
void greet() {
int count = 42; // 在栈上
std::string name = "JSI"; // 变量在栈上(字符串内部内容可能在堆上)
// 使用 count 和 name...
} // ← 这里自动销毁
JavaScript 类比是函数内部 let 的生命周期:它在函数执行期间存在,之后变为可被垃圾回收。
但关键差异是:C++ 栈对象销毁是立即且确定的。它不会等“将来某次 GC 轮到它”,而是在右花括号处就发生。每一次都如此,且有语言层面的保证。
堆(Heap)
堆用于“需要活过创建它的函数”的数据。JavaScript 的对象、数组、闭包基本都可视为堆上数据,由 GC 回收。
C++ 里你可以用 new 在堆上分配,并用 delete 手动释放:
堆上分配:手动生命周期 ⚠️
void createBuffer() {
int* data = new int[1024]; // 在堆上分配
// 使用 data...
delete[] data; // 必须手动释放
} // 忘记 delete[] 就泄漏
思考: 如果
new后、delete前抛异常,会怎样?delete不会执行,直接泄漏。
这就是手动内存管理的根本问题,也是现代 C++ 几乎不用裸new/delete的原因。后面会用 RAII 解决。
心智模型:
┌─────────────────────────────────────────────────────────┐
│ STACK │
│ 快,自动,固定大小;函数返回即销毁。 │
│ C++ 常见用途:局部变量、函数参数。 │
├─────────────────────────────────────────────────────────┤
│ HEAP │
│ 相对慢,动态;手动或由智能指针管理。 │
│ 不释放就一直活着(或泄漏)。 │
│ C++ 常见用途:需要跨函数生命周期的数据。 │
└─────────────────────────────────────────────────────────┘
图 1:栈 vs 堆。JavaScript 把这层差异隐藏在垃圾回收器之后;C++ 要求你显式做出选择。
JSI 模块里,你通常会大量使用栈对象 + 智能指针。写得好的现代 C++ 里,裸 new/delete 很少出现。
概念 2:引用与指针(数据别名)
在 JavaScript 里,把对象传进函数时,函数拿到的是引用的副本:它可以修改对象的属性,但给参数重新赋值不会影响调用方变量。(从技术上说这叫 “pass-by-sharing”,并不是 C++ 语境里的真正“按引用传递”)
但在“修改对象内容”这个场景里,它的体感确实很像按引用传递:
JavaScript:对象变更对调用方可见
function addItem(list) {
list.push("new item");
}
const myList = ["a", "b"];
addItem(myList);
console.log(myList); // ['a', 'b', 'new item']
C++ 会显式让你选:按值(copy)、按引用(alias)、按指针(地址)传递。这就是 & 和 * 的意义。
按值传递(Copy)
按值传递:产生副本
void process(std::string text) { // 拷贝
text += " modified"; // 只改副本
}
std::string original = "hello";
process(original);
// original 仍是 "hello"
这和 JavaScript 里原始值的传递行为很像:let x = 5; foo(x); 传的是副本。
按引用传递(&)
按引用传递:原数据别名
void process(std::string& text) { // 引用
text += " modified"; // 改原值
}
类型后面的 & 表示“这不是副本,而是同一份数据的另一个名字”。最接近的 JavaScript 类比是把对象传进函数:函数能改对象属性,因为它拿到的是同一份数据的引用。
但 C++ 引用还更进一步:如果你在函数里给引用参数重新赋值(例如 text = "new value"),会直接改调用方变量本体;而 JavaScript 里在函数内部 param = newValue 不会影响调用方。
常量引用(const &)
常量引用:只读别名
void print(const std::string& text) {
std::cout << text; // 可读
// text += " nope"; // 编译错误
}
这是 JSI 代码里最常见的模式之一:当函数接收的数据只需要读取、不需要修改时,就会使用 const &。这样既能避免拷贝开销,又能防止意外修改。
关键理解: 在 JSI 函数签名里看到
const jsi::Value&时,可以读作:“我在本次调用期间借用这个值;我不会修改它,也不会在返回后持有它。”const是对编译器的承诺,也是对代码阅读者的承诺。
指针(*)
指针保存的是内存地址。它比引用更底层——引用通常可由指针实现,但语义更安全(不能是空、不能改绑到别处)。
指针:地址操作语义
int value = 42;
int* ptr = &value;
std::cout << *ptr; // 42
你会在 JSI 函数签名中看到指针:
jsi::Value myFunction(
jsi::Runtime& rt,
const jsi::Value& thisVal,
const jsi::Value* args,
size_t count
) {
double x = args[0].asNumber();
// 其他处理...
}
args 参数是数组首元素的指针。args[0] 是第一个参数,args[1] 是第二个参数。count 参数告诉你一共有多少个参数。
这是 C 风格数组传参:没有 .length 属性,所以长度需要单独传入。
速查表:
| 符号 | 含义 | JS 类比 |
|---|---|---|
Type x |
按值(拷贝) | 原始值传参 |
Type& x |
引用(别名) | 对象可变更的效果类比 |
const Type& x |
只读引用 | 只读借用 |
Type* x |
指针(地址) | 无直接等价 |
&x |
取地址 | 无直接等价 |
*x |
解引用 | 无直接等价 |
图 2:C++ 参数传递符号。& 一符两义:在类型声明中表示“引用”,在表达式中表示“取地址”。
易错点(Gotcha):
&符号会随上下文变化而有两种完全不同的含义。
在类型声明里(如std::string& text),它表示“引用到(reference to)”;
在表达式里(如int* ptr = &value),它表示“取地址(address of)”。
这几乎会绊住每个刚学 C++ 的 JavaScript 开发者。看到&时,先判断它是挨着类型,还是挨着变量名。
概念 3:RAII(销毁即清理)
RAII(Resource Acquisition Is Initialization)是 JSI 开发中最重要的 C++ 概念。这个名字可能是计算机科学里最“劝退”的命名之一,但它背后的思想其实很简单。
在 JavaScript 中,你通常要手写清理代码:
JavaScript 常见手动清理:
function readFile(path) {
const handle = openFile(path);
try {
return handle.read();
} finally {
handle.close();
}
}
如果你忘了写 finally,文件句柄就泄漏了;如果在 close() 前抛异常且不在 try 覆盖范围里,也会泄漏。这种写法很脆弱。
在 C++ 里,RAII 的含义是:构造函数负责获取资源,析构函数负责释放资源。
由于对象离开作用域时(包括栈展开过程)析构函数会自动运行,所以清理是有保证的——即使抛出异常也一样。
C++:RAII 让清理自动发生
class FileHandle {
FILE* file_;
public:
FileHandle(const char* path) : file_(fopen(path, "r")) {
if (!file_) throw std::runtime_error("Failed to open file");
}
~FileHandle() { fclose(file_); }
};
std::string readFile(const char* path) {
FileHandle handle(path);
auto content = handle.read();
return content;
} // 自动调用析构,保证 close
~FileHandle() 是析构函数:对象销毁时自动运行。对栈对象来说,通常是离开作用域(遇到 })时;对堆对象来说,是 delete 调用时(或智能指针判断到该释放时)。
关键洞察: RAII 关注的并不只是文件,而是任何资源——内存、网络连接、锁、GPU 缓冲区、音频会话。这个模式始终一致:在构造函数中获取,在析构函数中释放,并让作用域来决定生命周期。
在 JSI 模块中,HostObject 会用 RAII 管理它的 C++ 状态:当 JavaScript 的垃圾回收器回收 HostObject 时,C++ 析构函数会运行,并清理对应的原生资源。
心智模型:
JavaScript: C++ (RAII):
const x = acquire(); {
try { Resource x(...); // 获取
use(x); use(x);
} finally { } // ← 析构自动释放
release(x); // 即使异常也执行
}
图 3:RAII 消除了手写清理。右花括号本身就是 finally。
RAII 之所以对 JSI 特别重要,是因为原生模块会管理很多 JavaScript 垃圾回收器并不了解的资源——比如音频缓冲区、文件句柄、数据库连接、原生线程池。RAII 能保证这些资源以“确定性”的方式被清理,而不是等“GC 哪天有空再处理”。
概念 4:智能指针(堆内存自动管理)
裸 new / delete 是 C++ 对 C 语言 malloc / free 的类型安全版本。与 malloc/free 不同,new 会调用构造函数,delete 会调用析构函数;但只要手写,就仍然容易出错。
现代 C++ 的主流做法是使用智能指针:它们是对堆指针的 RAII 封装,会在不再需要时自动 delete。
你只需要掌握两种智能指针。可以把它们理解成两种“所有权策略”。
std::unique_ptr(独占所有权)
unique_ptr 对其数据拥有独占所有权,其他对象不能共同拥有。unique_ptr 被销毁时,底层数据会被释放。你不能拷贝它——只能对它做 move(转移所有权)。
#include <memory>
void example() {
// 创建 unique_ptr:它独占 AudioBuffer
auto buffer = std::make_unique<AudioBuffer>(1024);
buffer->fill(0.0f);
// auto copy = buffer; // ❌ 不能拷贝
auto moved = std::move(buffer); // ✓ 转移所有权
// buffer 现在是 nullptr,数据由 moved 持有
}
// ← moved 销毁时,AudioBuffer 自动释放
JavaScript 类比:想象一个“不可共享”的引用。任意时刻只能有一个变量指向这份数据;要交给别人只能 move,原变量随即变成 null。
unique_ptr 所有权转移:
auto a = make_unique<X>(); a ──────▶ [X on heap]
auto b = std::move(a); a ──▶ nullptr
b ──────▶ [X on heap]
// b 离开作用域 b 销毁 -> [X freed]
图 4:unique_ptr 的所有权转移。同一时刻只能有一个指针拥有该数据。move 会转移所有权,并将源指针置为空。
std::shared_ptr(共享所有权)
shared_ptr 允许多个拥有者共享同一份数据。它内部维护一个引用计数:每拷贝一次计数加一,每销毁一个持有者计数减一。当计数降到零时,底层数据会被释放。
#include <memory>
void example() {
auto config = std::make_shared<AppConfig>(); // 引用计数=1
auto copy1 = config; // 引用计数=2
auto copy2 = config; // 引用计数=3
copy1.reset(); // 引用计数=2
copy2.reset(); // 引用计数=1
} // 引用计数=0 后释放
它最接近 JavaScript 的 GC 心智模型:对象只要“仍有人引用”就存活。
区别是:shared_ptr 是确定性的引用计数(计数归零立刻释放);JavaScript 是 tracing GC(“未来某次 GC”释放)。
shared_ptr 引用计数:
auto a = make_shared<X>(); a ──────▶ [X] 引用计数: 1
auto b = a; a ──────▶ [X] 引用计数: 2
b ──────┘
a.reset(); b ──────▶ [X] 引用计数: 1
b.reset(); [X] 引用计数: 0 -> 释放
图 5:shared_ptr 的引用计数。多个指针可指向同一份数据;当最后一个指针释放时,数据才会被销毁。
JSI 里该选哪一个?
| 智能指针 | 适用场景 | JSI 示例 |
|---|---|---|
unique_ptr |
单一所有者、无需共享 | 内部缓冲区、临时计算结果 |
shared_ptr |
多方持有,或需要暴露给 JS | HostObject(JS GC 与 C++ 都要持有) |
对 JSI 来说,真正关键的是 shared_ptr。当你创建 HostObject(即暴露给 JavaScript 的 C++ 对象)时,它通常会被包裹在 std::shared_ptr 里。JavaScript 垃圾回收器会持有一个引用,而你的 C++ 代码也可能持有其他引用。只有当 JS 与 C++ 两侧都释放各自引用后,HostObject 才会被销毁。
HostObject 使用 shared_ptr(第 5 篇预告)
// HostObject 一律使用 shared_ptr —— JS GC 会持有其中一个引用
auto storage = std::make_shared<StorageHostObject>(dbPath);
runtime.global().setProperty(
runtime, "storage",
jsi::Object::createFromHostObject(runtime, storage)
);
// 现在:JS(通过 GC)持有一个引用,C++ 侧也持有 `storage`
// 只有当双方都释放后,StorageHostObject 才会被销毁
易错点(Gotcha):
shared_ptr是有额外开销的——它的引用计数是原子整数(支持线程安全的递增/递减),并且每个shared_ptr都比裸指针更大(因为它携带控制块)。在热路径和实时代码中,应优先考虑unique_ptr。
我们会在第 8、9 篇构建多线程与音频管线代码时看到,这个差异为什么重要。
概念 5:Lambda(C++ 闭包)
Lambda 是你最容易一眼认出来的 C\+\+ 概念。它本质上就是闭包——能够从其外围作用域捕获变量的匿名函数。
JavaScript 闭包
function makeCounter() {
let count = 0;
return () => ++count;
}
C\+\+ Lambda:同样的模式
auto makeCounter() {
int count = 0;
return [count]() mutable { return ++count; };
}
语法看起来不一样,但可观察结果相同:连续调用返回函数 3 次,你会得到 1、2、3。
内部机制不同——JS 捕获的是变量绑定(同作用域闭包可共享),而 C\+\+ 的 [count] mutable 是捕获私有副本——但在“返回单个计数器”这个场景下结果一致。
语法骨架:
[capture](parameters) -> return_type { body }
捕获列表 [...] 正是 C\+\+ lambda 与 JavaScript 闭包的关键区别。在 JavaScript 里,闭包会自动捕获外层作用域中的变量绑定——它能够观察到这些变量后续的变化,这在行为上类似于 C\+\+ 的“按引用捕获”(但 JS 通过 GC 保活作用域,所以不存在悬空引用风险)。在 C\+\+ 里,你必须显式选择“捕获什么”以及“如何捕获”。
捕获模式
int x = 10;
std::string name = "JSI";
auto byValue = [x]() { return x; };
auto byRef = [&x]() { return x; };
auto allValue = [=]() { return x; };
auto allRef = [&]() { return x; };
auto mixed = [x, &name]() { return name + "!"; };
| 捕获方式 | 语法 | JS 类比 | 行为 |
|---|---|---|---|
| 按值捕获 | [x] |
const x_copy = x 后使用 x_copy
|
快照语义,外部 x 变化不会影响 lambda |
| 按引用捕获 | [&x] |
最接近 JS 闭包体验 | 活别名语义,可观察/修改外部 x
|
| 全部按值 | [=] |
无直接等价 | 复制函数体里用到的所有外部变量 |
| 全部按引用 | [&] |
接近 JS 默认闭包 | 以引用方式捕获函数体里用到的所有变量 |
图 6:Lambda 捕获模式。JavaScript 闭包总是共享外层作用域的变量绑定;C++ 则要求你显式做出选择——而这个选择会直接影响线程安全。
为什么“捕获”对 JSI 尤其关键?
这就是 JSI 关联变得关键的地方:当你创建一个 JSI host function 时,通常会使用 lambda。
JSI 中最重要的模式是:按值捕获 shared_ptr,确保对象生命周期足够长。
带 Lambda 捕获的 JSI host function
void install(jsi::Runtime& runtime, std::shared_ptr<Database> db) {
auto get = jsi::Function::createFromHostFunction(
runtime,
jsi::PropNameID::forAscii(runtime, "get"),
1,
[db](jsi::Runtime& rt,
const jsi::Value& thisVal,
const jsi::Value* args,
size_t count) -> jsi::Value {
auto key = args[0].asString(rt).utf8(rt);
auto result = db->get(key);
return jsi::String::createFromUtf8(rt, result);
}
);
runtime.global().setProperty(runtime, "dbGet", std::move(get));
}
注意:这里 lambda 按值捕获 db。但 db 是 shared_ptr,按值捕获实际上是“复制 shared_ptr 本身”,会增加引用计数。于是 lambda 获得数据库对象的共享所有权。即使外层 db 变量离开作用域,lambda 里那份副本仍能让对象存活。
想一想: 如果我们不是按值捕获
db([db]),而是按引用捕获([&db]),会发生什么?install函数返回后,作为局部变量的db会被销毁,而 lambda 会持有一个悬空引用——也就是指向一块已不存在内存的指针。下一次 JavaScript 调用dbGet()时,就会崩溃。
这就是为什么 JSI 的 lambda 几乎总是按值捕获shared_ptr,而不是按引用捕获。
这种模式——在 JSI 的 lambda 里按值捕获 shared_ptr——几乎出现在每一个原生模块中。它正是 C++ 对象能够“按 JavaScript 需要的时长持续存活”的关键机制。
Move 语义:转移所有权而非复制
还有一个概念能把前面的内容全部串起来。你已经在 unique_ptr 场景看过 std::move,现在来理解它到底做了什么。
在 JavaScript 里,对象赋值不会复制对象本体:
JavaScript:对象是共享的,不是拷贝的
const a = { data: [1, 2, 3] };
const b = a; // b 和 a 指向同一个对象
b.data.push(4); // a.data 也会变成 [1, 2, 3, 4]
在 C++ 里,对象赋值默认会发生拷贝:
C++:对象默认按值拷贝
std::vector<int> a = {1, 2, 3};
std::vector<int> b = a; // b 是副本,a 和 b 相互独立
b.push_back(4); // a 仍然是 {1, 2, 3}
拷贝很安全,但可能很贵。如果 a 里有 1MB 数据,b = a 就会真的复制这 1MB。std::move 的意思是:“我不再需要 a 了,把内部资源直接转移给 b,不要拷贝。”
Move:不拷贝,直接转移
std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a); // b 接管 a 的内部缓冲区
// a 进入 moved-from 状态:仍然有效,但值未指定(通常为空)
// b 持有 {1, 2, 3},过程没有深拷贝
可以把它类比成:
普通拷贝 = 复印一份 100 页文档;
move = 直接把文档递给别人,几乎瞬时,但你自己不再持有原件。
Copy: a ──▶ [1,2,3] b ──▶ [1,2,3] (存在两份数据)
Move: a ──▶ [] b ──▶ [1,2,3] (数据转移,无复制)
图 7:Copy vs Move。Copy 复制数据,Move 转移所有权。源对象会留在“有效但值未指定”的状态(通常为空)。
你会在 JSI 代码里看到 std::move 的常见场景:
- 把
unique_ptr转移给新的拥有者 - 把大对象传入函数时避免拷贝
- 高效返回构造好的对象
JSI 场景中的 move
// 把 JSI 函数 move 到属性里(无需复制)
auto fn = jsi::Function::createFromHostFunction(rt, name, 0, callback);
rt.global().setProperty(rt, "myFunc", std::move(fn));
// fn 现在是空的,runtime.global() 接管了所有权
串起来看:一段真实 JSI 代码
把这 5 个概念放进一段真实 JSI 模块代码里看。下面是一个简化版,风格接近你在 react-native-mmkv 这类库里会见到的写法:
一个完整的迷你 JSI 模块:所有核心概念都在里面
#include <jsi/jsi.h>
#include <memory>
#include <string>
#include <unordered_map>
using namespace facebook;
// 一个简单的内存键值存储
class KeyValueStore {
public:
// const&:只读引用,避免拷贝
void set(const std::string& key, const std::string& value) {
data_[key] = value;
}
// const 成员函数:不修改对象状态
std::string get(const std::string& key) const {
auto it = data_.find(key);
if (it != data_.end()) return it->second;
return "";
}
private:
// 成员随 KeyValueStore 生命周期销毁(RAII)
std::unordered_map<std::string, std::string> data_;
}; // 析构时自动释放 data_(RAII)
// rt 是引用:借用 runtime,不拥有 runtime
void installStorage(jsi::Runtime& rt) {
// shared_ptr:JS GC 和 C++ 都可能持有
auto store = std::make_shared<KeyValueStore>();
// set 函数:lambda 按值捕获 store(shared_ptr 拷贝,引用计数+1)
auto setFn = jsi::Function::createFromHostFunction(
rt, jsi::PropNameID::forAscii(rt, "set"), 2,
[store](jsi::Runtime& rt, const jsi::Value&,
const jsi::Value* args, size_t count) -> jsi::Value {
auto key = args[0].asString(rt).utf8(rt); // jsi::String -> std::string
auto val = args[1].asString(rt).utf8(rt);
store->set(key, val); // 使用捕获到的 shared_ptr
return jsi::Value::undefined();
}
);
// get 函数:同样的捕获模式
auto getFn = jsi::Function::createFromHostFunction(
rt, jsi::PropNameID::forAscii(rt, "get"), 1,
[store](jsi::Runtime& rt, const jsi::Value&,
const jsi::Value* args, size_t count) -> jsi::Value {
auto key = args[0].asString(rt).utf8(rt);
auto result = store->get(key);
return jsi::String::createFromUtf8(rt, result);
}
);
// 安装到 JS 全局作用域:move 转移所有权,避免不必要复制
auto storage = jsi::Object(rt);
storage.setProperty(rt, "set", std::move(setFn)); // move:转移所有权
storage.setProperty(rt, "get", std::move(getFn));
rt.global().setProperty(rt, "storage", std::move(storage));
}
在 JavaScript 里这样调用:
storage.set("theme", "dark");
const theme = storage.get("theme");
console.log(theme);
输出:
"dark"
这段代码里包含了本文所有关键概念:
| 代码位置 | 概念 | 发生了什么 |
|---|---|---|
jsi::Runtime& rt |
引用 | 借用 runtime,不拥有它 |
const jsi::Value& |
常量引用 | 只读访问 this 值 |
const jsi::Value* args |
指针 | 指向参数数组 |
std::make_shared() |
智能指针 | 在堆上分配并采用共享所有权 |
[store](...) { ... } |
Lambda + 捕获 | 按值捕获 shared_ptr 闭包 |
std::move(setFn) |
Move | 把函数所有权转移给对象 |
~KeyValueStore()(隐式) |
RAII | 销毁时自动释放 data_
|
你暂时可以不学的 C++ 内容
C++ 非常庞大。针对 JSI 开发,下面这些内容你现在可以放心先跳过:
| C++ 特性 | 为什么当前可忽略 |
|---|---|
| 高级模板 | JSI 内部会用,但你通常不必自己写 |
| 多重继承 | JSI 场景常见单继承 |
| 高阶运算符重载 | 模块开发很少需要自己定义 |
const_cast / reinterpret_cast
|
系统层偶尔有用,JSI 入门阶段通常不需要 |
手写 new / delete
|
优先 make_unique / make_shared
|
| 复杂宏逻辑 | 除平台 #ifdef 外尽量少用 |
如果你在第三方原生模块里看到这些高级特性,通常也不影响你理解周边 JSI 代码的核心逻辑。
关键结论
- 栈 vs 堆。 栈内存是自动的:函数开始时分配,函数返回时释放。堆内存生命周期更长,需要管理。JSI 里通常用智能指针来管理堆内存。
-
引用(
&)与指针(*)。 引用是别名,即已有数据的另一个名字。const &表示“只读借用”。指针保存内存地址。JSI 里常见jsi::Runtime&(借用 runtime)和const jsi::Value*(参数数组指针)。 -
RAII。 构造函数获取资源,析构函数释放资源,生命周期由作用域决定。这是 C++ 对
try/finally的语言级答案,而且不会被忘记。每个 HostObject 都依赖 RAII 在 JS 垃圾回收后清理原生资源。 -
智能指针。
unique_ptr= 单所有者、自动清理;shared_ptr= 通过引用计数共享所有权。HostObject 通常使用shared_ptr,因为 JS GC 与 C++ 代码都可能持有同一对象。 -
Lambda 显式捕获。 不同于 JavaScript 闭包默认共享外层绑定,C++ lambda 必须显式声明捕获内容和方式。JSI 最关键模式是:在 lambda 里按值捕获
shared_ptr,让原生对象在 JS 仍需访问时保持存活。
Reading the Crash(回看崩溃栈)
再看第 1 篇的 crash trace,你现在已经能读懂其中的 C++ 符号:
-
audio::TxRingBuffer::push(uint8_t const*, unsigned long, long):audio命名空间下TxRingBuffer类的push方法,参数是只读字节指针、长度、时间戳。你现在知道uint8_t const*表示“指向原始字节的只读指针”。 -
std::__ndk1::shared_ptr<audio::AudioPipelineHostObject>::~shared_ptr():shared_ptr析构函数。~代表析构(RAII 清理发生)。模板参数告诉你它持有的是AudioPipelineHostObject。当它运行时,说明引用计数归零了,最后一个拥有者已释放。 -
audio::TxRingBuffer::~TxRingBuffer():环形缓冲区的析构函数。它在 HostObject 析构过程中被调用,意味着 pipeline 持有 ring buffer,销毁 pipeline 就会连带销毁 buffer。
现在析构链条就很清楚了:shared_ptr 释放 -> AudioPipelineHostObject 析构 -> TxRingBuffer 析构。与此同时,CaptureEncoderThread::processFrame 仍在调用 TxRingBuffer::push。缓冲区正在一个线程上被销毁,而另一个线程还在向它写入。这就是典型的 use-after-free——而你现在已经有足够的 C++ 认知,能从这些符号里直接看出来。
当前系统视图:
JS Thread UI Thread Native Thread
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Hermes │ │ Platform │ │ C++ code │
│ │ │ │ │ │
│ ── JSI ──────┼───┼───────────────┼──▶│ stack / heap │ ← NEW
│ │ │ │ │ shared_ptr │ ← NEW
│ │ │ │ │ RAII + dtors │ ← NEW
└───────────────┘ └───────────────┘ └───────────────┘
Frequently Asked Questions
React Native JSI 需要掌握哪些 C++?
5 个核心概念:栈/堆内存、引用(&)与指针(*)、RAII(通过析构自动清理资源)、智能指针(unique_ptr 与 shared_ptr)、以及带显式捕获列表的 lambda。你不需要先掌握模板高级玩法、多重继承、运算符重载。
RAII 为什么对 JSI 重要?
RAII(Resource Acquisition Is Initialization)意味着:构造时获取资源,离开作用域时通过析构自动释放资源。在 JSI 中,HostObject 借助 RAII 来清理原生资源(文件句柄、缓冲区、连接等),不需要手动 close(),也不依赖“未来某次 GC 再说”。
unique_ptr 和 shared_ptr 区别是什么?
unique_ptr 是独占所有权:同一时间只有一个指针拥有对象,通过 std::move 转移所有权。shared_ptr 是引用计数共享所有权:最后一个引用释放时对象才销毁。JSI HostObject 常用 shared_ptr,因为 JavaScript GC 和 C++ 侧都可能同时持有引用。
为什么 JSI Lambda 常按值捕获 shared_ptr?
按值捕获 shared_ptr 会复制指针并增加引用计数,从而保证 lambda 存在期间原生对象持续存活。若按引用捕获([&db]),外层变量离开作用域后就会留下悬空引用,下次 JS 调用函数时就可能崩溃。
下一篇预告
你现在已经掌握了 C++ 的核心词汇:数据住在哪(栈/堆)、怎么借用(&)、怎么管理(unique_ptr / shared_ptr)、怎么清理(RAII)、怎么写闭包(显式捕获 lambda)。
在 第 4 篇:你的第一个 React Native JSI 函数 中,我们会把它们真正拼起来:从零写一个 JSI 函数,完成 runtime 注册、参数校验、错误处理,并从 JavaScript 端调用它。不用 boilerplate 生成器,不用 codegen,就用最原生的 JSI。
第 3 篇给你“词汇”,第 4 篇给你“动词”。
References & Further Reading
- cppreference — std::unique_ptr
- cppreference — std::shared_ptr
- cppreference — RAII
- cppreference — Lambda expressions
- cppreference — Move semantics
- C++ Core Guidelines — Bjarne Stroustrup & Herb Sutter
- JSI Header — jsi.h (facebook/react-native)
Quick Reference
C++ 与 JavaScript 概念对照
| JavaScript | C++ 对应 | 关键差异 |
|---|---|---|
let x = obj |
auto x = obj(拷贝)或 auto& x = obj(引用) |
C++ 默认拷贝;& 才是避免拷贝 |
| Garbage Collected | 栈(自动)或堆(new/智能指针) |
栈在 } 处立即结束;堆需显式或托管释放 |
| 闭包自动捕获 | Lambda 显式捕获 [...]
|
[=] 全部按值,[&] 全部按引用 |
undefined |
无直接等价 | C++ 未初始化内存属于未定义行为 |
智能指针速查
| 类型 | 是否拥有资源 | 是否可拷贝 | 适用场景 |
|---|---|---|---|
std::unique_ptr |
是(独占) | 否(仅 move) | 单所有者、无需共享 |
std::shared_ptr |
是(共享) | 是(引用计数) | 多所有者,尤其 JS ↔ C++ 边界 |
std::weak_ptr |
否(观察者) | N/A | 断环、探测对象是否仍存活 |
RAII 模式
{
auto ptr = std::make_shared<MyClass>(); // 构造函数执行
// ... 使用 ptr ...
} // 析构函数在此保证执行(即使异常)
系列:React Native JSI 深度解析(12 篇)
第 1 篇:React Native 架构——线程、Hermes 与事件循环 | 第 2 篇:React Native Bridge 与 JSI——到底变了什么 | 第 3 篇:面向 JavaScript 开发者的 C++(你在这里) | 第 4 篇:你的第一个 React Native JSI 函数 | 第 5 篇:HostObjects——把 C++ 类暴露给 JavaScript | 第 6 篇:内存所有权 | 第 7 篇:平台接线 | 第 8 篇:线程与异步 | 第 9 篇:实时音频管线 | 第 10 篇:存储引擎 | 第 11 篇:TurboModules vs Pure JSI vs Pure C++ | 第 12 篇:生产调试与陷阱