Electron 中编写 Windows C++ 原生扩展:基于 Win32 API 从零构建原生界面与跨线程回调
本教程是 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 编程经验:熟悉窗口类与窗口过程(
WNDCLASSEXW、WindowProc),熟悉 Windows 消息循环(代码中会使用GetMessage/TranslateMessage/DispatchMessage),并能使用标准控件如WC_EDITW、WC_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/rebuild 的 electron-rebuild 命令)。
2) 设置构建配置(binding.gyp)
Windows 专用插件需要修改 binding.gyp,完成三件事:
- 仅在 Windows 编译——代码本身是平台相关的,需要以条件块隔离;
- 链接 Windows 专属库——本教程目标是
comctl32.lib与shcore.lib; - 配置编译器与 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.gyp 在OS=="win"分支里链接dxgi.lib、d3d11.lib、dxguid.lib。msvs_settings与defines:见下两小节。
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.gyp的libraries双保险);#pragma comment(linker, manifestdependency ...)显式声明 Common Controls v6.0.0.0 的激活上下文清单依赖,确保控件获得现代视觉样式(避免回退到旧的 ComCtl32 v5 外观);g_todoAddedCallback保存 JavaScript 侧注册的回调;TodoItem用GUID作为 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即得毫秒。这正是 JSDate使用的时间基准。
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();
}
线程内流程梳理:
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)(来自shcore.lib):启用 Per-Monitor V2 DPI 感知,窗口在不同缩放比显示器之间拖动时会得到正确的尺寸/字体;InitCommonControlsEx:初始化 ComCtl32 通用控件类(标准类 + Win95 兼容类),日期时间选择器DATETIMEPICK_CLASSW等依赖此初始化;- 注册窗口类
WNDCLASSEXW:绑定WindowProc回调,窗口类名L"TodoApp",hInstance取GetModuleHandle(nullptr); CreateWindowExW:创建WS_OVERLAPPEDWINDOW主窗口,宽高500×500经Scale做 DPI 换算,位置取系统默认CW_USEDEFAULT;- 消息循环:
GetMessage → TranslateMessage → DispatchMessage三件套;收到WM_QUIT时循环退出; - 收尾:
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_BORDER、ES_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_VSCROLL、LBS_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(¤tTime);
DateTime_SetSystemtime(hDatePicker, GDT_VALID, ¤tTime);
}
每次添加完成后:清空编辑框文本、把日期选择器重置为当天(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);
}
处理逻辑解读:
- 用户在编辑框输入文字并点击 Add(按钮 ID
2),父窗口收到WM_COMMAND,LOWORD(wParam)即控件 ID; GetDlgItemTextW(hwnd, 1, ...)取回编辑框文本;非空才继续;DateTime_GetSystemtime从日期选择器(ID4)读出SYSTEMTIME;CoCreateGuid生成新 Todo 的 GUID,时间戳经SystemTimeToMillis转成 epoch 毫秒后入g_todos;SendMessageW(hListBox, LB_ADDSTRING, ...)把格式化后的条目追加进列表(ID3);ResetControls复位输入区,随后NotifyCallback(g_todoAddedCallback, todo.toJson())把 JSON 形式的新 Todo 推给 JS 侧注册的回调;- 窗口被关闭收到
WM_DESTROY时PostQuitMessage(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(¤tTime);
DateTime_SetSystemtime(hDatePicker, GDT_VALID, ¤tTime);
}
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.gyp 的 target_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。整体分四步实现:
- 为类增加私有成员,持有环境引用、发射器对象、回调注册表与线程安全函数句柄:
private:
Napi::Env env_;
Napi::ObjectReference emitter;
Napi::ObjectReference callbacks;
napi_threadsafe_function tsfn_;
- 定义跨线程载荷结构与析构清理:
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) 释放线程安全函数,防止退出泄漏。
- 在构造函数里创建线程安全函数并接线 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、按 eventType 从 callbacks 注册表取函数并用 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、调用它时,就堆分配一个 CallbackData 并 napi_call_threadsafe_function(..., napi_tsfn_blocking) 投递给主线程。napi_tsfn_blocking 表示队列满时阻塞等待,确保载荷不丢失。
- 暴露
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-quit或before-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.cc、OS=="mac"链接 AppKit、其余平台选impl_posix.cc;virtual-display/binding.gyp 同样在条件块里声明node-addon-apiinclude、NODE_ADDON_API_CPP_EXCEPTIONSdefines 与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 插件,实现了:
- 用纯 C++ 创建原生 Windows GUI;
- 一个具备「添加 Todo」交互的 Todo 列表应用(编辑框 + 日期选择器 + 按钮 + 列表控件);
- C++ 与 JavaScript 之间的双向通信(JS 调用
helloGui/helloWorld进入 C++,C++ 通过 ThreadSafeFunction 把todoAdded事件送回 JS); - 使用 Win32 控件与 Windows 专属特性(Common Controls v6、Per-Monitor V2 DPI 感知、区域化日期格式化);
- 从原生线程安全地回调 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 知识。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00