阅读视图

发现新文章,点击刷新页面。

【翻译】React Native JSI 深度解析(第 3 篇):面向 JavaScript 开发者的 C++

React Native JSI 深度解析(第 3 篇):面向 JavaScript 开发者的 C++

“抽象的目的不是含糊其辞,而是创建一个新的语义层,在这个层里你可以做到绝对精确。”
— 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);
}

你看到 &*constsize_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。但 dbshared_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_ptrshared_ptr)、以及带显式捕获列表的 lambda。你不需要先掌握模板高级玩法、多重继承、运算符重载。

RAII 为什么对 JSI 重要?

RAII(Resource Acquisition Is Initialization)意味着:构造时获取资源,离开作用域时通过析构自动释放资源。在 JSI 中,HostObject 借助 RAII 来清理原生资源(文件句柄、缓冲区、连接等),不需要手动 close(),也不依赖“未来某次 GC 再说”。

unique_ptrshared_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

  1. cppreference — std::unique_ptr
  2. cppreference — std::shared_ptr
  3. cppreference — RAII
  4. cppreference — Lambda expressions
  5. cppreference — Move semantics
  6. C++ Core Guidelines — Bjarne Stroustrup & Herb Sutter
  7. 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 篇:生产调试与陷阱

在 React Native 中集成 MinIO 对象存储(图片/文件上传服务)

前言

在移动应用开发中,文件上传和存储是一个常见需求。无论是用户头像、签名图片还是各类文档,都需要一个可靠的存储方案。MinIO 作为一个高性能的对象存储服务,完全兼容 AWS S3 API,成为了许多开发者的首选。

本文将详细介绍如何在 React Native 项目中集成 MinIO,包括环境配置、SDK 集成、实际代码示例以及最佳实践。

为什么选择 MinIO?

MinIO 的优势

  1. 完全兼容 S3 API - 可以直接使用 AWS SDK,无需学习新的 API
  2. 高性能 - 基于 Go 语言开发,性能优异
  3. 自托管 - 可以部署在自己的服务器上,数据完全可控
  4. 开源免费 - 基于 Apache License 2.0 开源
  5. 简单易用 - 配置简单,上手快速

与其他方案对比

方案 优势 劣势
MinIO 自托管、高性能、免费 需要自己维护服务器
AWS S3 无需维护、全球分发 需要付费、数据在云端
阿里云 OSS 国内访问快、功能丰富 需要付费、厂商锁定
本地存储 无需网络、速度快 存储空间有限、无法跨设备

技术方案

使用 AWS S3 SDK

由于 MinIO 完全兼容 S3 API,我们可以直接使用 AWS 官方的 JavaScript SDK:

npm install @aws-sdk/client-s3
# 或
yarn add @aws-sdk/client-s3

同时需要安装 react-native-config 来管理环境变量:

npm install react-native-config
# 或
yarn add react-native-config

环境配置

1. 配置环境变量

在项目根目录创建 .env 文件:

# MinIO 配置
MINIO_ENDPOINT='http://xxx:xxx'
MINIO_ACCESS_KEY='your_access_key'
MINIO_SECRET_KEY='your_secret_key'
MINIO_BUCKET='your_bucket_name'
MINIO_USE_SSL=false

2. 初始化 S3 客户端

创建一个自定义 Hook 来封装 MinIO 操作:

// src/hooks/useMinio.js
import {useState, useEffect, useCallback, useRef} from 'react';
import {S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand} from '@aws-sdk/client-s3';
import Config from 'react-native-config';

const useMinio = () => {
  const [client, setClient] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const bucketName = Config.MINIO_BUCKET || 'default-bucket';
  const clientRef = useRef(null);

  // 初始化 S3 客户端
  useEffect(() => {
    if (!clientRef.current) {
      try {
        const endpoint = Config.MINIO_ENDPOINT || 'http://localhost:9000';
        
        const s3Client = new S3Client({
          endpoint: endpoint,
          forcePathStyle: true, // MinIO 需要路径风格
          region: 'us-east-1',
          credentials: {
            accessKeyId: Config.MINIO_ACCESS_KEY || '',
            secretAccessKey: Config.MINIO_SECRET_KEY || '',
          },
        });
        
        clientRef.current = s3Client;
        setClient(s3Client);
      } catch (err) {
        setError(err);
        console.error('Error initializing S3 client:', err);
      }
    }
  }, []);

  return {
    loading,
    error,
    bucketName,
    client,
  };
};

export default useMinio;

关键配置说明

  • forcePathStyle: true - MinIO 必须使用路径风格(/bucket/object),而不是虚拟主机风格
  • region - MinIO 默认使用 us-east-1,可以自定义
  • endpoint - MinIO 服务器地址,包含端口

核心功能实现

1. 上传文件

上传文件是最常用的功能。在 React Native 中,我们通常处理的是 Buffer 或 Base64 格式的数据。

const uploadImageFromBuffer = useCallback(async (buffer, objectName, contentType = 'image/jpeg') => {
  if (!client) {
    throw new Error('S3 client not initialized');
  }

  setLoading(true);
  setError(null);

  try {
    const command = new PutObjectCommand({
      Bucket: bucketName,
      Key: objectName,
      Body: buffer,
      ContentType: contentType,
    });

    await client.send(command);
    console.log(`File uploaded successfully as ${objectName}`);
    
    return objectName;
  } catch (err) {
    setError(err);
    console.error('Error uploading file:', err);
    throw err;
  } finally {
    setLoading(false);
  }
}, [client, bucketName]);

2. 获取文件 URL

获取已上传文件的访问 URL:

const getImageUrl = useCallback(async (objectName) => {
  try {
    const endpoint = Config.MINIO_ENDPOINT || 'http://localhost:9000';
    
    // 构建简单 URL 格式:endpoint/bucket/objectName
    const url = `${endpoint}/${bucketName}/${objectName}`;
    
    console.log('Generated image URL:', url);
    return url;
  } catch (err) {
    setError(err);
    console.error('Error getting image URL:', err);
    throw err;
  }
}, [bucketName]);

3. 删除文件

const deleteImage = useCallback(async (objectName) => {
  if (!client) {
    throw new Error('S3 client not initialized');
  }

  try {
    const command = new DeleteObjectCommand({
      Bucket: bucketName,
      Key: objectName,
    });

    await client.send(command);
    console.log(`File ${objectName} deleted successfully`);
  } catch (err) {
    setError(err);
    console.error('Error deleting file:', err);
    throw err;
  }
}, [client, bucketName]);

4. 检查文件是否存在

const objectExists = useCallback(async (objectName) => {
  if (!client) {
    throw new Error('S3 client not initialized');
  }

  try {
    const command = new HeadObjectCommand({
      Bucket: bucketName,
      Key: objectName,
    });

    await client.send(command);
    return true;
  } catch (err) {
    if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
      return false;
    }
    throw err;
  }
}, [client, bucketName]);

实际应用示例

场景:电子签名上传

以下是一个完整的电子签名上传示例,包括 Base64 转换、上传和 URL 获取:

import React, {useState} from 'react';
import {View, TouchableOpacity, Text, ActivityIndicator} from 'react-native';
import useMinio from '../../hooks/useMinio';

const SignatureUpload = () => {
  const {uploadImageFromBuffer, getImageUrl, loading} = useMinio();
  const [signatureUrl, setSignatureUrl] = useState(null);

  const handleSignatureUpload = async (base64Signature) => {
    try {
      // 1. 提取 base64 数据
      let base64Data = base64Signature;
      if (base64Data.includes('base64,')) {
        base64Data = base64Data.split('base64,')[1];
      }

      // 2. 将 base64 转换为 Uint8Array(React Native 兼容方式)
      const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
      const decodeLength = (base64Data.length * 3) / 4;
      const bytes = new Uint8Array(decodeLength);
      let bufferIndex = 0;
      
      for (let i = 0; i < base64Data.length; i += 4) {
        const enc1 = base64Chars.indexOf(base64Data[i]);
        const enc2 = base64Chars.indexOf(base64Data[i + 1]);
        const enc3 = base64Chars.indexOf(base64Data[i + 2] || '=');
        const enc4 = base64Chars.indexOf(base64Data[i + 3] || '=');
        
        bytes[bufferIndex++] = (enc1 << 2) | (enc2 >> 4);
        if (enc3 !== 64) {
          bytes[bufferIndex++] = ((enc2 & 15) << 4) | (enc3 >> 2);
        }
        if (enc4 !== 64) {
          bytes[bufferIndex++] = ((enc3 & 3) << 6) | enc4;
        }
      }

      const actualBytes = bytes.slice(0, bufferIndex);

      // 3. 生成唯一的对象名称
      const timestamp = Date.now();
      const userId = 'user123'; // 实际项目中从用户信息获取
      const objectName = `${userId}/${timestamp}.png`;

      // 4. 上传到 MinIO
      await uploadImageFromBuffer(actualBytes, objectName, 'image/png');

      // 5. 获取在线 URL
      const imageUrl = await getImageUrl(objectName);
      
      setSignatureUrl(imageUrl);
      console.log('Signature uploaded successfully:', imageUrl);
      
      return imageUrl;
    } catch (error) {
      console.error('Error uploading signature:', error);
      throw error;
    }
  };

  return (
    <View>
      <TouchableOpacity onPress={() => handleSignatureUpload('your_base64_data')}>
        <Text>上传签名</Text>
      </TouchableOpacity>
      
      {loading && <ActivityIndicator />}
      
      {signatureUrl && (
        <Image source={{uri: signatureUrl}} style={{width: 200, height: 100}} />
      )}
    </View>
  );
};

最佳实践

1. 对象命名规范

建议使用有层次结构的命名方式:

{userId}/{type}/{timestamp}.{extension}

示例:

  • user123/avatar/1713456789000.jpg
  • user123/signature/1713456789001.png
  • user456/document/1713456789002.pdf

2. 文件大小限制

在上传前检查文件大小,避免上传过大的文件:

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

const uploadWithSizeCheck = async (buffer, objectName) => {
  if (buffer.length > MAX_FILE_SIZE) {
    throw new Error('File size exceeds 5MB limit');
  }
  return uploadImageFromBuffer(buffer, objectName);
};

3. 错误处理

完善的错误处理机制:

const handleUpload = async () => {
  try {
    setLoading(true);
    const url = await uploadImageFromBuffer(buffer, objectName);
    Toast.success('上传成功');
    return url;
  } catch (error) {
    if (error.name === 'NetworkError') {
      Toast.error('网络错误,请检查网络连接');
    } else if (error.name === 'AccessDenied') {
      Toast.error('权限不足,请联系管理员');
    } else {
      Toast.error('上传失败,请重试');
    }
    console.error('Upload error:', error);
  } finally {
    setLoading(false);
  }
};

4. 进度显示

对于大文件上传,可以添加进度显示(需要使用分片上传):

// 使用 @aws-sdk/lib-storage 支持进度显示
import {Upload} from '@aws-sdk/lib-storage';

const uploadWithProgress = async (buffer, objectName, onProgress) => {
  const upload = new Upload({
    client,
    params: {
      Bucket: bucketName,
      Key: objectName,
      Body: buffer,
    },
  });

  upload.on('httpUploadProgress', (progress) => {
    const percentage = Math.round((progress.loaded / progress.total) * 100);
    onProgress(percentage);
  });

  await upload.done();
};

5. 缓存策略

对于频繁访问的图片,可以实现本地缓存:

import {AsyncStorage} from 'react-native';

const getCachedOrUpload = async (localPath, objectName) => {
  const cacheKey = `cached_${objectName}`;
  const cachedUrl = await AsyncStorage.getItem(cacheKey);
  
  if (cachedUrl) {
    return cachedUrl;
  }
  
  const url = await uploadImageFromBuffer(buffer, objectName);
  await AsyncStorage.setItem(cacheKey, url);
  return url;
};

常见问题

Q1: 为什么需要 forcePathStyle: true

MinIO 使用路径风格的 URL(/bucket/object),而 AWS S3 默认使用虚拟主机风格(bucket.s3.amazonaws.com/object)。设置 forcePathStyle: true 可以确保 SDK 使用正确的 URL 格式。

Q2: 如何处理网络中断?

实现重试机制:

const uploadWithRetry = async (buffer, objectName, maxRetries = 3) => {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await uploadImageFromBuffer(buffer, objectName);
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
    }
  }
};

Q3: 如何实现文件预签名 URL?

对于需要临时访问的文件,可以使用预签名 URL:

import {getSignedUrl} from '@aws-sdk/s3-request-presigner';

const getPresignedUrl = async (objectName, expiresIn = 3600) => {
  const command = new GetObjectCommand({
    Bucket: bucketName,
    Key: objectName,
  });
  
  return await getSignedUrl(client, command, {expiresIn});
};

Q4: React Native 中如何处理文件选择?

可以使用 react-native-document-pickerreact-native-image-picker

npm install react-native-image-picker
import {launchImageLibrary} from 'react-native-image-picker';

const pickAndUpload = async () => {
  const result = await launchImageLibrary({mediaType: 'photo'});
  
  if (result.assets && result.assets[0]) {
    const asset = result.assets[0];
    // asset.uri 是本地文件路径
    // 需要转换为 Buffer 后再上传
  }
};

性能优化

1. 并发上传

对于多个文件,使用并发上传:

const uploadMultiple = async (files) => {
  const uploadPromises = files.map(file => 
    uploadImageFromBuffer(file.buffer, file.objectName)
  );
  
  return Promise.all(uploadPromises);
};

2. 压缩图片

上传前压缩图片以减少带宽:

npm install react-native-image-resizer
import ImageResizer from 'react-native-image-resizer';

const compressAndUpload = async (imagePath, objectName) => {
  const compressed = await ImageResizer.createResizedImage(
    imagePath,
    800, // 宽度
    600, // 高度
    'JPEG',
    80 // 质量
  );
  
  // 读取压缩后的文件并上传
  const buffer = await readFile(compressed.uri);
  return uploadImageFromBuffer(buffer, objectName, 'image/jpeg');
};

3. CDN 加速

如果 MinIO 服务器在国内,可以考虑配置 CDN 加速:

const getImageUrl = useCallback(async (objectName) => {
  const cdnEndpoint = Config.MINIO_CDN_ENDPOINT || Config.MINIO_ENDPOINT;
  const url = `${cdnEndpoint}/${bucketName}/${objectName}`;
  return url;
}, [bucketName]);

安全建议

1. 环境变量管理

  • 不要将敏感信息提交到代码仓库
  • 使用 .env.local 存储本地开发配置
  • 生产环境使用安全的密钥管理方案

2. 访问控制

  • 为不同用户创建不同的 Access Key
  • 设置合理的 Bucket 策略
  • 定期轮换密钥

3. 数据加密

  • 敏感数据上传前加密
  • 使用 HTTPS 传输
  • MinIO 支持服务器端加密

总结

MinIO 是一个优秀的对象存储解决方案,在 React Native 中集成也非常简单。通过使用 AWS S3 SDK,我们可以快速实现文件上传、下载、删除等功能。

本文介绍了从环境配置到实际应用的完整流程,包括核心功能实现、最佳实践和常见问题解决方案。希望这些内容能帮助你在 React Native 项目中更好地使用 MinIO。

参考资源

❌