六、在Electron中使用C++扩展

在上一章中,我们讨论Electron的性能相关的话题。其中遗留了使用C++来优化性能的一个选项。其实,不仅仅是因为C++可以优化性能,有些系统底层相关的功能也不得不使用C++来完成。例如,如果你的App需要使用某个读卡器或者特殊的打印机之类的。比如调用一些第三方库,如压缩、加密或者音视频编解码之类的。

总结下来,使用C++扩展会有三个原因:

1、使用C++优化性能,处理计算密集型或大量数据的任务。

2、访问系统底层资源,如调用系统API、硬件驱动等等。

3、调用第三方C++库。

当然,使用C++还可以带来一个好处,那就是降低内存的使用。同样的数据量处理和功能逻辑,C++的内存使用也比JavaScript少很多。由于Electron是建立的Node.js和Chromium基础上的。Chromium本身就是C++开发的,Electron内置的功能模块也是用C++开发的,其实,就是Node.js的C++扩展模块。为Electron写C++扩展,跟为Node.js写C++扩展其实是等价的。硬要说不同,那就是Electron中内置的Node.js的版本与宿主开发环境中的Node.js的版本可能会有些差异。

6.1 基本的编译和使用

6.1.1 编译环境

C/C++是编译型语言,所以,使用C/C++之前,首先需要准备好环境。不过,并不复杂。只需要安装一个node-gyp这个包就可以了。

npm install -g node-gyp

除了node-gyp,你还需要有一整套C/C++编译工具。

如果是Windows系统,需要提前安装一下Visual StudioPython。这两个都可以手动安装,安装方法可以在网上找一下,直接去官网下载安装包安装一下即可。如果想偷懒也可以使用Chocolatey,这是一个Windows下的软件包管理器,如同Linux下的Apt和yum,可以做到一键安装:

choco install python visualstudio2022-workload-vctools -y

另外,需要特别留意的是,Windows下需要用到Powershell

在Linux下是最简单的只需要一个命令就可以完成安装包的依赖,如果是Debian系,就是使用

sudo apt install -y build-essential python3 make

如果是MacOS,则需要使用xcode-select --install命令安装Xcode Command Line Tools

6.1.2 binding.gyp

首先,需要创建一个binding.gyp的文件,内容如下:

{
  "targets": [
    {
      "target_name": "hello",
      "sources": [ "hello.cpp" ]
    }
  ]
}

内容比较简单,就是定义一下最基本的信息。其中可以包含以下信息:

target_name:目标的名称,此名称将用作生成的 Visual Studio 解决方案中的项目名称。

type:可选项:static_library 静态库、executable 可执行文件、shared_library 共享库【默认】。

defines:将在编译命令行中传入的 C 预处理器定义(使用 -D 或 /D 选项)。

include_dirs:C++ 头文件所在的目录。

sources:C++ 源文件。

conditions:适配不同环境配置条件块。

copies:拷贝 dll 动态库到生成目录。

library_dirs: 配置 lib 库目录到 vs 项目中。

libraries:项目依赖的库。

msvs_settings:Visual Studio 中属性设置。

相对比较完整的例子如下:

{
    "targets": [
        {
            "target_name": "addon_name",
            "type": "static_library",
            'defines': ['DEFINE_FOO', 'DEFINE_A_VALUE=value',],
            'include_dirs': [
                './src/include',
                '<!(node -e "require(\'nan\')")' # include NAN in your project
            ],
            'sources': [
                'file1.cc',
                'file2.cc',
            ],
            'conditions': [[
                'OS=="win"', {
                    'copies': [{
                        'destination': '<(PRODUCT_DIR)',
                        'files': ['./dll/*']
                    }],
                    'defines': ['WINDOWS_SPECIFIC_DEFINE',],
                    'library_dirs': ['./lib/'],
                    'link_settings': {
                        'libraries': ['-lyou_sdk.lib']
                    },
                    'msvs_settings': {
                        'VCCLCompilerTool': {
                            'AdditionalOptions': ['/utf-8']
                        }
                    },
                }
            ]],
        },
    ]
}

当然,在咱们这个例子中由于没有任务依赖,所以,不需要用到这么复杂的配置。

6.1.3 C++源文件

hello.cpp文件的内容为:

#include <node.h>

namespace demo {

using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::NewStringType;
using v8::Object;
using v8::String;
using v8::Value;

void Hello(const FunctionCallbackInfo<Value>& args) {
  Isolate* isolate = args.GetIsolate();
  args.GetReturnValue().Set(String::NewFromUtf8(isolate, "world", NewStringType::kNormal).ToLocalChecked());
}

void Initialize(Local<Object> exports) {
  NODE_SET_METHOD(exports, "hello", Hello);
}

NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)

}

其中,

  • NODE_MODULE:这个用来定义一个Node模块的宏。当Node加载该模块的时候,就会按照这个宏的定义调用Initialize函数。
  • Initialize函数的功能是导出符号。可以导出函数和常量两种符号。我们这儿仅仅导出了一个名为hello的函数,然后,其实现函数为Hello
  • Hello函数中,所有的入参和出参都是通过args完成的。在这个例子中,我们仅仅返回了一个字符串world

6.1.4 调用C++扩展

我们只能在主进程中调用C++扩展,在渲染进程中,如果想使用C++扩展,就必须通过ipc调用完成。主进程运行的脚本index.js内容如下:

let { app, BrowserWindow, ipcMain } = require('electron');

let hello_file = "./build/Release/hello";
const hello = require(hello_file);

ipcMain.on("hello", (event) => {
    console.log("call hello in main process");
    event.reply("hello_reply", hello.hello());
});

app.whenReady().then(() => {
    const win = new BrowserWindow({
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false
        }
    })
    win.loadFile("./index.html")
})

在渲染进程中通过ipcRenderer来调用主进程,index.html的内容如下:

<!DOCTYPE html>
<html>

<head>
    <title>demo for c++ in electron</title>
</head>

<body>
    <button onclick="sendMsg()">发送消息</button>
    <br>
    <div id="txt"></div>

    <script lang="javascript">
        let { ipcRenderer } = require("electron");

        ipcRenderer.on("hello_reply", (event, msg) => {
            document.querySelector("#txt").innerHTML += msg + "<br>";
        })
        function sendMsg() {
            ipcRenderer.send("hello");
        }
    </script>
</body>

</html>

6.1.5 编译和使用

有了这两个文件就可以进行编译了。运行下面这个命令即可:

node-gyp configure # 生成配置项,仅需要运行一次。
node-gyp build     # 编译

如果此次你运行,就无法发现,编译出来的C++扩展无法使用。报错信息大致如下:

App threw an error during load
Error: The module '/home/allan/Desktop/Electronå
¥é¨ç¯/demo5.1/build/Release/hello.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 93. This version of Node.js requires
NODE_MODULE_VERSION 101. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).
    at process.func [as dlopen] (node:electron/js2c/asar_bundle:5:1812)
    at Object.Module._extensions..node (node:internal/modules/cjs/loader:1199:18)
    at Object.func [as .node] (node:electron/js2c/asar_bundle:5:1812)
    at Module.load (node:internal/modules/cjs/loader:988:32)
    at Module._load (node:internal/modules/cjs/loader:829:12)
    at Function.c._load (node:electron/js2c/asar_bundle:5:13343)
    at Module.require (node:internal/modules/cjs/loader:1012:19)
    at require (node:internal/modules/cjs/helpers:102:18)
    at Object.<anonymous> (/home/allan/Desktop/Electron入门篇/demo5.1/index.js:4:15)
    at Module._compile (node:internal/modules/cjs/loader:1116:14)
A JavaScript error occurred in the main process

这是因为,我们编译的node.jsElectron中集成的node.js版本不一致导致的。node-gyp编译时使用的是编译环境中的node.js,而Electron中集成的node.js与编译环境中总是会有一些不同。所以,无法在Electron中正常加载使用。解决方法也容易,那就是使用electron-rebuild这个包来编译。

electron-rebuild的安装方法为:

npm add electron-rebuild --save-dev

为了方便使用,我们可以改一下package.json,内容如下:

{
  "name": "demo5.1",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "electron .",
    "build": "electron-rebuild"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "17.3.1",
    "electron-rebuild": "^3.2.9"
  }
}

其中,build命令就是用来替代node-gyp命令进行编译的。electron-rebuild会解决版本不一致问题,然后调用node-gyp完成编译。它解决版本不一致的方法其实是使用Electron提供的头文件,而不是使用node.js提供的头文件。而且会有一些版本上的差异,功能上并无区别。

需要留意的是,默认情况下node-gpy会自动去https://nodejs.org/dist下载node.js的头文件,而electron-rebuild会去https://artifacts.electronjs.org/headers/下载头文件。不过,electronjs中不允许访问目录,只能通过文件完整路径去下载。

接下来,我们只需要执行下面两个命令就可以了:

npm run build # 编译
npm run start # 运行

不出意外的话,你将看到下面的画面:

image-20240610012008447

关于C++扩展的参考资料:

6.2 Vue中使用C++扩展

如果想在vue中使用C++扩展,事情会变得稍微复杂一些。这是因为vue插件做了很多工作,导致我们无法直接通过require来加载C++编译出来的node文件了,这其实是封装带来的坏处。不过,并非做不到,只是没有那么的直白了而已。

首先,我们按照第4章中的步骤创建一个vue的工程出来,然后,在工程目录中在创建一个名为:hello_addon的目录。在该目录中执行以下命令:

npm init --yes

然后,创建两个文件,分别是binding.gyphello.cppindex.js。其中building.gyphello.cpp的内容跟前面完全相同。代码就不再贴出来了,想看的可以翻看前面的章节或者直接打开demo5.2的源码看一下。index.js有了较大不同,内容变为:

module.exports = require('./build/Release/hello_addon');

只有一行,那就是把hello_addon.node这个扩展导出来,以便使用者调用。到这儿,扩展部分就写好了。接下来,我们回到vue工程的根目录,执行下面这条命令:

vue install ./hello_addon --save

意思就是,把子目录hello_addon这个包添加到进来vue工程中来。你打开package.json,就会发现hello_addon这个依赖包已经被添加进来了。内容大致如下:

... ...
  "dependencies": {
    "core-js": "^3.8.3",
    "hello_addon": "file:hello_addon",
    "vue": "^3.2.13"
  },
... ...

接下来,我们在background.js中添加调用hello_addon的代码,内容如下:

... ...
protocol.registerSchemesAsPrivileged([
  { scheme: 'app', privileges: { secure: true, standard: true } }
])

let hello = require("hello_addon")
console.log("call hello_addon in main process: ", hello.hello())

async function createWindow() {
    ... ...

如果,你这个时候运行npm run electron:serve这条指令的话,你就会看到下面的错误信息:

image-20240610225022205

即使在这儿我们把hello_addon的路径改为全路径依旧会报找不到。这是因为我们在运行的时候使用webpack,加载的时候不会再直接加载,而是经过了webpack的处理,导致无法加载成功。为了解决这个问题,我们需要在vue.config.js中做一点修改,内容如下:

const { defineConfig } = require('@vue/cli-service')

module.exports = defineConfig({
  transpileDependencies: true,
  pluginOptions: {
    electronBuilder: {
      externals: ["hello_addon"],
    },
  },
})

其中,第7行是最关键的一行,它的意思是告诉webpack,它是一个外部模块,不要打包它。在官方文档也有相关的说明:

image-20240610225917283

不过,官方的文档并没有说明。这个配置是针对包的,而非文件。所以,很多人会在这个地方出现理解上的偏差,导致卡壳。

改完这个配置文件以后,再次执行npm run electron:serve就不会再有错误了。

最后。我们通过命令npm run electron:build打包以后看一下,你会发现打包好的程序也是可以正常使用的。但是,如果你查看一下app.asar文件就会发现有个不大不小的安全问题:

image-20240610234523268

打包以后的asar文件中居然存在hello.cpp这个源文件。这是因为webpack并不知道cpp文件不能打包进来。我们可以在vue.config.js中将这个信息告诉给它。

const { defineConfig } = require('@vue/cli-service')

module.exports = defineConfig({
  transpileDependencies: true,
  pluginOptions: {
    electronBuilder: {
      externals: ["hello_addon"],
      builderOptions: {
        files: [
          "**/*",
          "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
          "!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}",
          "!**/node_modules/*.d.ts",
          "!**/node_modules/.bin",
          "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj,cpp,hpp,c,h}",
          "!.editorconfig",
          "!**/._*",
          "!**/node_gyp_bins",
          "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}",
          "!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}",
          "!**/{appveyor.yml,.travis.yml,circle.yml}",
          "!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}"
        ]
      },
    },
  },
})

其中,第15行就是告诉webpack,这些扩展名的文件不需要打包到asar文件中去的。

6.3 C++扩展中的异步

正常而言,我们调用JavaScript的话,是同一线程的,所以,对于性能有提升,但是有限。因为这样做依旧无法有效利用CPU的多核心。于是,我们需要在C++中使用多线程,使用异步处理的方式达到多核心同时运行的效果。既然使用了异步,执行完成后如何把执行完成的结果返回给JavaScript就是一个问题了。

在JavaScript中,有两种形式可以实现异步执行时返回结果。

  • 使用callback函数。

    在调用函数时传入一个函数指针,当处理完成后,由异步处理函数来调用这个callback函数返回执行结果。

  • 使用Promise对象。

    函数正常返回,但是,返回的不是结果,而是一个Promise对象,调用者再通过Promise对象拿到执行的结果。

本质上二者是相同的,但是,由于使用方式的不同,二者又具有不同的特点。其中,callback函数本身并不代表异步。只是说,如果callback函数可以用于异步。换句话说,同步也可以使用callback。另外,callback很容易出现回调地狱,也就是因为回调中嵌套回调导致代码变得复杂不容易理解。而Promise对象不存在这个问题。因为Promise对象本身是同步的,只有获取Promise对象中的结果的时候才是异步的。也就避免了嵌套。所以,推荐使用Promise对象来实现异步。

6.3.1 使用callback函数实现异步

基于demo5.2,我们把hello扩展中的cpp改写为:

#include <node.h>
#include "callback.h"

namespace demo
{

  using v8::FunctionCallbackInfo;
  using v8::Isolate;
  using v8::Local;
  using v8::NewStringType;
  using v8::Object;
  using v8::String;
  using v8::Value;

  v8::Global<v8::Function> func_callback;
  void worker_thread_func(uv_work_t *req)
  {
    callback::request_data *request = (callback::request_data *)req->data;
    uv_sleep(5000);
    request->result = "world";
  }
  void HelloWithCallback(const FunctionCallbackInfo<Value> &args)
  {
    v8::Isolate *isolate = args.GetIsolate();
    v8::Local<v8::Context> context = v8::Context::New(isolate);
    if (!callback::check_request_param(isolate, context, args))
    {
      args.GetReturnValue().Set(v8::Boolean::New(isolate, false));
      return;
    }
    callback::begin_worker_thread(isolate, context, args, worker_thread_func);
    args.GetReturnValue().Set(v8::Boolean::New(isolate, true));
  }

  void Initialize(Local<Object> exports)
  {
    NODE_SET_METHOD(exports, "HelloWithCallback", HelloWithCallback);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)

}

我们导出了一个名为HelloWithCallback的函数,这个函数里面并没有实际的逻辑,而是开启了一个worker线程。这个线程的逻辑是在callback.h里面实现的。

我们把实际的逻辑放在了函数worker_thread_func里面,这个函数是运行在worker线程中,所以,即使运行很长时间也不会有什么影响。

callback.h的内容如下:

#include <string>
#include <v8.h>
#include <uv.h>
namespace callback
{
    struct request_data
    {
        std::string argv;
        std::string result;
        v8::Global<v8::Function> func_callback;

        request_data(v8::Isolate *isolate, v8::Local<v8::String> request_json, v8::Local<v8::Function> func)
        {
            argv = *v8::String::Utf8Value(isolate, request_json);
            func_callback.Reset(isolate, func);
        }
    };

    void worker_callbakc_func(uv_work_t *req, int status)
    {
        if (status == UV_ECANCELED)
        {
            request_data *request = (request_data *)req->data;
            delete req;
            request->func_callback.Reset();
            delete request;
            return;
        }
        v8::Isolate *isolate = v8::Isolate::GetCurrent();
        request_data *request = (request_data *)req->data;
        delete req;

        // 构造JS回调函数的参数
        v8::Local<v8::Value> argv[1];
        argv[0] = v8::String::NewFromUtf8(isolate, request->result.c_str()) .ToLocalChecked();

        v8::TryCatch try_catch(isolate);
        v8::Local<v8::Function> cb = v8::Local<v8::Function>::New(isolate, request->func_callback);
        cb->Call(isolate->GetCurrentContext(), Null(isolate), 1, argv);
        if (try_catch.HasCaught())
        {
            fprintf(stderr, "callback failure");
        }
        // 回收资源
        request->func_callback.Reset();
        delete request;
    }

    //检查请求参数
    bool check_request_param(v8::Isolate *isolate, v8::Local<v8::Context> &context, const v8::FunctionCallbackInfo<v8::Value> &args)
    {
        // 判断入参是否满足条件
        v8::TryCatch try_catch(isolate);
        if (args.Length() < 2 || !args[0]->IsString() || !args[1]->IsFunction())
            return false;
        v8::MaybeLocal<v8::Value> jsonData = v8::JSON::Parse(context, args[0]->ToString(context).ToLocalChecked());
        if (jsonData.IsEmpty())
            return false;
        if (try_catch.HasCaught())
            return false;
        return true;
    }
    // 启动线程
    void begin_worker_thread(v8::Isolate *isolate, v8::Local<v8::Context> &context, const v8::FunctionCallbackInfo<v8::Value> &args, uv_work_cb work_cb)
    {
        //构造uv_work_t
        request_data *req = new request_data(isolate, args[0]->ToString(context).ToLocalChecked(), v8::Local<v8::Function>::Cast(args[1]));
        uv_work_t *uv_req = new uv_work_t();
        uv_req->data = req;
        //启动uv线程
        uv_queue_work(uv_default_loop(), uv_req, work_cb, worker_callbakc_func);
    }
}

在这个文件中,我们启动的一个uv线程,然后,将参数传入到uv线程中,在uv线程中执行我们函数的功能,然后再把结果通过回调函数返回给JavaScript。

使用方法跟普通的js函数无异:

const hello = require("hello");

ipcMain.on("hello", (event) => {
    hello.HelloWithCallback(JSON.stringify({}), (result) => {
        event.reply("hello_ack", result);
    })
}

6.3.2 使用Promise对象实现异步

同样基于demo5.2,我们把hello扩展中的cpp改写为:

#include <node.h>
#include "resolver.h"

namespace demo
{
  using v8::FunctionCallbackInfo;
  using v8::Isolate;
  using v8::Local;
  using v8::NewStringType;
  using v8::Object;
  using v8::String;
  using v8::Value;

  void worker_thread_Resolver(uv_work_t *req)
  {
    callback::request_data *request = (callback::request_data *)req->data;
    uv_sleep(5000);
    auto tid = syscall(SYS_gettid);
    request->result = "world, " + std::to_string(tid);
  }

  void HelloAsync(const FunctionCallbackInfo<Value> &args)
  {
    v8::Isolate *isolate = args.GetIsolate();
    v8::Local<v8::Context> context = v8::Context::New(isolate);
    Local<v8::Promise::Resolver> resolver = v8::Promise::Resolver::New(context).ToLocalChecked();

    resolver::begin_worker_thread(args, resolver, worker_thread_Resolver);
    args.GetReturnValue().Set(resolver->GetPromise());
  }

  void Initialize(Local<Object> exports)
  {
    NODE_SET_METHOD(exports, "HelloAsync", HelloAsync);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
}

resolver.h的内容如下:

#include <node.h>
#include <thread>

namespace resolver
{
    struct request_data
    {
        std::string argv;
        std::string result;
        v8::Global<v8::Promise::Resolver> resolver;

        request_data(v8::Isolate *isolate, v8::Local<v8::String> request_json, v8::Local<v8::Promise::Resolver> _resolver)
        {
            argv = *v8::String::Utf8Value(isolate, request_json);
            resolver.Reset(isolate, _resolver);
        }
    };

    using v8::FunctionCallbackInfo;

    void worker_callbakc_func(uv_work_t *req, int status)
    {
        if (status == UV_ECANCELED)
        {
            request_data *request = (request_data *)req->data;
            delete req;
            request->resolver.Reset();
            delete request;
            return;
        }
        v8::Isolate *isolate = v8::Isolate::GetCurrent();
        request_data *request = (request_data *)req->data;
        delete req;

        v8::TryCatch try_catch(isolate);
        v8::Local<v8::Promise::Resolver> resolver = v8::Local<v8::Promise::Resolver>::New(isolate, request->resolver);
        v8::Local<v8::Value> value = v8::String::NewFromUtf8(isolate, request->result.c_str()).ToLocalChecked();
        resolver->Resolve(isolate->GetCurrentContext(), value);
        if (try_catch.HasCaught())
        {
            fprintf(stderr, "resolver failure");
        }
        // 回收资源
        request->resolver.Reset();
        delete request;
    }

    void begin_worker_thread(const FunctionCallbackInfo<v8::Value> &args, v8::Local<v8::Promise::Resolver> &resolver, uv_work_cb work_cb)
    {
        v8::Isolate *isolate = args.GetIsolate();
        v8::Local<v8::Context> context = v8::Context::New(isolate);
        request_data *req = new request_data(isolate, args[0]->ToString(context).ToLocalChecked(), resolver);
        uv_work_t *uv_req = new uv_work_t();
        uv_req->data = req;
        //启动uv线程
        uv_queue_work(uv_default_loop(), uv_req, work_cb, worker_callbakc_func);
    }
}

可以看到,跟Callback最大的不同仅仅是把回调函数换成了Promise对象而已。另外,需要留意的是,无论是CallBack还是Promise对象回调回去以后都是在JavaScript的主线程里面。

6.4 使用nan编写C++扩展

nan是一个node.js的插件,可以用来简化C++扩展的编写,尤其是异常的处理。使用nan编写C++插件只需要按照下面几步即可:

第一步、创建一个普通的npm工程

mkdir nan_hello
cd nan_hello
npm init --yes
npm install --save-dev nan electron-rebuild electron@17.3.1

注意,这儿假设你已经搭建好node-gyp。如果没有搭建node-gyp编译环境的话,参考6.1.1

第二步、创建binding.gyp,内容如下:

{
  "targets": [
    {
      "target_name": "hello",
      "sources": [ "hello.cpp" ],
      "include_dirs": [
        "<!(node -e \"require('nan')\")"
      ]
    }
  ]
}

注意这儿的include_dirs,需要通过这个指令让编译器在编译阶段找到nas的头文件。

第三步、创建hello.cpp,内容如下:

#include <nan.h>

void Hello(const Nan::FunctionCallbackInfo<v8::Value>& info) {
  v8::Local<v8::Context> context = info.GetIsolate()->GetCurrentContext();

  if (info.Length() != 2 && (!info[0]->IsString() || !info[1]->IsNumber())){
    Nan::ThrowTypeError("Invlid arguments");
    return;
  }

  Nan::Utf8String arg0(info[0]->ToString(context).ToLocalChecked());
  int arg1 = info[1]->NumberValue(context).FromJust();

  auto result = std::string(*arg0) + " world " + std::to_string(arg1);
  info.GetReturnValue().Set(Nan::New(result).ToLocalChecked());
}

void Init(v8::Local<v8::Object> exports) {
  v8::Local<v8::Context> context = exports->GetCreationContext().ToLocalChecked();
  exports->Set(context, Nan::New("hello").ToLocalChecked(),
               Nan::New<v8::FunctionTemplate>(Hello)->GetFunction(context) .ToLocalChecked());
}

NODE_MODULE(hello, Init)

如果你使用的是vscode,编辑器会找不到nan.h这个头文件,解决方法很简单。在.vscode目录建一个名为c_cpp_properties.json的文件,内容为:

{
    "configurations": [
        {
            "name": "Linux",
            "includePath": [
                "${default}",
                "${HOME}/.electron-gyp/17.3.1/include/node/",
                "node_modules/nan"
            ]
        }
    ],
    "version": 4
}

注意:electron-gyp路径中存在版本号,找你机器上存在的版本即可。

第四步:创建测试脚本index.js

hello = require('./build/Release/hello');
console.log(hello.hello("hello", 1));
try {
    console.log(hello.hello("hello"));
} catch (error) {
    console.log('发生了一个错误:', error.message)
}

注意:我们这儿为了演示,并没有使用Electron。

第五步:编译运行

首先,在package.json中新增编译脚本:

{
  "name": "nan_hello",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "electron-rebuild",
    "start": "electron ."
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "^17.3.1",
    "electron-rebuild": "^3.2.9",
    "nan": "^2.20.0"
  }
}

然后,依次运行一下命令即可:

npm run build
npm run start

image-20240616003550651

如果想要导出给其他包使用,还需要写一个export的js——export.js,内容为:

module.exports = require('./build/Release/hello');

另外,在package.json中使用exports字段新增导出脚本信息:

{
  "name": "nan_hello",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "exports": [
    "./export.js"
  ],
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "electron-rebuild",
    "start": "electron ."
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "^17.3.1",
    "electron-rebuild": "^3.2.9",
    "nan": "^2.20.0"
  }
}

至于如何在其他包中使用,具体可以参考demo6.4,这儿不再赘述。

当然,nan并非只能用来写写简单的helloword,它跟原生API一样,也可以回调JS函数等等。其中,这些能力建议阅读nan的项目页面提供的文档和例子来学习。其项目地址为:https://github.com/nodejs/nan 。不过,nan的封装其实并不是特别彻底,尤其的变量的使用并不是很统一,使用方式也不太符合C++原始习惯,所以,并不推荐使用。

6.5 使用node-addon-api编写C++扩展

nan类似,node-addon-api也是简化C++扩展的编写。不过,它比nan更加简约一些。

使用node-addon-api只需要按照以下几步即可:

第一步:创建一个普通的npm工程

mkdir naa_hello
cd naa_hello
npm init --yes
npm install --save-dev node-addon-api electron-rebuild electron@17.3.1

第二步、创建binding.gyp

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

第三步、创建hello.cpp

#include <napi.h>

napi_value Hello(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();

  if (info.Length() != 2 || !info[0].IsString() || !info[1].IsNumber()) {
    napi_throw_type_error(env, NULL, "Invlid arguments");
    return NULL;
  }
  auto arg0 = info[0].As<Napi::String>();
  auto arg1 = info[1].As<Napi::Number>().Int32Value();
  auto result = (std::string)arg0 + " world " + std::to_string(arg1);
  return Napi::String::New(env, result);
}

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(hello, Init)

我们看到,无论是传入参数,还是返回值,又或者是导出函数,都比原生接口和Nan简单了很多。而且其封装非常统一,且用法也很符合C++原始的习惯。

至于编译使用,跟nan完全相同,这儿就不再赘述了。如有疑问,可以参考源码demo6.5

6.6 C++中调用Js函数

无论是使用原生API,还是使用插件都可以实现在C++中调用Js函数,其中,在6.3 C++扩展中的异步我们使用的就是原生API调用的js函数,非常的麻烦且复杂。这次我们演示一下,如何使用node-addon-api调用js函数。本小节的代码均在demo6.6中。

运行效果为:

image-20240616021702924

6.6.1 同步调用

核心代码如下:

#include <napi.h>

napi_value CallJsFunction(const Napi::CallbackInfo &info) {
  Napi::Env env = info.Env();
  Napi::Function cb = info[0].As<Napi::Function>();
  cb.Call(env.Global(), {Napi::String::New(env, "hello world")});
  return nullptr;
}

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

NODE_API_MODULE(call_js_function, Init)

在js中调用的方法为:

addon = require('./build/Release/call_js_function');
addon.CallJsFunction((msg)=>{
    console.log("msg:", msg);
});

很明显,使用node-addon-api插件,要比原生的简约了非常多,几乎没有冗余的代码了。

6.6.2 使用Callback函数异步调用

如果你需要在C++中异步执行,可以使用Napi::AsyncWorker这个类。用法也非常简单,核心代码为:

#include <napi.h>
#include <thread>

class MyWorker : public Napi::AsyncWorker {
 public:
  MyWorker(Napi::Function& callback) : Napi::AsyncWorker(callback) {}

  void Execute() {
    // 在工作者线程中执行任务
    std::this_thread::sleep_for(std::chrono::seconds(5));
  }

  void OnOK() {
    // 执行完成, 回调js函数
    Callback().Call({Napi::String::New(Env(), "run finish in c++")});
  }
};

napi_value AsyncCallJsFunction(const Napi::CallbackInfo &info) {
  Napi::Function cb = info[0].As<Napi::Function>();
  MyWorker* myWorker = new MyWorker(cb);
  myWorker->Queue();
  return nullptr;
}

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

NODE_API_MODULE(call_js_function, Init)

调用方法为:

addon = require('./build/Release/call_js_function');
addon.AsyncCallJsFunction((msg)=>{
    console.log("AsyncCallJsFunction msg:", msg);
});
console.log("call AsyncCallJsFunction finish.");

由于我们在C++中sleep了5秒,所以,我们会先看到call AsyncCallJsFunction finish.,然后再看到AsyncCallJsFunction msg: run finish in c++

6.6.3 使用Promise异步调用

如果想使用Promise也很简单,核心代码为:

#include <napi.h>
#include <thread>
#include <sstream>

class MyPromiseWorker : public Napi::AsyncWorker
{
public:
  MyPromiseWorker(const Napi::Env &env, int _a, int _b) : Napi::AsyncWorker(env), m_deferred(env), a(_a), b(_b) {}
  Napi::Promise GetPromise() { return m_deferred.Promise(); }
  void Execute() {
    if (b < 0.00001) {
      SetError("Cannot divide by zero");
      return;
    }
    // 在工作者线程中执行任务
    std::this_thread::sleep_for(std::chrono::seconds(5));
    c = a / b;
  }

  void OnOK() {
    // 执行成功, 回调js函数
    std::stringstream ss;
    ss << "run finish in c++:" << a << " / " << b << " = " << std::to_string(c);
    m_deferred.Resolve(Napi::String::New(Env(), ss.str()));
  }

  void OnError(const Napi::Error &err) {
    // 执行失败, 回调js函数
    m_deferred.Reject(err.Value());
  }

private:
  Napi::Promise::Deferred m_deferred;
  double a, b;
  double c;
};

napi_value CallJsFunctionPromise(const Napi::CallbackInfo &info)
{
  MyPromiseWorker *myWorker = new MyPromiseWorker(info.Env(), info[0].ToNumber(), info[1].ToNumber());
  myWorker->Queue();
  return myWorker->GetPromise();
}

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

NODE_API_MODULE(call_js_function, Init)

相对于callback的方式,我们需要多定义一个Napi::Promise::Deferred对象,然后将这个对象返回给JavaScript,最后在执行完成或者出错的时候再通过该对象回调到JavaScript里面去。

在JavaScript中的使用方法为:

addon = require('./build/Release/call_js_function');

addon.CallJsFunctionPromise(1, 2).then((msg)=>{
    console.log("CallJsFunctionPromise1 msg:", msg);
});
console.log("call CallJsFunctionPromise1 finish.");

addon.CallJsFunctionPromise(1, 0).then((msg)=>{
    console.log("CallJsFunctionPromise2 msg:", msg);
}).catch((err)=>{
    console.log("CallJsFunctionPromise2 err:", err);
});
console.log("call CallJsFunctionPromise2 finish.");

6.6.4 在C++中给JavaScript发送消息

有时候C++需要不定期调用JavaScript,比如监听某个事件,如果电源变化事件、或者网络消息等等。这时就需要C++给JavaScript发送消息的能力。这也不难,借助Napi::ThreadSafeFunction即可实现,核心代码如下:

#include <napi.h>
#include <thread>
#include <sstream>

class MySendWorker : public Napi::AsyncWorker
{
public:
  MySendWorker(const Napi::Env &env, Napi::Function& callback) :
        Napi::AsyncWorker(env),
        callback_(callback),
        tsfn_{Napi::ThreadSafeFunction::New(env, callback, "MySendWorker", 0, 1)} {}

  virtual ~MySendWorker() override { tsfn_.Release(); }

  void Execute() override {
    // 在工作者线程中执行任务
    for(int i = 0; i < 5; i++){
      std::this_thread::sleep_for(std::chrono::seconds(1));

      napi_status status = tsfn_.BlockingCall([i](Napi::Env env, Napi::Function js_callback) {
          js_callback.Call({
            Napi::String::New(env, "msg"),
            Napi::String::New(env, "sleep " + std::to_string(i+1))
          });
      });
      if (status != napi_ok) {
        SetError("Napi::ThreadSafeNapi::Function.BlockingCall() failed");
        return;
      }
    }
  }

  void OnError(const Napi::Error &error) {
    // 执行失败, 回调js函数
    callback_.Call({Napi::String::New(Env(), "error"), error.Value()});
  }

private:
  Napi::Function callback_;
  Napi::ThreadSafeFunction tsfn_;
};

napi_value CallJsFunctionSend(const Napi::CallbackInfo &info)
{
  Napi::Function callback = info[0].As<Napi::Function>();
  MySendWorker *myWorker = new MySendWorker(info.Env(), callback);
  myWorker->Queue();
  return nullptr;
}

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

NODE_API_MODULE(call_js_function, Init)

JavaScript中的使用方法为:

addon = require('./build/Release/call_js_function');
const { EventEmitter } = require('node:events');
class Receiver extends EventEmitter {
    constructor () {
      super();
      addon.CallJsFunctionSend(this.emit.bind(this));
    }
  }

  const receiver = new Receiver();

  receiver.on('msg', (msg) => {
    console.log('receive: ', msg);
  });