JS `Electron` `Native` `Addons`:用 C++ 扩展 Electron 功能

各位同学,今天咱们聊聊Electron的“野路子”玩法——Native Addons。简单来说,就是用C++给Electron插上翅膀,让你的应用飞起来!

开场白:为什么需要 Native Addons?

Electron本身基于JavaScript和Node.js,很多时候已经足够强大了。但有些场景,JS就显得力不从心了:

  • 性能瓶颈: 大量计算密集型任务,例如图像处理、音视频编解码、加密解密等,JS的效率可能会拖后腿。
  • 硬件交互: 直接操作底层硬件,例如访问USB设备、串口通信等,JS原生API可能不足。
  • 复用现有代码: 已经有成熟的C/C++库,不想用JS重写一遍。
  • 保密性: 一些核心算法不想暴露源码,C++编译后的二进制文件更难被逆向。

这时候,Native Addons就派上用场了。它可以让你用C++编写高性能模块,然后在Electron应用中像普通JS模块一样调用。

第一部分:Native Addons 的基本概念

Native Addons本质上是Node.js的插件,Electron应用可以像使用Node.js模块一样使用它们。其核心在于将C++代码编译成特定格式的动态链接库,并提供JS接口。

1. 关键技术:Node-API (N-API)

Node-API是Node.js官方提供的一套稳定的C API,用于构建Native Addons。使用N-API编写的Addons,在不同Node.js版本之间具有更好的兼容性,降低了升级维护成本。

2. 构建工具:node-gyp

node-gyp是Node.js官方提供的构建工具,用于编译Native Addons。它会根据你的系统环境和Node.js版本,自动生成构建脚本,并调用相应的编译器(例如GCC、Visual Studio)来编译C++代码。

3. binding.gyp 文件

binding.gyp是一个JSON格式的配置文件,用于告诉node-gyp如何编译你的Native Addon。它定义了源文件、头文件、编译选项等信息。

第二部分:手把手创建一个 Native Addon

咱们通过一个简单的例子来演示如何创建一个Native Addon,实现一个加法函数。

1. 项目初始化

创建一个新的项目目录,例如 my-addon,然后在该目录下执行以下命令:

npm init -y
npm install node-gyp --save-dev

2. 创建 binding.gyp 文件

在项目根目录下创建 binding.gyp 文件,内容如下:

{
  "targets": [
    {
      "target_name": "my_addon",
      "sources": [ "my_addon.cc" ],
      "include_dirs": [
        "<!@(node -p "require('node-addon-api').include")"
      ],
      "dependencies": [
        "<!(node -p "require('node-addon-api').gyp")"
      ],
      "cflags!": [ "-fno-exceptions" ],
      "cflags_cc!": [ "-fno-exceptions" ],
      "defines": [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ]
    }
  ]
}

解释:

  • target_name: 指定Addon的名称,生成的动态链接库会以这个名称命名。
  • sources: 指定C++源文件。
  • include_dirs: 指定头文件搜索路径,这里包含了node-addon-api的头文件。
  • dependencies: 指定依赖项,这里依赖node-addon-api
  • cflags!: 禁止编译C++异常,提升效率。
  • defines: 定义宏,禁用C++异常。

3. 创建 C++ 源文件 my_addon.cc

在项目根目录下创建 my_addon.cc 文件,内容如下:

#include <napi.h>

Napi::Value Add(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();

  if (info.Length() < 2) {
    Napi::TypeError::New(env, "Wrong number of arguments").ThrowAsJavaScriptException();
    return env.Null();
  }

  if (!info[0].IsNumber() || !info[1].IsNumber()) {
    Napi::TypeError::New(env, "Wrong arguments").ThrowAsJavaScriptException();
    return env.Null();
  }

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

  return num;
}

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

NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init)

解释:

  • #include <napi.h>: 引入Node-API头文件。
  • Add(const Napi::CallbackInfo& info): 加法函数,接收两个参数,返回它们的和。
  • Napi::Env env: 获取当前Node.js环境。
  • info.Length(): 获取传入参数的个数。
  • info[0].IsNumber(): 判断第一个参数是否为数字。
  • info[0].As<Napi::Number>().DoubleValue(): 将第一个参数转换为double类型。
  • Napi::Number::New(env, a + b): 创建一个新的Number对象,值为a + b
  • Init(Napi::Env env, Napi::Object exports): 模块初始化函数,将Add函数导出为add
  • NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init): 注册模块。

4. 构建 Native Addon

在项目根目录下执行以下命令:

node-gyp configure
node-gyp build

如果一切顺利,会在 build/Release 目录下生成 my_addon.node 文件,这就是编译好的Native Addon。

5. 在 Electron 应用中使用 Native Addon

创建一个简单的Electron应用,例如:

// main.js
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');

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

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: true,
      contextIsolation: false
    }
  });

  win.loadFile('index.html');
}

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

ipcMain.handle('add', async (event, a, b) => {
  const result = myAddon.add(a, b);
  return result;
});
<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>My Addon Example</title>
  </head>
  <body>
    <h1>My Addon Example</h1>
    <input type="number" id="num1" value="10">
    +
    <input type="number" id="num2" value="20">
    =
    <span id="result"></span>

    <script>
      const { ipcRenderer } = require('electron');

      async function calculate() {
        const num1 = document.getElementById('num1').value;
        const num2 = document.getElementById('num2').value;
        const result = await ipcRenderer.invoke('add', Number(num1), Number(num2));
        document.getElementById('result').textContent = result;
      }

      document.getElementById('num1').addEventListener('change', calculate);
      document.getElementById('num2').addEventListener('change', calculate);

      calculate();
    </script>
  </body>
</html>

解释:

  • require('./build/Release/my_addon.node'): 引入编译好的Native Addon。
  • myAddon.add(a, b): 调用Addon中的add函数。
  • 使用ipcMainipcRenderer进行主进程和渲染进程的通信,将计算任务交给主进程的Native Addon执行。

6. 运行 Electron 应用

在项目根目录下执行以下命令:

npm start

如果一切正常,你应该能看到一个简单的加法计算器,计算结果由Native Addon提供。

第三部分:更高级的 Native Addons 用法

除了简单的函数调用,Native Addons还可以实现更复杂的功能,例如:

1. 异步操作

对于耗时操作,应该使用异步方式执行,避免阻塞主线程。Node-API提供了AsyncWorker类,可以方便地实现异步任务。

// my_addon.cc
#include <napi.h>
#include <thread>
#include <chrono>

class MyWorker : public Napi::AsyncWorker {
 public:
  MyWorker(Napi::Function& callback, int delay)
      : Napi::AsyncWorker(callback), delay_(delay) {}

  ~MyWorker() {}

  // This function will be invoked in a worker thread
  void Execute() {
    // Simulate a long-running task
    std::this_thread::sleep_for(std::chrono::milliseconds(delay_));
    result_ = "Task completed after " + std::to_string(delay_) + "ms";
  }

  // This function will be invoked in the main thread
  void OnOK() {
    Napi::HandleScope scope(Env());
    Callback().Call({Env().Null(), Napi::String::New(Env(), result_)});
  }

 private:
  int delay_;
  std::string result_;
};

Napi::Value DoSomethingAsync(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();

  if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsFunction()) {
    Napi::TypeError::New(env, "Wrong arguments").ThrowAsJavaScriptException();
    return env.Null();
  }

  int delay = info[0].As<Napi::Number>().Int32Value();
  Napi::Function callback = info[1].As<Napi::Function>();

  MyWorker* worker = new MyWorker(callback, delay);
  worker->Queue();

  return env.Undefined();
}

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

NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init)
// main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');

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

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: true,
      contextIsolation: false
    }
  });

  win.loadFile('index.html');

  myAddon.doSomethingAsync(2000, (err, result) => {
    if (err) {
      console.error(err);
    } else {
      console.log(result); // Task completed after 2000ms
    }
  });

  console.log("This will be printed before the result from the addon.");
}

app.whenReady().then(() => {
  createWindow();
});

解释:

  • MyWorker类继承自Napi::AsyncWorker,用于执行异步任务。
  • Execute()方法在工作线程中执行耗时操作。
  • OnOK()方法在主线程中执行回调函数,传递结果。
  • Queue()方法将任务加入到Node.js的事件循环中。

2. 对象和类

Native Addons可以创建和操作C++对象,并在JS中访问它们的属性和方法。

// my_addon.cc
#include <napi.h>

class MyObject : public Napi::ObjectWrap<MyObject> {
 public:
  static Napi::Object Init(Napi::Env env, Napi::Object exports) {
    Napi::Function func = DefineClass(env, "MyObject", {
      InstanceMethod("getValue", &MyObject::GetValue),
      InstanceMethod("setValue", &MyObject::SetValue),
    });

    Napi::FunctionReference* constructor = new Napi::FunctionReference();
    *constructor = Napi::Persistent(func);
    env.SetInstanceData(constructor);

    exports.Set("MyObject", func);
    return exports;
  }

  MyObject(const Napi::CallbackInfo& info) : Napi::ObjectWrap<MyObject>(info) {
    Napi::Env env = info.Env();
    if (info.Length() > 0 && info[0].IsNumber()) {
      this->value_ = info[0].As<Napi::Number>().DoubleValue();
    } else {
      this->value_ = 0;
    }
  }

 private:
  Napi::Value GetValue(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();
    Napi::Number num = Napi::Number::New(env, this->value_);
    return num;
  }

  Napi::Value SetValue(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    if (info.Length() < 1 || !info[0].IsNumber()) {
      Napi::TypeError::New(env, "Wrong arguments").ThrowAsJavaScriptException();
      return env.Null();
    }

    this->value_ = info[0].As<Napi::Number>().DoubleValue();
    return env.Undefined();
  }

  double value_;
};

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  return MyObject::Init(env, exports);
}

NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init)
// main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');

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

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: true,
      contextIsolation: false
    }
  });

  win.loadFile('index.html');

  const myObject = new myAddon.MyObject(10);
  console.log(myObject.getValue()); // 10
  myObject.setValue(20);
  console.log(myObject.getValue()); // 20
}

app.whenReady().then(() => {
  createWindow();
});

解释:

  • MyObject类继承自Napi::ObjectWrap<MyObject>,用于创建C++对象。
  • DefineClass()方法定义类的属性和方法。
  • InstanceMethod()方法将C++方法暴露给JS。
  • 可以在JS中使用new myAddon.MyObject()创建对象,并调用其方法。

第四部分:常见问题和注意事项

  • 版本兼容性: 使用Node-API可以提高兼容性,但仍然需要关注Node.js和Electron的版本更新,及时更新你的Addons。
  • 内存管理: C++需要手动管理内存,注意避免内存泄漏。可以使用智能指针等技术来简化内存管理。
  • 异常处理: 在C++代码中捕获异常,并将其转换为JS异常,方便JS代码处理。
  • 调试: 可以使用GDB等工具调试C++代码,或者使用Node.js的调试器来调试JS和C++代码。
  • 安全: 注意输入验证,避免安全漏洞。

表格总结:Node-API 常用 API

API 描述
Napi::Env 表示Node.js环境。
Napi::Object 表示JS对象。
Napi::String 表示JS字符串。
Napi::Number 表示JS数字。
Napi::Boolean 表示JS布尔值。
Napi::Function 表示JS函数。
Napi::Array 表示JS数组。
Napi::Value 表示JS值的基类。
Napi::CallbackInfo 包含函数调用的信息,例如参数和上下文。
Napi::AsyncWorker 用于执行异步任务。

结束语:Native Addons 的未来

Native Addons是Electron生态系统中一个重要的组成部分,它可以让你突破JS的限制,充分利用C++的强大能力。随着Node-API的不断完善和Electron的持续发展,Native Addons将会在更多场景下发挥重要作用。希望今天的讲座能帮助大家更好地理解和使用Native Addons,创造出更强大的Electron应用!

记住,能力越大,责任越大,用好你的C++超能力,打造出令人惊艳的应用吧!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注