跳转到主内容

原生代码与 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 开发的人最为实用。 你应该已经拥有了基础窗口类和过程的相关经验,比如 WNDCLASSEXWWindowProc 函数。 你也应该熟悉了 Windows 消息循环,它是任何一个原生应用程序的核心 - 我们的代码将会使用 GetMessageTranslateMessageDispatchMessage 来处理消息。 最后,我们将会使用(但不作解释)标准的 Win32 控件,比如 WC_EDITWWC_BUTTONW

note

如果你不熟悉 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 应该看起来像这样:

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 库并设置适当的编译器标志。 简言之,我们需要完成下列三件事:

  1. 我们需要确保我们的插件只能在 Windows 上编译,因为我们将要编写平台特定代码。
  2. 我们需要包含 Windows 特有的库。 在本教程中,我们将使用 comctl32.libshcore.lib
  3. 我们需要配置编译器并定义 C++ 宏。
binding.gyp
{
"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 设置

binding.gyp
"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 设置

binding.gyp
"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 中定义头文件:

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。 这是个比较大的文件,所以我们将会分小节审查它。 首先,我们包含必要的头文件并定义基础结构体。

src/cpp_code.cc
#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 的方法

接下来,我们实现基础函数和辅助方法:

src/cpp_code.cc
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 控件。

下列代码中,我们还没有添加任何实际的控件。 我们是故意这样做的,以便在这里以更小的范围查看我们添加的代码。

src/cpp_code.cc
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() 函数中 控件的代码放在这里! 的那个位置即可。

具体而言,我们要添加按钮、一个日期选择器和一个列表。

src/cpp_code.cc
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() { ... } 函数的正下方,我们添加下列代码:

src/cpp_code.cc
// 用来存储 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 时间戳,我们返回一个简单的字符串就行。 将下列代码直接添加到上述函数的下方:

src/cpp_code.cc
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 时,我们希望能将控件重置回空白状态。 要完成这一点,在我们刚刚添加的代码的下方添加一个辅助函数:

src/cpp_code.cc
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);
}

接着,我们需要实现处理窗口消息的窗口过程。 和我们这里的大部分代码一样,这段代码中几乎没有与 Electron 相关的代码 - 作为 Win32 C++ 开发者,你应该能认出这个函数。 唯一特别的事是我们可能需要通知 JavaScript 回调函数有关被添加的 Todo 的情况。 我们之前已经实现了 NotifyCallback() 函数,我们将在这里使用这个函数。 将下列代码添加到上述函数的下方:

src/cpp_code.cc
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 之间的桥梁。 以下是完整的实现:

src/cpp_code.cc
#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(&currentTime);
DateTime_SetSystemtime(hDatePicker, GDT_VALID, &currentTime);
}

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 之间的桥梁。 先从为我们的插件创建一个基础骨架开始:

src/cpp_addon.cc
#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 暴露它:

src/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", {
// 稍后我们会在这里添加方法
});

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 函数:

src/cpp_addon.cc
// ... 之前的代码

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。

src/cpp_addon.cc
// ... 之前的代码

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。 我们需要:

  1. 添加私有成员来存储回调函数
  2. 为跨线程通信创建一个线程安装函数
  3. 添加一个 On 方法来注册 JavaScript 回调
  4. 搭建会触发 JavaScript 回调的 C++ 回调函数
src/cpp_addon.cc
// ... 之前的代码

class CppAddon : public Napi::ObjectWrap<CppAddon> {
public:
// ... 之前的公有方法

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

// ... 已有的私有方法
};

// ... 文件剩下的部分

现在,我们增强构造函数以初始化这些成员:

src/cpp_addon.cc
// ... 之前的代码

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;
}
}

// ... 类剩下的部分
};

// ... 文件剩下的部分

现在我们向构造函数添加建立线程安全函数的代码:

src/cpp_addon.cc
// ... 已有的构造函数代码
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 回调函数并使用提供的载荷调用它。

现在我们添加建立回调代码:

src/cpp_addon.cc
// ... 建立线程安全函数之后的已有构造函数代码

// 在这里建立回调
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 注册回调函数:

src/cpp_addon.cc
// ... 在类定义中,将 On 添加到 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)
});

// ... Init 函数剩下的部分
}

// ... 还要在私有区域内添加实现
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();
}

这就能允许 JavaScript 为特定的事件类型注册回调函数。

组装桥梁

现在所有的零件都已经就位了。

以下是完整的实现:

src/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)
});

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;
}

// 在这里建立回调
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::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 日期对象。

js/index.js
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()
}

#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 = {}
}

7. 构建并测试插件

所有文件都已就位,你可以构建插件了:

npm run build

结论

你现在已经使用 C++ 和 Win32 API 为 Windows 构建了一个完整的原生 Node.js 插件。 我们完成的工作包括:

  1. 用 C++ 创建一个原生 Windows GUI
  2. 实现一个有添加、编辑、删除功能的 Todo 列表应用程序
  3. C++ 与 JavaScript 之间进行双向通信
  4. 使用 Win32 控件和 Windows 限定特性
  5. C++ 线程安全地回调 JavaScript

这为在你的 Electron 应用中构建更复杂的 Windows 限定功能奠定了基础,让你兼得两全其美:既拥有 Web 技术的便捷性,又具备原生代码的强大功能。

关于使用 Win32 API 的更多信息,请参考 Microsoft C++、C 和汇编程序文档以及 Windows API 参考