3、第一个原生模块:创建项目结构、编写binding.gyp文件、实现简单的C++函数、编译与测试

好,咱们正式开始动手了。

前面两章聊了那么多概念和背景,我知道你肯定手痒了。今天我们就来写一个真正的原生模块——从零开始,一个不落。

我个人习惯是,学任何新技术,第一个模块都别搞太复杂。能跑起来,能看到效果,比什么都重要。所以今天的目标很简单:写一个C++函数,能在Node.js里调用它,让它返回一个字符串。

3.1 项目结构:别小看这个文件夹

先创建项目目录。我建议你跟着我一步步来,别跳步。

mkdir my-first-addon
cd my-first-addon
npm init -y

嗯,这里要注意:npm init -y 会生成一个 package.json。但原生模块的 package.json 和普通Node包有点不一样。你需要手动加一个字段:

{
  "name": "my-first-addon",
  "version": "1.0.0",
  "description": "我的第一个原生模块",
  "main": "build/Release/addon.node",
  "scripts": {
    "build": "node-gyp rebuild",
    "test": "node test.js"
  },
  "gypfile": true
}

看到 "gypfile": true 了吗?这个告诉Node.js,我这个包是用node-gyp编译的。我在项目中遇到过有人忘了加这个,结果 npm install 的时候死活不编译,排查了半天。

接下来创建源码目录:

mkdir src
touch src/addon.cpp
touch binding.gyp
touch test.js

最终的项目结构长这样:

my-first-addon/
├── binding.gyp
├── package.json
├── src/
│   └── addon.cpp
└── test.js

简单吧?但别小看这个结构。我见过很多新手把C++文件和binding.gyp放在同一级目录,结果编译的时候各种路径报错。养成好习惯,源码放 src/,干净利落。

3.2 编写binding.gyp文件:这是你的“施工图纸”

binding.gyp 是node-gyp的配置文件。说白了,它就是告诉编译器:你要编译哪些源文件,用哪些头文件路径,链接哪些库。

来看一个最简版本:

{
  "targets": [
    {
      "target_name": "addon",
      "sources": [ "src/addon.cpp" ],
      "include_dirs": [
        "<!@(node -p \"require('node-addon-api').include\")"
      ],
      "defines": [ "NAPI_VERSION=8" ],
      "cflags!": [ "-fno-exceptions" ],
      "cflags_cc!": [ "-fno-exceptions" ]
    }
  ]
}

我来拆解一下每个字段的意思:

字段 含义 我的经验
target_name 编译产物的名字,最终会生成 addon.node 这个名字要和 require() 里的名字一致
sources 要编译的C++源文件列表 多个文件用逗号隔开,路径相对于binding.gyp
include_dirs 头文件搜索路径 这里用了node-addon-api的路径,省得自己配
defines 预定义宏 NAPI_VERSION=8 表示用N-API第8版
cflags! / cflags_cc! 编译选项,! 表示覆盖默认值 关掉异常处理,能减小生成的二进制体积
⚠️ 注意: 如果你用的是Windows,可能还需要加 "msvs_settings" 来配置Visual Studio的编译选项。不过咱们今天先跑通Linux/macOS,Windows的坑后面章节再填。

3.3 实现简单的C++函数:Hello from C++!

终于到写代码的环节了。打开 src/addon.cpp,输入以下内容:

#include <napi.h>

Napi::String Hello(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  return Napi::String::New(env, "Hello from C++! 这是你的第一个原生模块。");
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set(
    Napi::String::New(env, "hello"),
    Napi::Function::New(env, Hello)
  );
  return exports;
}

NODE_API_MODULE(addon, Init)

这段代码干了三件事:

  1. 定义了一个C++函数 Hello,它接收 CallbackInfo(调用信息),返回一个 Napi::String。
  2. 写了一个初始化函数 Init,把 Hello 函数注册到 exports 对象上,这样JS那边就能通过 addon.hello 调用它。
  3. 用宏 NODE_API_MODULE 把模块注册到Node.js的模块系统里。

你可能会问:为什么不用 node-addon-api 的 Napi::ObjectWrap?嗯,那是面向对象的写法,咱们今天先搞个简单的函数式写法,后面章节再深入。

💡 小技巧: 我个人习惯在写原生模块时,函数名首字母大写,比如 Hello、Add。这样在C++代码里一眼就能看出哪些是暴露给JS的函数,哪些是内部辅助函数。

3.4 编译与测试:见证奇迹的时刻

代码写完了,接下来编译。确保你已经安装了 node-gyp:

npm install -g node-gyp

然后执行编译:

node-gyp configure
node-gyp build

或者一步到位:

node-gyp rebuild

编译成功后,你会看到 build/Release/addon.node 这个文件。这就是你的原生模块二进制文件。

现在写个测试脚本 test.js:

const addon = require('./build/Release/addon.node');

console.log(addon.hello());

运行它:

node test.js

如果一切顺利,你会看到:

Hello from C++! 这是你的第一个原生模块。

🎉 恭喜!你成功创建了第一个原生模块!

从这一刻起,你已经不再是普通的Node.js开发者了。你拥有了调用C++能力、突破JavaScript性能瓶颈的钥匙。

3.5 避坑指南:我踩过的那些坑

编译过程中可能会遇到各种问题。我把常见的坑列出来,你遇到了直接对照排查:

错误信息 原因 解决方案
gyp: No Xcode or CLT version detected! macOS上没装Xcode命令行工具 xcode-select --install
fatal error: 'napi.h' file not found 没装 node-addon-api npm install node-addon-api --save
MSB4019: The imported project "Microsoft.Cpp.Default.props" was not found Windows上没装Visual Studio Build Tools 安装 windows-build-tools 或VS 2022
Error: Module version mismatch. Expected 108, got 115 Node.js版本和编译时的ABI版本不一致 重新编译:node-gyp rebuild

我曾经在一个项目里,因为Node.js从16升级到了18,忘了重新编译,结果模块加载时报版本不匹配。排查了整整两个小时才发现是这么个低级错误。所以记住一条铁律:换了Node.js版本,一定要重新编译原生模块。

3.6 小结与思考

今天的内容其实不多,但每一步都很关键。你学会了:

  • 创建原生模块的标准项目结构
  • 编写 binding.gyp 配置文件
  • 用N-API写一个简单的C++函数
  • 编译并测试原生模块

下节课,我们会深入 binding.gyp 的更多配置选项,比如链接第三方库、条件编译、多平台支持等。但今天先消化这些,动手把第一个模块跑起来。

记住:能跑起来的代码,才是好代码。别光看不练,去敲一遍。