首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >前端开发者的 C++ 实战补漏:JS 和 C++ 互相调用

前端开发者的 C++ 实战补漏:JS 和 C++ 互相调用

原创
作者头像
骑猪耍太极
发布2026-06-29 19:38:16
发布2026-06-29 19:38:16
1440
举报
文章被收录于专栏:AI编程之旅AI编程之旅

前端写 Node 服务时,有些问题绕不开 C++:性能瓶颈在 JS 层解不开,或者业务 SDK 只提供 C/C++ 动态库。JS 要用这些能力,就得找到一条跨语言边界。FFI、WASM、子进程、N-API 都能解决一部分问题;如果目标是在 Node 进程里稳定暴露 C++ 能力、处理 JS 对象、做异步回调,N-API 是更贴近这个需求的一条路。


1. JS 和 C++ 互相调用的几种方式

让 JS 调到 C++ 代码,常见有四种方式:

  • FFI:JS 通过 ffi-napi 直接调 C 动态库的导出函数。不用写 C++ 扩展,适合调用稳定的 C ABI;但复杂对象、异步回调和生命周期管理会比较吃力。
  • WASM:C++ 编译成 WebAssembly,JS 加载调用。跨平台,浏览器也能用;但系统 API、线程、文件访问这些能力受运行环境限制。
  • 子进程:spawn 一个 C++ 程序,走 stdio 通信。隔离性最好,也最容易部署;但通信开销大,数据要序列化,调用体验不像本地函数。
  • N-API:写 C++ 扩展编译成 .node,JS require 加载。可以操作 JS 对象、做异步回调,也能留在同一个 Node 进程里;代价是要写 C++ 代码,编译和发布更复杂。

前端类比:这几种方式的关系,类似前端调原生能力的几种路径。FFI 像直接调一个低层系统接口,子进程像单独启动一个服务,N-API 更接近 React Native 的原生模块。

Node 原生扩展场景里,N-API 的优势在于边界更完整:JS 调 C++ 函数,C++ 处理 JS 值,工作线程完成后再把结果回传给 JS。

2. .node 文件是什么

.node 文件就是一个改了后缀的动态库。

代码语言:bash
复制
file myaddon.node
# → ELF 64-bit LSB shared object  (Linux)
# → 或 DLL                         (Windows)

Node 加载一个 native 模块时,本质就是 dlopen 这个 .node 文件,然后调用它导出的固定入口函数。这个入口函数负责把一堆 C++ 函数注册成 JS 能调用的方法。

这一步可以分成三件事:JS 通过 require 触发加载,Node 用动态库加载机制打开 .node 文件,入口函数把 C++ 函数挂到 exports 上。之后 JS 调 addon.add(1, 2),才会真正进入 C++ 的 Add 函数。

前端类比:require('./myaddon.node') 就像 import 一个用 C++ 写的 npm 包,只是这个包的源码是编译好的机器码,不是 JS。

3. N-API 是什么

光有 .node 还不够,JS 和 C++ 之间需要一个翻译协议,这就是 N-API。

N-API 是 Node 官方提供的 C 语言 API,用来在 C++ 里操作 JS 的值、函数、对象。它最大的卖点是 ABI 稳定:同一个 .node 编译出来后,换不同版本的 Node 也不用重新编译。

ABI(应用二进制接口)指的是编译后的二进制层面怎么对接。普通的 Node C++ 扩展(基于 V8 API 或 NAN)依赖 V8 内部布局,Node 一升级 ABI 就变,扩展要重编译。N-API 隔离了这层,保证 ABI 跨版本不变。

N-API 里有几个核心概念要先认识:

  • napi_env:一个 Node 环境实例的上下文。前端类比:相当于一个 JS 引擎实例的引用。
  • napi_value:JS 值在 C++ 里的表示。前端类比:JS 对象的遥控器,不是对象本身。
  • napi_callback:注册给 JS 调用的 C++ 函数。前端类比:暴露给 JS 的方法。

特别注意 napi_value:它是一个不透明句柄。你在 C++ 里拿到的 napi_value 只是代表某个 JS 值的标记,不能直接当 C++ 的 int / string 用,得通过 N-API 函数转换。这和 JS 里对象是引用类型一个道理,你拿到的是引用,不是值本身。

补充:实际工程里很少有人直接写 N-API 的 C 接口,太啰嗦。常用的是 node-addon-api,一个 C++ 封装库,把 napi_value 包成 Napi::ValueNapi::String 等 RAII 对象。下面的代码尽量用 node-addon-api 风格,少数跨线程通道用简化类演示机制。

4. 同步桥接

同步桥接是最容易理解的一种形态。JS 调一个 C++ 函数,C++ 算完直接返回。

代码语言:cpp
复制
// C++ 侧:使用 node-addon-api 暴露一个加法函数给 JS
Napi::Value Add(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    double a = info[0].As<Napi::Number>().DoubleValue();
    double b = info[1].As<Napi::Number>().DoubleValue();

    return Napi::Number::New(env, a + b);
}

JS 侧:

代码语言:js
复制
const addon = require('./myaddon.node')
console.log(addon.add(1, 2))   // 3

整个流程是:JS 调 add,Node 切到 C++ 的 Add 函数,info[0] / info[1] 拿到 JS 参数,.As<Napi::Number>() 转成 C++ 类型,Napi::Number::New 把结果包回 JS 值返回。过程同步,JS 主线程阻塞等结果。

局限也很明显:如果 C++ 要做的事很耗时(比如加载大文件、调网络),同步桥接会卡死 JS 主线程,整个 Node 进程假死。所以真实业务里,耗时的 C++ 操作必须异步。

5. 异步桥接的难点

异步桥接的核心难点是:C++ 工作线程不能直接调用 JS。

原因很简单。Node 的 JS 是单线程的,运行在主线程(event loop)上。C++ 起的工作线程如果直接调 JS 函数,等于多线程同时操作 JS 世界,会崩。所以把结果回传给 JS 这个动作,必须投递回主线程执行。

《异步操作的生命周期》里讲的跨线程投递通道,在这里派上用场。N-API 把它封装成了 TSFN。

6. TSFN 跨线程投递

TSFN(ThreadSafeFunction)让工作线程把一段代码投递回主线程执行。

TSFN 投递时序
TSFN 投递时序

前端类比:TSFN 就是 Web Worker 的 postMessage 通道。Worker 线程不能直接碰主线程的 DOM,只能 postMessage 把消息投递回主线程,由主线程处理。TSFN 的 NonBlockingCall(lambda) 就是 postMessage(lambda),lambda 里才是真正能安全调 JS 的地方。

用一个简化版的 SimulatedTsfn 表达这套机制:

代码语言:cpp
复制
class SimulatedTsfn {
public:
    using Callback = std::function<void()>;

    bool nonBlockingCall(Callback cb) {
        std::lock_guard<std::mutex> lock(mu_);
        if (!alive_) return false;        // 已 Abort,投递失败
        queue_.push(std::move(cb));        // 排队,等主线程取
        return true;
    }

    void drainQueue() {                    // 主线程 event loop 取出执行
        while (true) {
            Callback cb;
            {
                std::lock_guard<std::mutex> lock(mu_);
                if (queue_.empty()) break;
                cb = std::move(queue_.front());
                queue_.pop();
            }
            cb();                          // 离开锁以后再执行 lambda
        }
    }
    // ...
};

工作线程投递,主线程取出执行。

TSFN 有几个关键状态操作:

  • New:创建投递通道。前端类比:new Worker() + 建 postMessage 通道。
  • NonBlockingCall:投递一个 lambda,立即返回。前端类比:worker.postMessage(fn)
  • Unref:标记为后台任务,不阻止事件循环退出。前端类比:不让 Worker 阻止进程退出。
  • Abort:立即中止,丢弃队列里所有 lambda。前端类比:强制关闭 Worker,丢弃积压消息。

如果资源释放逻辑只写在 lambda 执行体里,而队列里的任务在 Abort 后不再执行,释放逻辑就可能跑不到。尤其是 release() 出来的裸指针,需要在投递失败、Abort、shutdown 这些路径里有兜底清理。这和《异步操作的生命周期》里讲的 claimAll 批量回收是一个思路。

7. 异步回调里的生命周期

TSFN 解决了跨线程回传的问题,但回传时通常还要带一个上下文对象。这个对象里可能有请求 id、Promise 的控制权、SDK 返回结果,甚至还有一块需要释放的内存。它的生命周期不能靠临时变量硬撑,最好交给 unique_ptr 管起来。

问题出在投递边界上。很多 C++ 回调队列会把 lambda 存进 std::function,而 std::function 要求可拷贝。unique_ptr 是独占所有权,不可拷贝;move 捕获 unique_ptr 后,lambda 也变成不可拷贝。这样一来,上下文对象虽然管住了,lambda 却投不进队列。

代码语言:cpp
复制
auto ctx = std::make_unique<AsyncCtx>("config.json", 42);
tsfn.NonBlockingCall([ctx = std::move(ctx)]() {
    ctx->process();
}); // ❌ lambda 持有 unique_ptr,可能无法放进 std::function

解法是把所有权转移拆成两段:投递前先 release() 放弃所有权,拿到一个可拷贝的裸指针;lambda 真正执行时,再用 unique_ptr 重新接管。这样既能穿过 std::function 的可拷贝要求,又能在 lambda 执行完时恢复 RAII 清理。

代码语言:cpp
复制
auto ctx = std::make_unique<AsyncCtx>("config.json", 42);
AsyncCtx* raw = ctx.release();

napi_status status = tsfn.NonBlockingCall([raw]() {
    std::unique_ptr<AsyncCtx> owned(raw);  // lambda 开始执行后,重新接管所有权
    owned->process();
});

if (status != napi_ok) {
    delete raw;                            // 投递失败,lambda 不会执行,必须兜底清理
    return;
}

这段代码的关键点在 release()owned(raw) 和失败分支。release() 之后,外层的 ctx 不再负责释放对象;owned(raw) 重新接管后,lambda 结束时对象会自动释放;如果投递失败,lambda 根本不会执行,只能在失败分支手动 delete raw

前端类比:这有点像把一个对象从 React state 里临时拿出来,交给异步队列,等回调真正执行时再挂回一个自动清理的 owner 上。中间裸指针窗口越短越好,而且每条失败路径都要有兜底。

8. 用 Promise 包装异步结果

异步操作完成后,结果要回传给 JS。最直接的方式是 callback,但 callback 风格前端同学都懂,嵌套多了就是回调地狱。更现代的方式是包装成 Promise,JS 侧可以 await

N-API 提供 Promise::Deferred 来做这件事。Deferred 的本质是把 Promise 的 resolve / reject 控制权留在 C++ 手里。对应到前端,就是 Promise.withResolvers()

代码语言:js
复制
// 前端:手动拿到 resolve/reject
const { promise, resolve, reject } = Promise.withResolvers()
// 把 resolve/reject 存起来,异步完成后调用

C++ 侧的 Deferred 一一对应:

代码语言:cpp
复制
// 创建 Deferred(拿到 resolve/reject 控制权)
auto deferred = Napi::Promise::Deferred::New(env);
// 抓出 Promise 返回给 JS(JS 侧可以 await)
Napi::Promise promise = deferred.Promise();
// 把 deferred 存起来,异步完成后调用
//   deferred.Resolve(result);   →  JS 的 Promise resolved
//   deferred.Reject(error);     →  JS 的 Promise rejected

一个异步操作可能有四种结局:SDK 回调成功、SDK 回调失败、超时、进程 shutdown。前三种走 Resolve 或 Reject,第四种走批量 Reject。

代码语言:cpp
复制
// 路径 1: SDK 回调成功 → Resolve
d1->resolve("{\"path\": \"/data/config.json\"}");

// 路径 2: SDK 回调失败 → Reject
d2->reject(404, "Resource not found");

// 路径 3: 超时(Watcher 线程扫描到)→ Reject
d3->reject(1001, "Operation timed out after 30s");

// 路径 4: shutdown(process.exit 触发 ClaimAll)→ Reject
d4->reject(1000, "Environment is shutting down");

Promise 语义只接受第一次结算,后续 resolve / reject 通常不会改变结果。但工程上不能只依赖 Promise 自己兜底。SDK 的第二次回调可能还会重复释放上下文、重复访问 env、重复写状态,这些副作用发生在 Promise 结算之前。

代码语言:cpp
复制
// Promise 结果只看第一次结算
d5->resolve("first");
d5->resolve("second");       // 结果不再变化

d5->releaseContext();          // ⚠️ 如果这类清理也重复执行,就可能出事

所以外层仍然需要 Registry 或状态位挡住重复回调。《异步操作的生命周期》里讲的 claim,解决的就是这类问题。

9. 错误处理为什么不能用 try/catch

N-API 扩展里的错误处理,不能完全沿用 JS 的 try/catch 习惯。很多工程会定义 NAPI_DISABLE_CPP_EXCEPTIONS,让 node-addon-api 不通过 C++ exception 抛错,而是要求调用方显式处理错误。有些构建还会打开 -fno-exceptions,这时 C++ 异常机制本身不可用。

同步函数里,参数不合法时通常显式抛一个 JS 异常,然后返回:

代码语言:cpp
复制
Napi::Value Add(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();
    if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
        Napi::TypeError::New(env, "expected two numbers")
            .ThrowAsJavaScriptException();
        return env.Null();
    }
    // ... 正常计算
}

异步投递时,能返回状态的调用必须检查。比如 ThreadSafeFunction::NonBlockingCall 投递失败时,lambda 不会执行,前面 release() 出来的裸指针就需要在失败分支清理:

代码语言:cpp
复制
AsyncCtx* raw = ctx.release();
napi_status status = tsfn.NonBlockingCall([raw]() {
    std::unique_ptr<AsyncCtx> owned(raw);
    owned->process();
});

if (status != napi_ok) {
    delete raw;
    return;
}

错误处理的重点不是把所有地方都包一层 try/catch,而是每条失败路径都有明确收尾:投递失败要释放上下文,Promise 失败要 reject,env 已销毁后不能再碰 JS。

前端类比:这就像从 JS 的 throw/catch 习惯,切换到 Go 的 if err != nil { return err }。啰嗦但安全。

9.1 Cleanup Hook 的执行顺序

Node 进程退出时,napi_env 会被销毁。注册过 napi_add_env_cleanup_hook 的清理函数会按 LIFO(后注册先执行)顺序执行,这和 React useEffect 的 cleanup 顺序一致。

代码语言:cpp
复制
// 伪代码:表达 cleanup hook 的注册和执行顺序,不是完整 API
env.addCleanupHook("cleanupModule", []() {
    // cleanupAll()
    // sdk_release(g_sdk_instance)
    // g_event_bridge.Abort()
});
env.addCleanupHook("ShutdownWatcher", []() {
    // registry.ClaimAll()
    // reject 所有 pending Promise
});
// teardown 时按 LIFO 执行:先 ShutdownWatcher,后 cleanupModule

关键约束:env 销毁后不能再做任何 N-API 操作(不能创建 JS 对象、不能调 JS 函数)。但 SDK 的后台线程可能还在跑,回调进来时 env 已经没了。这就是为什么清理时要先断开全局状态,让迟到的回调读到一个空指针直接 return,而不是去碰已销毁的 env

10. 从 JS 到第三方 dll 的全链路

放到真实扩展里,调用链通常长这样:

全链路时序
全链路时序

注意链路里的关键点:C++ 扩展会调用第三方业务 SDK,而 SDK 是另一个 .dll / .so。SDK 返回给你的内存(字符串、对象指针),应该怎么释放?

  • 直接在 .nodedelete?如果 SDK 在它自己的 dll 堆上分配的,你在 .nodedelete 就是跨堆释放。
  • 不释放?内存泄漏。

这就是后面要讲的核心问题。N-API 桥接把 JS 和 C++ 接上了线,但这条线再往下延伸到第三方 dll 时,内存归属和释放会成为一个独立的坑。

11. 总结

N-API 桥接的核心,是处理 JS 单线程世界和 C++ 多线程世界之间的边界:

  1. 物理形态:.node 文件就是改了后缀的动态库,require() 加载它。
  2. 翻译协议:N-API 提供 napi_value(不透明句柄)表示 JS 值,C++ 不能直接操作,要通过 API 转换;真实工程通常用 node-addon-api 包一层。
  3. 同步桥接:JS 调 C++ 函数直接返回,简单但会阻塞 JS 主线程。
  4. 异步桥接:C++ 起工作线程干活,结果通过 TSFN(跨线程 postMessage 通道)投递回主线程执行。
  5. 生命周期:unique_ptr 因不可拷贝要走 release + 裸指针 + 重建模式穿越 std::function 边界,同时每条失败路径都要兜底清理。
  6. Promise 包装:Deferredresolve/reject 控制权留在 C++,异步完成后结算;外层仍要用 Registry 或状态位防双重回调。
  7. 错误处理:不要依赖 C++ 异常跨边界传播,显式处理返回状态;env 销毁后不能再碰 N-API,靠 cleanup hook LIFO 清理。

理解了这条链路,后面讨论跨模块释放时,链路尽头调用第三方 dll 时的内存释放坑就更容易看清楚。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. JS 和 C++ 互相调用的几种方式
  • 2. .node 文件是什么
  • 3. N-API 是什么
  • 4. 同步桥接
  • 5. 异步桥接的难点
  • 6. TSFN 跨线程投递
  • 7. 异步回调里的生命周期
  • 8. 用 Promise 包装异步结果
  • 9. 错误处理为什么不能用 try/catch
    • 9.1 Cleanup Hook 的执行顺序
  • 10. 从 JS 到第三方 dll 的全链路
  • 11. 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档