原生代码与 Electron:C++(Windows)
本教程在原生代码与 Electron 概述的基础上展开,聚焦于使用 C++ 和 Win32 API 为 Windows 创建一个原生插件。 为了演示该如何在你的 Electron 应用中嵌入原生 Win32 代码,我们将会构建一个基础的原生 Windows GUI(使用 Windows 公共控件),它会与 Electron 的 JavaScript 进行通信。
具体而言,我们会集成两个常用的原生 Windows 库:
comctl32.lib,里面包含了公共控件和用户界面组件。 它提供了各种 UI 元素,比如按钮、滚动条、工具栏、状态栏、进度条、树形视图。 就 Windows 上的 GUI 开发而言,这个库非常底层且基础 - 更现代的框架,例如 WinUI 或 WPF 是更为先进的替代品,但是它们需要更多的 C++ 代码并且要考虑 Windows 的版本问题,这远远超出了本教程的实用范围。 这样,我们就能避免为多个 Windows 版本构建原生界面时会遇到的风险!shcore.lib,一个能提供高 DPI 感知功能,以及其他有关管理显示器和 UI 元素的 Shell 相关功能的库。
本教程对于那些已经在某种程度上熟悉在 Windows 上进行原生 C++ GUI 开发的人最为实用。 你应该已经拥有了基础窗口类和过程的相关经验,比如 WNDCLASSEXW 和 WindowProc 函数。 你也应该熟悉了 Windows 消息循环,它是任何一个原生应用程序的核心 - 我们的代码将会使用 GetMessage、TranslateMessage 和 DispatchMessage 来处理消息。 最后,我们将会使用(但不作解释)标准的 Win32 控件,比如 WC_EDITW 或 WC_BUTTONW。
如果你不熟悉 Windows 上的 C++ GUI 开发,我们推荐你阅读 Microsoft 的优秀文档和指南,这对初学者尤为有益。 “Win32 和 C++ 入门”就是篇极好的入门文档。
要求
就如我们的原生代码与 Electron 概述一样,本教程同样假定你已经安装了 Node.js 和 npm,以及编译原生代码所需的必要基础工具。 因为本教程讨论的是编写与 Windows 打交道的原生代码,我们推荐你在 Windows 上安装 Visual Studio 及其 “使用 C++ 的桌面开发”工作负荷后,跟着本教程操作。 详情请见安装 Visual Studio。
1. 创建一个包
你可以复用我们在原生代码与 Electron 教程里创建的包。 本教程将不会重复该教程里面所述的步骤。 让我们先搭建基础插件文件夹结构:
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"
}
}
2. 建立构建配置
对于 Windows 限定插件,我们需要修改 binding.gyp 文件以包含 Windows 库并设置适当的编译器标志。 简言之,我们需要完成下列三件事:
- 我们需要确保我们的插件只能在 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"
]
}]
]
}
]
}
如果你对这份配置的细节比较好奇,你可以往下继续阅读 - 否则,你可以直接照抄配置然后前往下一步,在那里我们定义 C++ 接口。
Microsoft Visual Studio 构建配置
msvs_settings 提供 Visual Studio 特有的设置。
VCCLCompilerTool 设置
"VCCLCompilerTool": {
"ExceptionHandling": 1,
"DebugInformationFormat": "OldStyle",
"AdditionalOptions": [
"/FS"
]
}
ExceptionHandling: 1:允许使用 /EHsc 编译器标志启用 C++ 异常处理。 这很重要,因为这能允许编译器捕捉 C++ 异常,发生异常时确保栈能正确展开,Node API 也需要这一项来正确处理 JavaScript 和 C++ 之间发生的异常。DebugInformationFormat: "OldStyle":指定调试信息的格式,使用较旧的,兼容性更好的 PDB(Program Database)格式。 这能够兼容各种调试工具,并能更好地与增量构建配合使用。AdditionalOptions: ["/FS"]:添加文件串行化标志,强制在编译期间对 PDB 文件进行串行访问。 这能够避免在并行构建时多个编译器进程尝试访问同一个 PDB 文件导致构建错误。
VCLinkerTool 设置
"VCLinkerTool": {
"GenerateDebugInformation": "true"
}
GenerateDebugInformation: "true":告诉链接器要包含调试信息,这能允许使用符号的工具进行源码级调试。 更重要的是,如果插件崩溃了,我们能得到人类可读的堆栈跟踪结果。
预处理器宏定义(defines):
NODE_ADDON_API_CPP_EXCEPTIONS:这个宏能在 Node 插件 API 内启用 C++ 异常处理。 默认情况下,Node API 使用的是返回值错误处理模式,但这个宏定义允许 C++ 包装层抛出并捕捉 C++ 异常,这使代码更符合 C++ 惯用的表达方式,也更易于处理。WINVER=0x0A00:这定义了代码所面向的最低 Windows 版本。 值0x0A00对应 Windows 10。 定义这个宏会告诉编译器,代码可以使用 Windows 10 中可用的特性,它将不会尝试维持与早期 Windows 版本的向后兼容性。 确保将这个宏设置为你的 Electron 应用程序打算支持的最低 Windows 版本。_WIN32_WINNT=0x0A00- 和WINVER类似,这定义了代码运行所需的最低 Windows NT 内核版本。 同样,0x0A00 对应 Windows 10。 通常设置为与WINVER相同的值。
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();
// 回调函数类型
using TodoCallback = std::function<void(const std::string&)>;
// 回调的写访问器
void setTodoAddedCallback(TodoCallback callback);
} // cpp_code 命名空间
这个头文件:
- 包含了概述教程里的基础
hello_world函数 - 添加了一个
hello_gui函数来创建一个 Win32 GUI。 - 定义了 Todo 操作的回调函数类型(添加操作)。 为了能让本教程稍微简短一点,我们将只实现一个回调。
- 为这些回调函数提供写访问器函数。
4. 实现 Win32 GUI 代码
现在,我们在 src/cpp_code.cc 里实现我们的 Win32 GUI。 这是个比较大的文件,所以我们将会分小节审查它。 首先,我们包含必要的头文件并定义基础结构体。
#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);
// 将宽字符串转换为用于 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) +
"}";
}
};
namespace cpp_code
{
// 稍后这里还有更多代码...
}
在这一节中:
- 我们包含了必要的 Win32 头文件
- 我们设置了 Pragma comment 以链接所需的库
- 我们为 Todo 操作定义了回调函数变量
- 我们创建了一个
TodoItem结构体,其中包含了一个转换为 JSON 的方法
接下来,我们实现基础函数和辅助方法:
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;
}
// 处理窗口消息的窗口过程函数
// hwnd:窗口句柄
// uMsg:消息代码
// wParam:额外的消息相关信息
// lParam:额外的消息相关信息
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
// 用于根据 DPI 缩放一个数值的辅助函数
int Scale(int value, UINT dpi)
{
return MulDiv(value, dpi, 96); // 96 是默认 DPI
}
// 用于将 SYSTEMTIME 转换为自纪元以来的毫秒数的辅助函数
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;
}
// 稍后这里还有更多代码...
}
在这一节中,我们添加了一个函数,用于设置被添加的 Todo 项的回调函数。 我们还添加了两个辅助函数,这些函数在处理 JavaScript 时必不可少:一个能根据显示器的 DPI 缩放我们的 UI 元素 - 另一个能将 Windows 的 SYSTEMTIME 转换为自纪元以来的毫秒数,这也是 JavaScript 记录时间的方式。
现在,我们来到教程里你可能真正想看到的那一部分 - 创建一个 GUI 线程并在屏幕上绘制原生像素。 我们通过在 cpp_code 命名空间里添加一个 void hello_gui() 函数来完成这件事。 我们需要考虑以下几点:
- 我们需要为 GUI 创建一个新线程以避免阻塞 Node.js 的事件循环。 处理 GUI 事件的 Windows 消息循环是一个无限循环,如果在主线程上运行,会阻碍 Node.js 处理其他事件。 在一个独立的线程上运行 GUI 能让原生 Windows 界面和 Node.js 均保持响应。 这种独立性也有助于避免在 GUI 操作需要等待 JavaScript 回调函数响应时可能发生的死锁。 对于更为简单的 Windows API 交互,你并不需要这么做 - 但是既然你需要检查消息循环,你就确实需要为 GUI 建立自己的线程。
- 然后,在我们的线程中,我们需要运行消息循环来处理任何窗口消息。
- 为了有正确的显示缩放,我们需要设置 DPI 感知功能。
- 我们需要注册窗口类,创建窗口,并添加各种 UI 控件。
下列代码中,我们还没有添加任何实际的控件。 我们是故意这样做的,以便在这里以更小的范围查看我们添加的代码。
void hello_gui() {
// 以独立的线程启动 GUI
std::thread guiThread([]() {
// 启用逐台显示器的 DPI 感知
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
// 初始化公共控件
INITCOMMONCONTROLSEX icex;
icex.dwSize = sizeof(INITCOMMONCONTROLSEX);
icex.dwICC = ICC_STANDARD_CLASSES | ICC_WIN95_CLASSES;
InitCommonControlsEx(&icex);
// 注册窗口类
WNDCLASSEXW wc = {};
wc.cbSize = sizeof(WNDCLASSEXW);
wc.lpfnWndProc = WindowProc;
wc.hInstance = GetModuleHandle(nullptr);
wc.lpszClassName = L"TodoApp";
RegisterClassExW(&wc);
// 获取显示器的 DPI
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;
}
// 控件的代码放在这里!窗口目前是空白状态,
// 下一步我们会添加控件
ShowWindow(hwnd, SW_SHOW);
// 消息循环
MSG msg = {};
while (GetMessage(&msg, nullptr, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
// 清理
DeleteObject(hFont);
});
// 分离线程使其独立运行
guiThread.detach();
}
既然我们有了一个线程、一个窗口和一个消息循环,我们就可以开始添加一些控件了。 我们在这里所做的一切并不仅适用于为 Electron 编写 Windows C++ - 你只需将下列代码复制粘贴到 hello_gui() 函数中 控件的代码放在这里! 的那个位置即可。
具体而言,我们要添加按钮、一个日期选择器和一个列表。
void hello_gui() {
// ...
// “控件的代码放在这里!”之上的所有代码
// 以 DPI 感知大小创建现代字体
HFONT hFont = CreateFontW(
-Scale(14, dpi), // 高度(经过缩放)
0, // 宽度
0, // 转义
0, // 朝向
FW_NORMAL, // 粗细
FALSE, // 斜体
FALSE, // 下划线
FALSE, // 删除线
DEFAULT_CHARSET, // 字符集
OUT_DEFAULT_PRECIS, // 输出精度
CLIP_DEFAULT_PRECIS, // 裁剪精度
CLEARTYPE_QUALITY, // 质量
DEFAULT_PITCH | FF_DONTCARE, // 间距和系列
L"Segoe UI" // 字体名称
);
// 以经过缩放的位置和大小创建输入控件
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);
// 在窗口的用户数据里存储菜单句柄
SetWindowLongPtr(hwnd, GWLP_USERDATA, (LONG_PTR)hContextMenu);
// “控件的代码放在这里!”之下的所有代码
// ...
}
既然我们拥有了能让用户添加 Todo 的用户界面,我们需要存储 Todo - 并添加一个可能会调用我们的 JavaScript 回调函数的辅助函数。 在 void hello_gui() { ... } 函数的正下方,我们添加下列代码:
// 用来存储 Todo 的全局数组
static std::vector<TodoItem> g_todos;
void NotifyCallback(const TodoCallback &callback, const std::string &json)
{
if (callback)
{
callback(json);
// 处理待处理的消息
MSG msg;
while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
}
我们还需要一个能将 Todo 转换为能显示的东西的函数。 我们不需要花里胡哨的东西 - 只要给定 Todo 的名称和 SYSTEMTIME 时间戳,我们返回一个简单的字符串就行。 将下列代码直接添加到上述函数的下方:
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;
}
当用户添加了一个 Todo 时,我们希望能将控件重置回空白状态。 要完成这一点,在我们刚刚添加的代码的下方添加一个辅助函数:
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);
}
接着,我们需要实现处理窗口消息的窗口过程。 和我们这里的大部分代码一样,这段代码中几乎没有与 Electron 相关的代码 - 作为 Win32 C++ 开发者,你应该能认出这个函数。 唯一特别的事是我们可能需要通知 JavaScript 回调函数有关被添加的 Todo 的情况。 我们之前已经实现了 NotifyCallback() 函数,我们将在这里使用这个函数。 将下列代码添加到上述函数的下方:
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: // 添加按钮
{
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);
}
我们现在已经成功实现了 Win32 C++ 代码。 大部分的代码应该看起来或者感觉像是你使用或不使用 Electron 时会编写的代码。 下一步,我们将搭建 C++ 与 JavaScript 之间的桥梁。 以下是完整的实现:
#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;
static TodoCallback g_todoUpdatedCallback;
static TodoCallback g_todoDeletedCallback;
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);
// 将宽字符串转换为用于 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) +
"}";
}
};
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;
}
void setTodoUpdatedCallback(TodoCallback callback)
{
g_todoUpdatedCallback = callback;
}
void setTodoDeletedCallback(TodoCallback callback)
{
g_todoDeletedCallback = callback;
}
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
// 用于根据 DPI 缩放一个数值的辅助函数
int Scale(int value, UINT dpi)
{
return MulDiv(value, dpi, 96); // 96 is the default DPI
}
// 用于将 SYSTEMTIME 转换为自纪元以来的毫秒数的辅助函数
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() {
// 以独立的线程启动 GUI
std::thread guiThread([]() {
// 启用逐台显示器的 DPI 感知
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
// 初始化公共控件
INITCOMMONCONTROLSEX icex;
icex.dwSize = sizeof(INITCOMMONCONTROLSEX);
icex.dwICC = ICC_STANDARD_CLASSES | ICC_WIN95_CLASSES;
InitCommonControlsEx(&icex);
// 注册窗口类
WNDCLASSEXW wc = {};
wc.cbSize = sizeof(WNDCLASSEXW);
wc.lpfnWndProc = WindowProc;
wc.hInstance = GetModuleHandle(nullptr);
wc.lpszClassName = L"TodoApp";
RegisterClassExW(&wc);
// 获取显示器的 DPI
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;
}
// 以 DPI 感知大小创建现代字体
HFONT hFont = CreateFontW(
-Scale(14, dpi), // 高度(经过缩放)
0, // 宽度
0, // 转义
0, // 朝向
FW_NORMAL, // 粗细
FALSE, // 斜体
FALSE, // 下划线
FALSE, // 删除线
DEFAULT_CHARSET, // 字符集
OUT_DEFAULT_PRECIS, // 输出精度
CLIP_DEFAULT_PRECIS, // 裁剪精度
CLEARTYPE_QUALITY, // 质量
DEFAULT_PITCH | FF_DONTCARE, // 间距和系列
L"Segoe UI" // 字体名称
);
// 以经过缩放的位置和大小创建输入控件
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);
// 消息循环
MSG msg = {};
while (GetMessage(&msg, nullptr, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
// 清理
DeleteObject(hFont);
});
// 分离线程使其独立运行
guiThread.detach();
}
// 用来存储 Todo 的全局数组
static std::vector<TodoItem> g_todos;
void NotifyCallback(const TodoCallback &callback, const std::string &json)
{
if (callback)
{
callback(json);
// 处理待处理的消息
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: // 添加按钮
{
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);
}
} // cpp_code 命名空间
5. 创建 Node.js 插件桥梁
现在让我们在 src/cpp_addon.cc 中实现我们的 C++ 代码与 Node.js 之间的桥梁。 先从为我们的插件创建一个基础骨架开始:
#include <napi.h>
#include <string>
#include "cpp_code.h"
Napi::Object Init(Napi::Env env, Napi::Object exports) {
// 稍后我们会在这里添加代码
return exports;
}
NODE_API_MODULE(cpp_addon, Init)
这是使用 node-addon-api 的 Node.js 插件所需的最小结构。 加载插件时会调用 Init 函数,NODE_API_MODULE 宏会注册我们的初始化函数。
创建一个类来包装我们的 C++ 代码
我们创建一个类,用来包装我们的 C++ 代码并向 JavaScript 暴露它:
#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", {
// 稍后我们会在这里添加方法
});
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) {
// 构造函数逻辑放在这里
}
private:
// 稍后会添加私有成员和方法
};
Napi::Object Init(Napi::Env env, Napi::Object exports) {
return CppAddon::Init(env, exports);
}
NODE_API_MODULE(cpp_addon, Init)
这就创建了一个继承自 Napi::ObjectWrap 的类,它能允许我们包装我们的 C++ 对象以在 JavaScript 中使用。 Init 函数初始化这个类并导出到 JavaScript。
实现基础功能 - HelloWorld
现在我们添加第一个方法,HelloWorld 函数:
// ... 之前的代码
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),
});
// ... Init 函数剩下的部分
}
CppAddon(const Napi::CallbackInfo& info)
: Napi::ObjectWrap<CppAddon>(info) {
// 构造函数逻辑放在这里
}
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);
}
};
// ... 文件剩下的部分
这就向我们的类添加了 HelloWorld 方法并用 DefineClass 进行注册。 这个方法会验证输入,调用我们的 C++ 函数,并将结果返回到 JavaScript。
// ... 之前的代码
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),
});
// ... Init 函数剩下的部分
}
// ... 构造函数
private:
// ... HelloWorld 方法
void HelloGui(const Napi::CallbackInfo& info) {
cpp_code::hello_gui();
}
};
// ... 文件剩下的部分
这个简单的方法调用了我们 C++ 代码里的 hello_gui 函数,它会在一个独立的线程里启动 Win32 GUI 窗口。
搭建事件系统
现在来到了复杂的部分 - 搭建事件系统,这样我们的 C++ 代码就能回调 JavaScript。 我们需要:
- 添加私有成员来存储回调函数
- 为跨线程通信创建一个线程安装函数
- 添加一个
On方法来注册 JavaScript 回调 - 搭建会触发 JavaScript 回调的 C++ 回调函数
// ... 之前的代码
class CppAddon : public Napi::ObjectWrap<CppAddon> {
public:
// ... 之前的公有方法
private:
Napi::Env env_;
Napi::ObjectReference emitter;
Napi::ObjectReference callbacks;
napi_threadsafe_function tsfn_;
// ... 已有的私有方法
};
// ... 文件剩下的部分
现在,我们增强构造函数以初始化这些成员:
// ... 之前的代码
class CppAddon : public Napi::ObjectWrap<CppAddon> {
public:
// CallbackData 结构体用于在线程之间传递数据
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) {
// 下一步我们将在这里建立线程安全函数
}
// 添加用于清理的析构函数
~CppAddon() {
if (tsfn_ != nullptr) {
napi_release_threadsafe_function(tsfn_, napi_tsfn_release);
tsfn_ = nullptr;
}
}
// ... 类剩下的部分
};
// ... 文件剩下的部分
现在我们向构造函数添加建立线程安全函数的代码:
// ... 已有的构造函数代码
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;
}
// 下一步我们会添加建立回调代码
}
这就创建了一个能让我们的 C++ 代码从任意线程调用 JavaScript 的线程安全函数。 调用时,它会获取相应的 JavaScript 回调函数并使用提供的载荷调用它。
现在我们添加建立回调代码:
// ... 建立线程安全函数之后的已有构造函数代码
// 在这里建立回调
auto makeCallback = [this](const std::string& eventType) {
return [this, eventType](const std::string& payload) {
if (tsfn_ != nullptr) {
auto* data = new CallbackData{
eventType,
payload,
this
};
napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
}
};
};
cpp_code::setTodoAddedCallback(makeCallback("todoAdded"));
这就创建了一个函数,它会为每种事件类型生成回调函数。 回调函数会捕捉事件类型,调用时会创建一个 CallbackData 对象并将其传递给我们的线程安全函数。
最后,我们添加 On 方法,允许 JavaScript 注册回调函数:
// ... in the class definition, add On to DefineClass
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
}
// ... and add the implementation in the private section
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();
}
这就能允许 JavaScript 为特定的事件类型注册回调函数。 The Destroy method releases all persistent references and aborts the threadsafe function, which must be called before the app quits to prevent the process from hanging.
组装桥梁
现在所有的零件都已经就位了。
以下是完整的实现:
#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)
});
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](const std::string& eventType) {
return [this, eventType](const std::string& payload) {
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 中添加 JavaScript 包装层来完成整个过程。 正如我们所见,C++ 需要大量样板代码,这些代码用 JavaScript 编写可能更轻松或更高效 - 你会发现许多在生产环境中的应用程序都会在调用原生代码前,先用 JavaScript 转换数据或请求。 例如,这里我们将时间戳转换为正确的 JavaScript 日期对象。
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))
});
this.addon.on('todoUpdated', (payload) => {
this.emit('todoUpdated', this.#parse(payload))
});
this.addon.on('todoDeleted', (payload) => {
this.emit('todoDeleted', 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 = {}
}
You must call destroy() before the app quits (e.g. in the will-quit or before-quit event handler). Without this, persistent references to callbacks and the threadsafe function will prevent the native addon's destructor from running, causing Electron to hang on quit.
7. 构建并测试插件
所有文件都已就位,你可以构建插件了:
npm run build
结论
你现在已经使用 C++ 和 Win32 API 为 Windows 构建了一个完整的原生 Node.js 插件。 我们完成的工作包括:
- 用 C++ 创建一个原生 Windows GUI
- 实现一个有添加、编辑、删除功能的 Todo 列表应用程序
- C++ 与 JavaScript 之间进行双向通信
- 使用 Win32 控件和 Windows 限定特性
- C++ 线程安全地回调 JavaScript
这为在你的 Electron 应用中构建更复杂的 Windows 限定功能奠定了基础,让你兼得两全其美:既拥有 Web 技术的便捷性,又具备原生代码的强大功能。
关于使用 Win32 API 的更多信息,请参考 Microsoft C++、C 和汇编程序文档以及 Windows API 参考。