首页
/ Electron 中编写 Windows C++ 原生扩展:基于 Win32 API 从零构建原生界面与跨线程回调

Electron 中编写 Windows C++ 原生扩展:基于 Win32 API 从零构建原生界面与跨线程回调

2026-09-07 19:39:44作者:翟萌耘Ralph

本教程是 Electron 官方「Native Code and Electron」Windows 平台专项指南,聚焦于在 Electron 中使用 C++ 与 Win32 API 编写原生模块:通过集成 comctl32.lib(通用控件库)与 shcore.lib(高 DPI 支持),用纯 C++ 绘制一个「原生」Todo 列表窗口,并借助 N-API 的 ThreadSafeFunction 实现 C++ GUI 线程与 Electron JavaScript 主进程之间的双向通信。读完本文,你将掌握 Windows 专用 binding.gyp 工程配置、独立 GUI 线程 + 消息循环的搭建、DPI 感知、node-addon-api 对象包装类,以及 C++→JS 跨线程回调的完整工程范式。

前置阅读:Native Code and Electron 通用入门(含跨平台目录结构、node-gyp 基础与 hello_world 示例);平台姊妹篇:C++ (Linux)Objective-C++ (macOS)Swift (macOS)


0. 技术背景与适用前提

Electron 的应用之所以能调用 C++ 原生代码,是因为它构建在 Native Node.js Addon 机制之上。原生 Node 插件本质上是动态链接共享对象(Windows 上即 DLL,扩展名 .node),经由 require()/import 即可像普通 JS 模块一样被加载。在 Electron 中使用原生插件,意味着可以用任何全原生应用才能访问的能力扩展你的应用:

  • 调用 JavaScript 中不存在的原生平台 API(Windows 上任意系统 API);
  • 创建与原生桌面框架交互的 UI 组件;
  • 集成已有的原生第三方库;
  • 编写比 JavaScript 更快的性能关键型代码。

需要特别注意:Electron 与发行版 Node.js 的 ABI 不同(例如 Electron 使用 Chromium 的 BoringSSL 而非 OpenSSL),因此为 Node 编译好的 .node 模块无法直接在 Electron 中加载,会出现如下错误:

Error: The module '/path/to/native/module.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION $XYZ. This version of Node.js requires
NODE_MODULE_VERSION $ABC. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).

解决方案是在安装后使用 @electron/rebuild(本教程 package.json 中的 build-electron 脚本即 electron-rebuild)将模块针对目标 Electron 版本重新编译,详见 Native Node Modules 文档。这说明了为什么本教程编译出的插件既要在 Node 环境用 node-gyp 构建调试,又要在 Electron 应用中触发 electron-rebuild

预备环境要求

  • Node.js 与 npm;
  • 本教程强烈建议在 Windows 上操作:安装 Visual Studio"Desktop development with C++" 工作负载(参见 Visual Studio 安装文档);
  • 具备基础 Win32 GUI 编程经验:熟悉窗口类与窗口过程(WNDCLASSEXWWindowProc),熟悉 Windows 消息循环(代码中会使用 GetMessage/TranslateMessage/DispatchMessage),并能使用标准控件如 WC_EDITWWC_BUTTONW

[!NOTE] 如果你对 Windows C++ GUI 开发尚不熟悉,建议先阅读微软官方入门指南 "Get Started with Win32 and C++"。

为保持教程聚焦,我们只集成两个库:

导入库 作用 本教程用途
comctl32.lib Windows 通用控件库,提供按钮、滚动条、工具栏、状态栏、进度条、树视图等 UI 元素 生成 Todo 窗口的编辑框、日期选择器、按钮与列表控件
shcore.lib 提供高 DPI 感知功能及其他显示与 UI 管理相关的 Shell 能力 调用 SetProcessDpiAwarenessContext 实现 Per-Monitor DPI 感知

文档特别指出 comctl32 属于非常底层的基础 GUI 库;更现代的 WinUI、WPF 虽然更强大,但需要更多 C++ 与多 Windows 版本适配工作,反而偏离本教程的教学重点。


1) 创建插件包

本教程复用通用入门教程 native-code-and-electron.md 中创建的 my-native-addon 包结构,并扩展为 Windows 专用目录布局:

my-native-win32-addon/
├── binding.gyp
├── include/
│   └── cpp_code.h
├── js/
│   └── index.js
├── package.json
└── src/
    ├── cpp_addon.cc
    └── cpp_code.cc

package.json 如下:

{
  "name": "cpp-win32",
  "version": "1.0.0",
  "description": "A demo module that exposes C++ code to Electron",
  "main": "js/index.js",
  "author": "Your Name",
  "scripts": {
    "clean": "rm -rf build_swift && rm -rf build",
    "build-electron": "electron-rebuild",
    "build": "node-gyp configure && node-gyp build"
  },
  "license": "MIT",
  "dependencies": {
    "bindings": "^1.5.0",
    "node-addon-api": "^8.3.0"
  }
}

两个关键运行期依赖的职责(与通用教程一致):

  • node-addon-api:N-API 的 C++ 封装层,提供比原生 C 风格 N-API 更安全、更现代的对象式 C++ API;
  • bindings:自动定位编译产物(Windows 下即 build/Release/cpp_addon.node)的加载辅助库。

三个脚本分别承担:清理构建产物(clean)、用 node-gyp 构建(build)、将插件针对当前 Electron 版本重建(build-electron,实际调用 @electron/rebuildelectron-rebuild 命令)。


2) 设置构建配置(binding.gyp)

Windows 专用插件需要修改 binding.gyp,完成三件事:

  1. 仅在 Windows 编译——代码本身是平台相关的,需要以条件块隔离;
  2. 链接 Windows 专属库——本教程目标是 comctl32.libshcore.lib
  3. 配置编译器与 C++ 宏定义
{
  "targets": [
    {
      "target_name": "cpp_addon",
      "conditions": [
        ['OS=="win"', {
          "sources": [
            "src/cpp_addon.cc",
            "src/cpp_code.cc"
          ],
          "include_dirs": [
            "<!@(node -p \"require('node-addon-api').include\")",
            "include"
          ],
          "libraries": [
            "comctl32.lib",
            "shcore.lib"
          ],
          "dependencies": [
            "<!(node -p \"require('node-addon-api').gyp\")"
          ],
          "msvs_settings": {
            "VCCLCompilerTool": {
              "ExceptionHandling": 1,
              "DebugInformationFormat": "OldStyle",
              "AdditionalOptions": [
                "/FS"
              ]
            },
            "VCLinkerTool": {
              "GenerateDebugInformation": "true"
            }
          },
          "defines": [
            "NODE_ADDON_API_CPP_EXCEPTIONS",
            "WINVER=0x0A00",
            "_WIN32_WINNT=0x0A00"
          ]
        }]
      ]
    }
  ]
}

逐项解读:

  • conditions: [['OS=="win"', {...}]]:node-gyp 的 GYP 语法。OS 由构建目标平台决定,非 Windows 平台下整个 target 不产出源码,实现平台隔离。这也呼应了仓库中 Electron 自身测试 fixtures 的写法——例如 spec/fixtures/native-addon/is-valid-window/binding.gyp['OS=="win"', { 'sources': ['src/impl_win.cc'] }]['OS=="mac"', { 'libraries': [AppKit.framework] }]['OS not in ["mac", "win"]', {...}] 分别选择平台实现文件;virtual-display/binding.gyp 亦以 OS=="mac" 为条件提供 .mm 源文件。可见「条件化 sources/libraries」正是 Electron 仓库内部跨平台原生模块的标准做法。
  • include_dirs 中的 <!@(node -p ...):在 configure 阶段执行 Node 命令,将 node-addon-api 的 include 目录动态注入;dependencies 中对应注入其 GYP 配置。
  • libraries:显式链接 comctl32.lib(通用控件)与 shcore.lib(高 DPI)。仓库测试中亦有同类模式,如 osr-gpu/binding.gypOS=="win" 分支里链接 dxgi.libd3d11.libdxguid.lib
  • msvs_settingsdefines:见下两小节。

2.1 Microsoft Visual Studio 编译设置

msvs_settings 提供 Visual Studio 特有的配置项,分别作用于编译阶段与链接阶段。

VCCLCompilerTool 设置

"VCCLCompilerTool": {
  "ExceptionHandling": 1,
  "DebugInformationFormat": "OldStyle",
  "AdditionalOptions": [
    "/FS"
  ]
}
展开的编译标志 作用
ExceptionHandling 1 /EHsc 启用 C++ 异常处理。使编译器能捕获 C++ 异常、异常发生时正确展开栈,并且是 Node-API 在 JS 与 C++ 间正确处理异常的必要条件
DebugInformationFormat "OldStyle" —(PDB 格式) 使用较旧、兼容性更好的 PDB 程序数据库格式,兼容多种调试工具并更好地配合增量构建
AdditionalOptions ["/FS"] /FS 强制并行编译时对 PDB 文件的串行访问(文件序列化标志),避免多个编译器进程同时访问同一 PDB 导致的构建错误

VCLinkerTool 设置

"VCLinkerTool": {
  "GenerateDebugInformation": "true"
}
  • GenerateDebugInformation: "true":让链接器输出调试信息,从而能在使用符号的工具中进行源码级调试。最重要的是:插件崩溃时可获得可读的堆栈追踪

2.2 预处理器宏(defines

含义
NODE_ADDON_API_CPP_EXCEPTIONS 在 Node Addon API 中启用 C++ 异常处理。默认情况下 N-API 采用返回值错误模式,该宏使 C++ 包装层能够抛出/捕获 C++ 异常,让代码更符合惯用 C++ 且更易编写。这在仓库的跨平台 fixtures 中也是通用做法——virtual-display/binding.gyp 亦在 defines 中声明它,并辅以 "cflags!": ["-fno-exceptions"]"cflags_cc!": ["-fno-exceptions"](清除 GCC 默认的异常禁用)与 xcode_settings
WINVER=0x0A00 定义目标最低 Windows 版本。0x0A00 对应 Windows 10。设置后编译器允许使用 Windows 10 才提供的特性,且不再维护对更早版本的向后兼容。务必设为你的 Electron 应用计划支持的最低 Windows 版本
_WIN32_WINNT=0x0A00 WINVER 类似,定义代码运行所需的最低 Windows NT 内核版本,同为 0x0A00(Windows 10)。通常与 WINVER 取值一致

[!TIP] 官方源码中 Electron 自身对 Windows 最低版本的要求同样通过 WINVER/_WIN32_WINNT 类宏约束。若你面向 Windows 7 等更老系统,应下调 0x0A00 对应值,但要注意 SetProcessDpiAwarenessContext 等较新 API 的可用性也受此影响。


3) 定义 C++ 接口(include/cpp_code.h)

头文件声明了后续实现、桥接与测试所需的全部接口:

#pragma once
#include <string>
#include <functional>

namespace cpp_code {

std::string hello_world(const std::string& input);
void hello_gui();

// Callback function types
using TodoCallback = std::function<void(const std::string&)>;

// Callback setters
void setTodoAddedCallback(TodoCallback callback);

} // namespace cpp_code

要点:

  • 复用通用教程的 hello_world 函数;
  • 新增 hello_gui:创建 Win32 GUI 窗口;
  • std::function 定义 Todo 操作(Add)回调类型;出于篇幅,本教程只实现「新增(add)」这一个回调,因此只保留 setTodoAddedCallback 一个 setter(C++ 实现与桥接层同步只接线这一个事件)。

4) 实现 Win32 GUI 代码(src/cpp_code.cc)

4.1 头文件、链接指令与数据结构

#include <windows.h>
#include <windowsx.h>
#include <string>
#include <functional>
#include <chrono>
#include <vector>
#include <commctrl.h>
#include <shellscalingapi.h>
#include <thread>

#pragma comment(lib, "comctl32.lib")
#pragma comment(linker, "\"/manifestdependency:type='win32' \
name='Microsoft.Windows.Common-Controls' version='6.0.0.0' \
processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")

using TodoCallback = std::function<void(const std::string &)>;

static TodoCallback g_todoAddedCallback;

struct TodoItem
{
  GUID id;
  std::wstring text;
  int64_t date;

  std::string toJson() const
  {
    OLECHAR *guidString;
    StringFromCLSID(id, &guidString);
    std::wstring widGuid(guidString);
    CoTaskMemFree(guidString);

    // Convert wide string to narrow for JSON
    std::string guidStr(widGuid.begin(), widGuid.end());
    std::string textStr(text.begin(), text.end());

    return "{"
           "\"id\":\"" + guidStr + "\","
           "\"text\":\"" + textStr + "\","
           "\"date\":" + std::to_string(date) +
           "}";
  }
};

说明:

  • #pragma comment(lib, "comctl32.lib") 在源码层面补强链接(与 binding.gyplibraries 双保险);
  • #pragma comment(linker, manifestdependency ...) 显式声明 Common Controls v6.0.0.0 的激活上下文清单依赖,确保控件获得现代视觉样式(避免回退到旧的 ComCtl32 v5 外观);
  • g_todoAddedCallback 保存 JavaScript 侧注册的回调;
  • TodoItemGUID 作为 id、std::wstring 保存文本、int64_t 保存毫秒时间戳;toJson() 通过 StringFromCLSID/CoTaskMemFree 把 GUID 转成字符串并手工拼装 JSON——注意此处宽字符转窄字符用的是朴素逐字节转换(示例级别,未做 UTF-8 编码处理)。

4.2 基础函数与 DPI/时间辅助方法

namespace cpp_code
{
  std::string hello_world(const std::string &input)
  {
    return "Hello from C++! You said: " + input;
  }

  void setTodoAddedCallback(TodoCallback callback)
  {
    g_todoAddedCallback = callback;
  }

  // Window procedure function that handles window messages
  // hwnd: Handle to the window
  // uMsg: Message code
  // wParam: Additional message-specific information
  // lParam: Additional message-specific information
  LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam);

  // Helper function to scale a value based on DPI
  int Scale(int value, UINT dpi)
  {
    return MulDiv(value, dpi, 96); // 96 is the default DPI
  }

  // Helper function to convert SYSTEMTIME to milliseconds since epoch
  int64_t SystemTimeToMillis(const SYSTEMTIME &st)
  {
    FILETIME ft;
    SystemTimeToFileTime(&st, &ft);
    ULARGE_INTEGER uli;
    uli.LowPart = ft.dwLowDateTime;
    uli.HighPart = ft.dwHighDateTime;
    return (uli.QuadPart - 116444736000000000ULL) / 10000;
  }
}

两个辅助函数的动机均与 JavaScript 对接有关:

  • Scale(value, dpi) = value * dpi / 96:以 96 DPI(100% 缩放)为基准,把像素设计值按当前 DPI 等比放大,保证窗口在不同缩放比显示器上尺寸一致;
  • SystemTimeToMillis:把 Win32 的 SYSTEMTIME 转成 Unix epoch 毫秒——FILETIME 从 1601-01-01 起以 100ns 计,减去 116444736000000000ULL(1601 到 1970 的差值)再除以 10000 即得毫秒。这正是 JS Date 使用的时间基准。

4.3 独立 GUI 线程 + 消息循环(核心)

为什么必须为 GUI 开独立线程? Windows 消息循环本质是 while 无限循环,若在 Node.js 主线程上运行会阻塞整个事件循环、令 Electron 失去响应。把 GUI 放到 std::thread 中,既可让原生界面保持响应,也能规避 GUI 操作等待 JS 回调时可能产生的死锁。教程明确提示:仅做简单 Win32 API 交互时无需建线程;但只要需要跑消息循环,就必须自建线程

void hello_gui() {
  // Launch GUI in a separate thread
  std::thread guiThread([]() {
    // Enable Per-Monitor DPI awareness
    SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);

    // Initialize Common Controls
    INITCOMMONCONTROLSEX icex;
    icex.dwSize = sizeof(INITCOMMONCONTROLSEX);
    icex.dwICC = ICC_STANDARD_CLASSES | ICC_WIN95_CLASSES;
    InitCommonControlsEx(&icex);

    // Register window class
    WNDCLASSEXW wc = {};
    wc.cbSize = sizeof(WNDCLASSEXW);
    wc.lpfnWndProc = WindowProc;
    wc.hInstance = GetModuleHandle(nullptr);
    wc.lpszClassName = L"TodoApp";
    RegisterClassExW(&wc);

    // Get the DPI for the monitor
    UINT dpi = GetDpiForSystem();

    // Create window
    HWND hwnd = CreateWindowExW(
      0, L"TodoApp", L"Todo List",
      WS_OVERLAPPEDWINDOW,
      CW_USEDEFAULT, CW_USEDEFAULT,
      Scale(500, dpi), Scale(500, dpi),
      nullptr, nullptr,
      GetModuleHandle(nullptr), nullptr
    );

    if (hwnd == nullptr) {
      return;
    }

    // Controls go here! The window is currently empty,
    // we'll add controls in the next step.

    ShowWindow(hwnd, SW_SHOW);

    // Message loop
    MSG msg = {};
    while (GetMessage(&msg, nullptr, 0, 0)) {
      TranslateMessage(&msg);
      DispatchMessage(&msg);
    }

    // Clean up
    DeleteObject(hFont);
  });

  // Detach the thread so it runs independently
  guiThread.detach();
}

线程内流程梳理:

  1. SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)(来自 shcore.lib):启用 Per-Monitor V2 DPI 感知,窗口在不同缩放比显示器之间拖动时会得到正确的尺寸/字体;
  2. InitCommonControlsEx:初始化 ComCtl32 通用控件类(标准类 + Win95 兼容类),日期时间选择器 DATETIMEPICK_CLASSW 等依赖此初始化;
  3. 注册窗口类 WNDCLASSEXW:绑定 WindowProc 回调,窗口类名 L"TodoApp"hInstanceGetModuleHandle(nullptr)
  4. CreateWindowExW:创建 WS_OVERLAPPEDWINDOW 主窗口,宽高 500×500Scale 做 DPI 换算,位置取系统默认 CW_USEDEFAULT
  5. 消息循环GetMessage → TranslateMessage → DispatchMessage 三件套;收到 WM_QUIT 时循环退出;
  6. 收尾DeleteObject(hFont) 释放字体资源。

最后 guiThread.detach() 让线程独立运行——GUI 线程拥有自己的消息队列与生命周期,不被 hello_gui() 的返回所牵制。

4.4 添加控件:编辑框、日期选择器、按钮与列表

hello_gui()// Controls go here! 处填入以下代码(这部分是纯粹的标准 Win32 代码,与 Electron 无关,可直接复制):

    // Create the modern font with DPI-aware size
    HFONT hFont = CreateFontW(
      -Scale(14, dpi),              // Height (scaled)
      0,                            // Width
      0,                            // Escapement
      0,                            // Orientation
      FW_NORMAL,                    // Weight
      FALSE,                        // Italic
      FALSE,                        // Underline
      FALSE,                        // StrikeOut
      DEFAULT_CHARSET,              // CharSet
      OUT_DEFAULT_PRECIS,           // OutPrecision
      CLIP_DEFAULT_PRECIS,          // ClipPrecision
      CLEARTYPE_QUALITY,            // Quality
      DEFAULT_PITCH | FF_DONTCARE,  // Pitch and Family
      L"Segoe UI"                   // Font face name
    );

    // Create input controls with scaled positions and sizes
    HWND hEdit = CreateWindowExW(0, WC_EDITW, L"",
      WS_CHILD | WS_VISIBLE | WS_BORDER | ES_AUTOHSCROLL,
      Scale(10, dpi), Scale(10, dpi),
      Scale(250, dpi), Scale(25, dpi),
      hwnd, (HMENU)1, GetModuleHandle(nullptr), nullptr);
    SendMessageW(hEdit, WM_SETFONT, (WPARAM)hFont, TRUE);

    // Create date picker
    HWND hDatePicker = CreateWindowExW(0, DATETIMEPICK_CLASSW, L"",
      WS_CHILD | WS_VISIBLE | DTS_SHORTDATECENTURYFORMAT,
      Scale(270, dpi), Scale(10, dpi),
      Scale(100, dpi), Scale(25, dpi),
      hwnd, (HMENU)4, GetModuleHandle(nullptr), nullptr);
    SendMessageW(hDatePicker, WM_SETFONT, (WPARAM)hFont, TRUE);

    HWND hButton = CreateWindowExW(0, WC_BUTTONW, L"Add",
      WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
      Scale(380, dpi), Scale(10, dpi),
      Scale(50, dpi), Scale(25, dpi),
      hwnd, (HMENU)2, GetModuleHandle(nullptr), nullptr);
    SendMessageW(hButton, WM_SETFONT, (WPARAM)hFont, TRUE);

    HWND hListBox = CreateWindowExW(0, WC_LISTBOXW, L"",
      WS_CHILD | WS_VISIBLE | WS_BORDER | WS_VSCROLL | LBS_NOTIFY,
      Scale(10, dpi), Scale(45, dpi),
      Scale(460, dpi), Scale(400, dpi),
      hwnd, (HMENU)3, GetModuleHandle(nullptr), nullptr);
    SendMessageW(hListBox, WM_SETFONT, (WPARAM)hFont, TRUE);

控件 ID 与坐标规划如下:

控件 类名 ID(HMENU 样式要点 位置(DPI=96 基准)
文本编辑框 WC_EDITW 1 WS_BORDERES_AUTOHSCROLL (10,10) 250×25
日期选择器 DATETIMEPICK_CLASSW 4 DTS_SHORTDATECENTURYFORMAT (270,10) 100×25
Add 按钮 WC_BUTTONW 2 BS_PUSHBUTTON (380,10) 50×25
列表框 WC_LISTBOXW 3 WS_VSCROLLLBS_NOTIFY (10,45) 460×400

要点:

  • 控件 ID 由父窗口句柄配合 CreateWindowExW(HMENU) 参数指定,后续通过 GetDlgItem/GetDlgItemText 系列 API 以 ID 取回句柄;
  • 每个控件创建后都以 SendMessageW(..., WM_SETFONT, (WPARAM)hFont, TRUE) 套用 Segoe UI(高度经 DPI 缩放的 14px 现代字体),避免默认「系统字体」造成的像素化/锯齿;
  • 列表框 LBS_NOTIFY 允许向父窗口发送通知消息;ES_AUTOHSCROLL 让编辑框支持横向滚动输入长文本。

4.5 数据存储与回调通知基础设施

紧接着 hello_gui(),加入 todo 的全局存储与「泵消息的回调」函数:

  // Global vector to store todos
  static std::vector<TodoItem> g_todos;

  void NotifyCallback(const TodoCallback &callback, const std::string &json)
  {
    if (callback)
    {
      callback(json);
      // Process pending messages
      MSG msg;
      while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
      {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
      }
    }
  }

关键点:callback(json) 会经由桥接层把数据投递给 JS。由于 JS 回调最终由 Node 主线程(Electron 主进程)执行,而这里运行在 GUI 线程,调用返回后 GUI 线程需主动用 PeekMessage 泵一遍本线程消息队列,保证期间堆积的窗口消息(如重绘)不被饿死。

展示用格式化函数与控件复位函数紧随其后:

  std::wstring FormatTodoDisplay(const std::wstring &text, const SYSTEMTIME &st)
  {
    wchar_t dateStr[64];
    GetDateFormatW(LOCALE_USER_DEFAULT, DATE_SHORTDATE, &st, nullptr, dateStr, 64);
    return text + L" - " + dateStr;
  }

GetDateFormatW 按用户区域设置的短日期格式(DATE_SHORTDATE)把 SYSTEMTIME 渲染成 "yyyy/M/d" 一类文本,拼接出列表项显示串。

  void ResetControls(HWND hwnd)
  {
    HWND hEdit = GetDlgItem(hwnd, 1);
    HWND hDatePicker = GetDlgItem(hwnd, 4);
    HWND hAddButton = GetDlgItem(hwnd, 2);

    // Clear text
    SetWindowTextW(hEdit, L"");

    // Reset date to current
    SYSTEMTIME currentTime;
    GetLocalTime(&currentTime);
    DateTime_SetSystemtime(hDatePicker, GDT_VALID, &currentTime);
  }

每次添加完成后:清空编辑框文本、把日期选择器重置为当天(GDT_VALID 表示将控件设为给定值)。

4.6 窗口过程 WindowProc:处理「Add」点击

窗口过程是消息中枢。本实现中仅针对「新增 todo」这一条命令消息做业务处理,其余全部交给 DefWindowProcW它是整份代码里唯一与 Electron 相关的触点——新增时通过 NotifyCallback 通知 JavaScript

  LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
  {
    switch (uMsg)
    {
      case WM_COMMAND:
      {
        HWND hListBox = GetDlgItem(hwnd, 3);
        int cmd = LOWORD(wParam);

        switch (cmd)
        {
          case 2: // Add button
          {
            wchar_t buffer[256];
            GetDlgItemTextW(hwnd, 1, buffer, 256);

            if (wcslen(buffer) > 0)
            {
              SYSTEMTIME st;
              HWND hDatePicker = GetDlgItem(hwnd, 4);
              DateTime_GetSystemtime(hDatePicker, &st);

              TodoItem todo;
              CoCreateGuid(&todo.id);
              todo.text = buffer;
              todo.date = SystemTimeToMillis(st);

              g_todos.push_back(todo);

              std::wstring displayText = FormatTodoDisplay(buffer, st);
              SendMessageW(hListBox, LB_ADDSTRING, 0, (LPARAM)displayText.c_str());

              ResetControls(hwnd);
              NotifyCallback(g_todoAddedCallback, todo.toJson());
            }
            break;
          }
        }
        break;
      }

      case WM_DESTROY:
      {
        PostQuitMessage(0);
        return 0;
      }
    }

    return DefWindowProcW(hwnd, uMsg, wParam, lParam);
  }

处理逻辑解读:

  1. 用户在编辑框输入文字并点击 Add(按钮 ID 2),父窗口收到 WM_COMMANDLOWORD(wParam) 即控件 ID;
  2. GetDlgItemTextW(hwnd, 1, ...) 取回编辑框文本;非空才继续;
  3. DateTime_GetSystemtime 从日期选择器(ID 4)读出 SYSTEMTIME
  4. CoCreateGuid 生成新 Todo 的 GUID,时间戳经 SystemTimeToMillis 转成 epoch 毫秒后入 g_todos
  5. SendMessageW(hListBox, LB_ADDSTRING, ...) 把格式化后的条目追加进列表(ID 3);
  6. ResetControls 复位输入区,随后 NotifyCallback(g_todoAddedCallback, todo.toJson()) 把 JSON 形式的新 Todo 推给 JS 侧注册的回调;
  7. 窗口被关闭收到 WM_DESTROYPostQuitMessage(0),结束 GetMessage 循环、退出 GUI 线程。

4.7 cpp_code.cc 完整实现

将以上片段按序组合(含后续补全的 setter 全局声明),得到完整源文件:

#include <windows.h>
#include <windowsx.h>
#include <string>
#include <functional>
#include <chrono>
#include <vector>
#include <commctrl.h>
#include <shellscalingapi.h>
#include <thread>

#pragma comment(lib, "comctl32.lib")
#pragma comment(linker, "\"/manifestdependency:type='win32' \
name='Microsoft.Windows.Common-Controls' version='6.0.0.0' \
processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")

using TodoCallback = std::function<void(const std::string &)>;

static TodoCallback g_todoAddedCallback;

struct TodoItem
{
  GUID id;
  std::wstring text;
  int64_t date;

  std::string toJson() const
  {
    OLECHAR *guidString;
    StringFromCLSID(id, &guidString);
    std::wstring widGuid(guidString);
    CoTaskMemFree(guidString);

    std::string guidStr(widGuid.begin(), widGuid.end());
    std::string textStr(text.begin(), text.end());

    return "{"
           "\"id\":\"" + guidStr + "\","
           "\"text\":\"" + textStr + "\","
           "\"date\":" + std::to_string(date) +
           "}";
  }
};

namespace cpp_code
{
  std::string hello_world(const std::string &input)
  {
    return "Hello from C++! You said: " + input;
  }

  void setTodoAddedCallback(TodoCallback callback)
  {
    g_todoAddedCallback = callback;
  }

  LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam);

  // Helper function to scale a value based on DPI
  int Scale(int value, UINT dpi)
  {
    return MulDiv(value, dpi, 96); // 96 is the default DPI
  }

  // Helper function to convert SYSTEMTIME to milliseconds since epoch
  int64_t SystemTimeToMillis(const SYSTEMTIME &st)
  {
    FILETIME ft;
    SystemTimeToFileTime(&st, &ft);
    ULARGE_INTEGER uli;
    uli.LowPart = ft.dwLowDateTime;
    uli.HighPart = ft.dwHighDateTime;
    return (uli.QuadPart - 116444736000000000ULL) / 10000;
  }

  void ResetControls(HWND hwnd)
  {
    HWND hEdit = GetDlgItem(hwnd, 1);
    HWND hDatePicker = GetDlgItem(hwnd, 4);
    HWND hAddButton = GetDlgItem(hwnd, 2);

    SetWindowTextW(hEdit, L"");

    SYSTEMTIME currentTime;
    GetLocalTime(&currentTime);
    DateTime_SetSystemtime(hDatePicker, GDT_VALID, &currentTime);
  }

  void hello_gui() {
    std::thread guiThread([]() {
      // Enable Per-Monitor DPI awareness
      SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);

      // Initialize Common Controls
      INITCOMMONCONTROLSEX icex;
      icex.dwSize = sizeof(INITCOMMONCONTROLSEX);
      icex.dwICC = ICC_STANDARD_CLASSES | ICC_WIN95_CLASSES;
      InitCommonControlsEx(&icex);

      // Register window class
      WNDCLASSEXW wc = {};
      wc.cbSize = sizeof(WNDCLASSEXW);
      wc.lpfnWndProc = WindowProc;
      wc.hInstance = GetModuleHandle(nullptr);
      wc.lpszClassName = L"TodoApp";
      RegisterClassExW(&wc);

      UINT dpi = GetDpiForSystem();

      HWND hwnd = CreateWindowExW(
        0, L"TodoApp", L"Todo List",
        WS_OVERLAPPEDWINDOW,
        CW_USEDEFAULT, CW_USEDEFAULT,
        Scale(500, dpi), Scale(500, dpi),
        nullptr, nullptr,
        GetModuleHandle(nullptr), nullptr
      );

      if (hwnd == nullptr) {
        return;
      }

      // Create the modern font with DPI-aware size
      HFONT hFont = CreateFontW(
        -Scale(14, dpi),              // Height (scaled)
        0,                            // Width
        0,                            // Escapement
        0,                            // Orientation
        FW_NORMAL,                    // Weight
        FALSE,                        // Italic
        FALSE,                        // Underline
        FALSE,                        // StrikeOut
        DEFAULT_CHARSET,              // CharSet
        OUT_DEFAULT_PRECIS,           // OutPrecision
        CLIP_DEFAULT_PRECIS,          // ClipPrecision
        CLEARTYPE_QUALITY,            // Quality
        DEFAULT_PITCH | FF_DONTCARE,  // Pitch and Family
        L"Segoe UI"                   // Font face name
      );

      // Create input controls with scaled positions and sizes
      HWND hEdit = CreateWindowExW(0, WC_EDITW, L"",
        WS_CHILD | WS_VISIBLE | WS_BORDER | ES_AUTOHSCROLL,
        Scale(10, dpi), Scale(10, dpi),
        Scale(250, dpi), Scale(25, dpi),
        hwnd, (HMENU)1, GetModuleHandle(nullptr), nullptr);
      SendMessageW(hEdit, WM_SETFONT, (WPARAM)hFont, TRUE);

      HWND hDatePicker = CreateWindowExW(0, DATETIMEPICK_CLASSW, L"",
        WS_CHILD | WS_VISIBLE | DTS_SHORTDATECENTURYFORMAT,
        Scale(270, dpi), Scale(10, dpi),
        Scale(100, dpi), Scale(25, dpi),
        hwnd, (HMENU)4, GetModuleHandle(nullptr), nullptr);
      SendMessageW(hDatePicker, WM_SETFONT, (WPARAM)hFont, TRUE);

      HWND hButton = CreateWindowExW(0, WC_BUTTONW, L"Add",
        WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
        Scale(380, dpi), Scale(10, dpi),
        Scale(50, dpi), Scale(25, dpi),
        hwnd, (HMENU)2, GetModuleHandle(nullptr), nullptr);
      SendMessageW(hButton, WM_SETFONT, (WPARAM)hFont, TRUE);

      HWND hListBox = CreateWindowExW(0, WC_LISTBOXW, L"",
        WS_CHILD | WS_VISIBLE | WS_BORDER | WS_VSCROLL | LBS_NOTIFY,
        Scale(10, dpi), Scale(45, dpi),
        Scale(460, dpi), Scale(400, dpi),
        hwnd, (HMENU)3, GetModuleHandle(nullptr), nullptr);
      SendMessageW(hListBox, WM_SETFONT, (WPARAM)hFont, TRUE);

      ShowWindow(hwnd, SW_SHOW);

      // Message loop
      MSG msg = {};
      while (GetMessage(&msg, nullptr, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
      }

      // Clean up
      DeleteObject(hFont);
    });

    // Detach the thread so it runs independently
    guiThread.detach();
  }

  // Global vector to store todos
  static std::vector<TodoItem> g_todos;

  void NotifyCallback(const TodoCallback &callback, const std::string &json)
  {
    if (callback)
    {
      callback(json);
      // Process pending messages
      MSG msg;
      while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
      {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
      }
    }
  }

  std::wstring FormatTodoDisplay(const std::wstring &text, const SYSTEMTIME &st)
  {
    wchar_t dateStr[64];
    GetDateFormatW(LOCALE_USER_DEFAULT, DATE_SHORTDATE, &st, nullptr, dateStr, 64);
    return text + L" - " + dateStr;
  }

  LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
  {
    switch (uMsg)
    {
      case WM_COMMAND:
      {
        HWND hListBox = GetDlgItem(hwnd, 3);
        int cmd = LOWORD(wParam);

        switch (cmd)
        {
          case 2: // Add button
          {
            wchar_t buffer[256];
            GetDlgItemTextW(hwnd, 1, buffer, 256);

            if (wcslen(buffer) > 0)
            {
              SYSTEMTIME st;
              HWND hDatePicker = GetDlgItem(hwnd, 4);
              DateTime_GetSystemtime(hDatePicker, &st);

              TodoItem todo;
              CoCreateGuid(&todo.id);
              todo.text = buffer;
              todo.date = SystemTimeToMillis(st);

              g_todos.push_back(todo);

              std::wstring displayText = FormatTodoDisplay(buffer, st);
              SendMessageW(hListBox, LB_ADDSTRING, 0, (LPARAM)displayText.c_str());

              ResetControls(hwnd);
              NotifyCallback(g_todoAddedCallback, todo.toJson());
            }
            break;
          }
        }
        break;
      }

      case WM_DESTROY:
      {
        PostQuitMessage(0);
        return 0;
      }
    }

    return DefWindowProcW(hwnd, uMsg, wParam, lParam);
  }
} // namespace cpp_code

至此,cpp_code.cc 中的 Win32 部分绝大多数是「与 Electron 无关的标准 C++」,你完全可以把它当作一个独立 Win32 小程序来编写与调试;真正需要 Electron 心智模型的,是下一节的桥接层。


5) 创建 Node.js 插件桥接层(src/cpp_addon.cc)

5.1 最小骨架与模块注册

#include <napi.h>
#include <string>
#include "cpp_code.h"

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    // We'll add code here later
    return exports;
}

NODE_API_MODULE(cpp_addon, Init)

Init 在插件被加载时调用;NODE_API_MODULE(cpp_addon, Init) 宏把初始化函数注册给 Node/Electron,其中 cpp_addon 必须与 binding.gyptarget_name 一致。

5.2 用 ObjectWrap 包装 C++ 类

让 C++ 对象能在 JS 侧以「类实例」方式使用,标准做法是继承 Napi::ObjectWrap<CppAddon>

#include <napi.h>
#include <string>
#include "cpp_code.h"

class CppAddon : public Napi::ObjectWrap<CppAddon> {
public:
    static Napi::Object Init(Napi::Env env, Napi::Object exports) {
        Napi::Function func = DefineClass(env, "CppWin32Addon", {
            // We'll add methods here later
        });

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

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

    CppAddon(const Napi::CallbackInfo& info)
        : Napi::ObjectWrap<CppAddon>(info) {
        // Constructor logic will go here
    }

private:
    // Will add private members and methods later
};

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

NODE_API_MODULE(cpp_addon, Init)

Init 的三个动作缺一不可:用 DefineClass 定义 JS 可见的类(这里类名为 CppWin32Addon)→ 用 Napi::Persistent 把构造函数保存为持久引用、并 env.SetInstanceData 挂到环境上 → 将构造函数导出到 exports

5.3 HelloWorld:JS → C++ 单向调用

    static Napi::Object Init(Napi::Env env, Napi::Object exports) {
        Napi::Function func = DefineClass(env, "CppWin32Addon", {
            InstanceMethod("helloWorld", &CppAddon::HelloWorld),
        });
        // ... rest of Init function
    }

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

        if (info.Length() < 1 || !info[0].IsString()) {
            Napi::TypeError::New(env, "Expected string argument").ThrowAsJavaScriptException();
            return env.Null();
        }

        std::string input = info[0].As<Napi::String>();
        std::string result = cpp_code::hello_world(input);

        return Napi::String::New(env, result);
    }

模式:InstanceMethod 注册方法名与成员函数指针 → 在成员函数内做参数校验info.Length()/IsString())、类型不合法时抛出 TypeError → 把 JS 字符串转成 std::string → 调用纯 C++ 函数 → 将结果转回 Napi::String 返回。

5.4 HelloGui:从 JS 触发原生窗口

    static Napi::Object Init(Napi::Env env, Napi::Object exports) {
        Napi::Function func = DefineClass(env, "CppWin32Addon", {
            InstanceMethod("helloWorld", &CppAddon::HelloWorld),
            InstanceMethod("helloGui", &CppAddon::HelloGui),
        });
        // ... rest of Init function
    }

private:
    void HelloGui(const Napi::CallbackInfo& info) {
        cpp_code::hello_gui();
    }

HelloGui 几乎是空壳——它只是把 hello_gui() 抛到独立 GUI 线程后立即返回,因此不会阻塞 JS 事件循环;窗口由 4.3 节中的 std::thread 自行驱动。

5.5 事件系统:让 C++ 安全地回调 JavaScript

由于 GUI 运行在独立原生线程,回调 JS 必须经过 N-API ThreadSafeFunction。整体分四步实现:

  1. 为类增加私有成员,持有环境引用、发射器对象、回调注册表与线程安全函数句柄:
private:
    Napi::Env env_;
    Napi::ObjectReference emitter;
    Napi::ObjectReference callbacks;
    napi_threadsafe_function tsfn_;
  1. 定义跨线程载荷结构与析构清理
    struct CallbackData {
        std::string eventType;
        std::string payload;
        CppAddon* addon;
    };

    CppAddon(const Napi::CallbackInfo& info)
        : Napi::ObjectWrap<CppAddon>(info)
        , env_(info.Env())
        , emitter(Napi::Persistent(Napi::Object::New(info.Env())))
        , callbacks(Napi::Persistent(Napi::Object::New(info.Env())))
        , tsfn_(nullptr) {
        // We'll add threadsafe function setup here in the next step
    }

    ~CppAddon() {
        if (tsfn_ != nullptr) {
            napi_release_threadsafe_function(tsfn_, napi_tsfn_release);
            tsfn_ = nullptr;
        }
    }

emitter/callbacks 用持久引用保存两个 JS 对象:前者作为事件发射时的 this,后者作为「事件名 → JS 回调函数」的注册表;析构函数负责 napi_release_threadsafe_function(tsfn_, napi_tsfn_release) 释放线程安全函数,防止退出泄漏。

  1. 在构造函数里创建线程安全函数并接线 C++ 回调
    napi_status status = napi_create_threadsafe_function(
        env_,
        nullptr,                      // 不使用命名 JS 回调(用 callbacks 注册表代替)
        nullptr,
        Napi::String::New(env_, "CppCallback"),
        0,                            // max_queue_size: 0 = 不限制
        1,                            // initial_thread_count
        nullptr,
        nullptr,
        this,                         // context:后续回调时还原为 CppAddon*
        [](napi_env env, napi_value js_callback, void* context, void* data) {
            auto* callbackData = static_cast<CallbackData*>(data);
            if (!callbackData) return;

            Napi::Env napi_env(env);
            Napi::HandleScope scope(napi_env);

            auto addon = static_cast<CppAddon*>(context);
            if (!addon) {
                delete callbackData;
                return;
            }

            try {
                auto callback = addon->callbacks.Value().Get(callbackData->eventType).As<Napi::Function>();
                if (callback.IsFunction()) {
                    callback.Call(addon->emitter.Value(), {Napi::String::New(napi_env, callbackData->payload)});
                }
            } catch (...) {}

            delete callbackData;
        },
        &tsfn_
    );

    if (status != napi_ok) {
        Napi::Error::New(env_, "Failed to create threadsafe function").ThrowAsJavaScriptException();
        return;
    }

这里的关键设计是:第 9 个参数 this 作为 context。任何原生线程调用 napi_call_threadsafe_function 后,载荷会在 JS 主线程排队,最终由第 10 个参数的 call_js 回调 消费——该回调在 Node 主线程执行,故可安全创建 Napi::HandleScope、按 eventTypecallbacks 注册表取函数并用 callback.Call(emitter, ...) 触发。用注册表取代固定 JS 回调,意味着同一把 tsfn 可复用给任意多个事件名。

随后把 C++ 层回调接上 tsfn:

    auto makeCallback = this {
        return this, eventType {
            if (tsfn_ != nullptr) {
                auto* data = new CallbackData{
                    eventType,
                    payload,
                    this
                };
                napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
            }
        };
    };

    cpp_code::setTodoAddedCallback(makeCallback("todoAdded"));

makeCallback 是一个「事件工厂」:为每个事件名生成一个 std::function<void(const std::string&)>;当 GUI 线程新增 Todo、调用它时,就堆分配一个 CallbackDatanapi_call_threadsafe_function(..., napi_tsfn_blocking) 投递给主线程。napi_tsfn_blocking 表示队列满时阻塞等待,确保载荷不丢失。

  1. 暴露 on / destroy 两个 JS 方法
    static Napi::Object Init(Napi::Env env, Napi::Object exports) {
        Napi::Function func = DefineClass(env, "CppWin32Addon", {
            InstanceMethod("helloWorld", &CppAddon::HelloWorld),
            InstanceMethod("helloGui", &CppAddon::HelloGui),
            InstanceMethod("on", &CppAddon::On),
            InstanceMethod("destroy", &CppAddon::Destroy)
        });
        // ... rest of Init function
    }

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

        if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction()) {
            Napi::TypeError::New(env, "Expected (string, function) arguments").ThrowAsJavaScriptException();
            return env.Undefined();
        }

        callbacks.Value().Set(info[0].As<Napi::String>(), info[1].As<Napi::Function>());
        return env.Undefined();
    }

    Napi::Value Destroy(const Napi::CallbackInfo& info) {
        callbacks.Reset();
        emitter.Reset();

        if (tsfn_ != nullptr) {
            napi_release_threadsafe_function(tsfn_, napi_tsfn_abort);
            tsfn_ = nullptr;
        }

        return info.Env().Undefined();
    }
  • On:JS 侧形如 addon.on('todoAdded', cb),把 (事件名, 函数) 写入 callbacks 注册表;
  • Destroy:重置所有持久引用,并用 napi_tsfn_abort 中止线程安全函数(丢弃队列中未执行的载荷)。它必须在应用退出前调用,否则对回调和 tsfn 的持久引用会阻止原生插件析构函数运行,导致 Electron 退出时挂死。

5.6 桥接层完整实现

将上述片段拼接,最终 cpp_addon.cc

#include <napi.h>
#include <string>
#include "cpp_code.h"

class CppAddon : public Napi::ObjectWrap<CppAddon> {
public:
    static Napi::Object Init(Napi::Env env, Napi::Object exports) {
        Napi::Function func = DefineClass(env, "CppWin32Addon", {
            InstanceMethod("helloWorld", &CppAddon::HelloWorld),
            InstanceMethod("helloGui", &CppAddon::HelloGui),
            InstanceMethod("on", &CppAddon::On),
            InstanceMethod("destroy", &CppAddon::Destroy)
        });

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

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

    struct CallbackData {
        std::string eventType;
        std::string payload;
        CppAddon* addon;
    };

    CppAddon(const Napi::CallbackInfo& info)
        : Napi::ObjectWrap<CppAddon>(info)
        , env_(info.Env())
        , emitter(Napi::Persistent(Napi::Object::New(info.Env())))
        , callbacks(Napi::Persistent(Napi::Object::New(info.Env())))
        , tsfn_(nullptr) {

        napi_status status = napi_create_threadsafe_function(
            env_,
            nullptr,
            nullptr,
            Napi::String::New(env_, "CppCallback"),
            0,
            1,
            nullptr,
            nullptr,
            this,
            [](napi_env env, napi_value js_callback, void* context, void* data) {
                auto* callbackData = static_cast<CallbackData*>(data);
                if (!callbackData) return;

                Napi::Env napi_env(env);
                Napi::HandleScope scope(napi_env);

                auto addon = static_cast<CppAddon*>(context);
                if (!addon) {
                    delete callbackData;
                    return;
                }

                try {
                    auto callback = addon->callbacks.Value().Get(callbackData->eventType).As<Napi::Function>();
                    if (callback.IsFunction()) {
                        callback.Call(addon->emitter.Value(), {Napi::String::New(napi_env, callbackData->payload)});
                    }
                } catch (...) {}

                delete callbackData;
            },
            &tsfn_
        );

        if (status != napi_ok) {
            Napi::Error::New(env_, "Failed to create threadsafe function").ThrowAsJavaScriptException();
            return;
        }

        // Set up the callbacks here
        auto makeCallback = this {
            return this, eventType {
                if (tsfn_ != nullptr) {
                    auto* data = new CallbackData{
                        eventType,
                        payload,
                        this
                    };
                    napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
                }
            };
        };

        cpp_code::setTodoAddedCallback(makeCallback("todoAdded"));
    }

    ~CppAddon() {
        if (tsfn_ != nullptr) {
            napi_release_threadsafe_function(tsfn_, napi_tsfn_release);
            tsfn_ = nullptr;
        }
    }

private:
    Napi::Env env_;
    Napi::ObjectReference emitter;
    Napi::ObjectReference callbacks;
    napi_threadsafe_function tsfn_;

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

        if (info.Length() < 1 || !info[0].IsString()) {
            Napi::TypeError::New(env, "Expected string argument").ThrowAsJavaScriptException();
            return env.Null();
        }

        std::string input = info[0].As<Napi::String>();
        std::string result = cpp_code::hello_world(input);

        return Napi::String::New(env, result);
    }

    void HelloGui(const Napi::CallbackInfo& info) {
        cpp_code::hello_gui();
    }

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

        if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction()) {
            Napi::TypeError::New(env, "Expected (string, function) arguments").ThrowAsJavaScriptException();
            return env.Undefined();
        }

        callbacks.Value().Set(info[0].As<Napi::String>(), info[1].As<Napi::Function>());
        return env.Undefined();
    }

    Napi::Value Destroy(const Napi::CallbackInfo& info) {
        callbacks.Reset();
        emitter.Reset();

        if (tsfn_ != nullptr) {
            napi_release_threadsafe_function(tsfn_, napi_tsfn_abort);
            tsfn_ = nullptr;
        }

        return info.Env().Undefined();
    }
};

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

NODE_API_MODULE(cpp_addon, Init)

6) 创建 JavaScript 包装层(js/index.js)

原生代码有大量样板,把「数据整形/校验」放到 JS 侧更划算——这也是大量生产应用的通用做法:先在 JS 层转换/准备数据,再调用原生代码。本示例中 JS 层把 C++ 传来的毫秒时间戳解析成真正的 Date 对象。

const EventEmitter = require('events')

class CppWin32Addon extends EventEmitter {
  constructor() {
    super()

    if (process.platform !== 'win32') {
      throw new Error('This module is only available on Windows')
    }

    const native = require('bindings')('cpp_addon')
    this.addon = new native.CppWin32Addon();

    this.addon.on('todoAdded', (payload) => {
      this.emit('todoAdded', this.#parse(payload))
    });
  }

  helloWorld(input = "") {
    return this.addon.helloWorld(input)
  }

  helloGui() {
    this.addon.helloGui()
  }

  destroy() {
    this.addon.destroy()
  }

  #parse(payload) {
    const parsed = JSON.parse(payload)

    return { ...parsed, date: new Date(parsed.date) }
  }
}

if (process.platform === 'win32') {
  module.exports = new CppWin32Addon()
} else {
  module.exports = {}
}

设计要点:

  • 继承 EventEmitter,把原生 on 事件二次封装成 Node 风格事件——内部把 C++ JSON 字符串解析后重组为 {id, text, date: Date}emit,让消费方拿到的就是结构化 JS 对象(通过私有方法 #parse 实现);
  • 模块加载即做平台守卫:非 Windows 直接抛错;导出时也只在 win32 下导出单例,否则导出空对象,避免在其他平台 require 崩溃;
  • 转发 helloWorld / helloGui / destroy 三个原生方法,形成干净的最小公共 API。

[!IMPORTANT] 应用退出前必须调用 destroy()(例如放在 Electron 的 will-quitbefore-quit 事件处理器里)。否则对回调与线程安全函数的持久引用会阻止原生插件析构函数执行,导致 Electron 退出时挂起


7) 构建与运行

全部文件就位后,依次执行:

npm run build

build 等价于 node-gyp configure && node-gyp build:先按 binding.gyp 生成 Visual Studio 工程并下载 node-addon-api 依赖,再调用 MSBuild 编译出 build/Release/cpp_addon.node。若在 Windows 上出现 MSBuild 相关报错,请确认已安装「Desktop development with C++」工作负载,并保证 npm 能找到对应版本的 Visual Studio。

调试期建议在纯 Node 进程中先验证核心逻辑(JS 层包装后可在主进程 require('./js') 调用 helloWorld),随后在 Electron 中执行:

npm run build-electron

electron-rebuild,它会读取当前 Electron 版本对应的头文件目录、以 Electron 的 ABI 重新编译插件(Electron 与 Node 的 NODE_MODULE_VERSION 不同,此步不可省略,参见 Native Node Modules)。

Electron 仓库自身的落地佐证

「gyp 条件化平台构建 + N-API 线程安全回调」并不是教程的孤例,而是 Electron 官方测试套件中长期使用的模式,可以作为可验证的实现参照:

  • 条件化平台构建spec/fixtures/native-addon/is-valid-window/binding.gyp 使用 OS=="win" 选入 src/impl_win.ccOS=="mac" 链接 AppKit、其余平台选 impl_posix.ccvirtual-display/binding.gyp 同样在条件块里声明 node-addon-api include、NODE_ADDON_API_CPP_EXCEPTIONS defines 与 cflags! 处理。这与本教程 binding.gyp 的写法一脉相承;
  • N-API 线程安全函数在 Electron 中可用:官方测试 spec/node-spec.ts 通过 @electron-ci/echo 插件调用 .threadsafe('napi threadsafe function', resolve).async(...),在真实 Electron 进程中验证了异步 work 与线程安全函数能够把事件送回主线程并保持进程可退出(如测试中断言 closed 计数)。

结论与进一步学习

到这里,你已经完成了一个完整、可用的 Windows C++ 原生 Node.js 插件,实现了:

  1. 用纯 C++ 创建原生 Windows GUI;
  2. 一个具备「添加 Todo」交互的 Todo 列表应用(编辑框 + 日期选择器 + 按钮 + 列表控件);
  3. C++ 与 JavaScript 之间的双向通信(JS 调用 helloGui/helloWorld 进入 C++,C++ 通过 ThreadSafeFunction 把 todoAdded 事件送回 JS);
  4. 使用 Win32 控件与 Windows 专属特性(Common Controls v6、Per-Monitor V2 DPI 感知、区域化日期格式化);
  5. 从原生线程安全地回调 JavaScript。

这套架构为在 Electron 应用中构建更复杂的 Windows 特性(系统托盘深度集成、原生对话框之外的定制控件、COM/WinRT 互操作等)打下了地基——「Web 技术的开发效率 + 原生代码的能力边界」正是 Electron 提供的最佳平衡。后续深化方向包括:

  • 将事件系统扩展为多个事件(在 cpp_code.h 中仿照 setTodoAddedCallback 增加 Update/Delete 回调,并在 makeCallback 工厂中接线新事件名,即可让 Todo 的增删改全部回流 JS);
  • 认真处理宽字符 → UTF-8 的转换(本教程示例使用逐字节转换,生产代码应使用 WideCharToMultiByte(CP_UTF8, ...)std::filesystem/ICU);
  • 阅读 N-API 官方文档理解 async work、Promise 等更多跨线程原语;
  • 参考微软 C++ 文档Windows API 参考 扩展 Win32 知识。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388