很多人以为 Electron 只是「套了个浏览器壳」,干不了系统级的事——读窗口标题、查注册表、拿系统目录、控制其他进程,这些只有 C# / C++ 才行。这个印象是错的。
关键在于:Electron 的渲染进程确实是浏览器沙箱,但它的主进程是完整的 Node.js,能 require 任何原生模块。系统级的事,渲染进程做不了,主进程能做;主进程里用 koffi 动态加载 user32.dll / kernel32.dll,就能像 C# 的 DllImport 一样去 P/Invoke。C# 能做的,Electron 一样能做,差距在封装不在能力。
渲染进程为什么不能直接调 Win32
Electron 的窗口里跑的是 Chromium,渲染进程出于安全默认是沙箱化的(即使不开沙箱,它也只是个网页,没有 require 原生模块的能力)。两个硬约束:
- 沙箱 + 跨平台:渲染进程的代码要同时跑在 Windows / macOS / Linux 上,而 Win32 只在 Windows 存在,不能在渲染层写;
- 没有原生加载能力:渲染进程拿不到
dlopen/LoadLibrary,除非走contextBridge暴露的接口。
所以正确的分工是:界面交给渲染进程,系统级的事交给主进程(或主进程派生的子进程),两者之间用 IPC 通信。这也是我那篇《用 Electron 做本地优先的桌面应用》里三进程分工的延伸——这一篇专门讲主进程这一侧的「系统调用」怎么做。
koffi 是什么,怎么用
koffi 是一个用 Node 调 C 函数的 FFI 库(不需要 node-gyp 编译,纯 JS 加载 DLL / .so / .dylib)。它的核心就两步:加载库 → 声明函数签名 → 调用。签名就是契约,类型写错会直接崩进程,所以比写 JS 要小心。
下面两个例子都是我在主进程里实际用过的。
例 1:取当前前台窗口的标题
const koffi = require('koffi');
const user32 = koffi.load('user32.dll');
// 声明函数(签名即契约:HWND 用 void*,字符串用 wchar_t*)
const GetForegroundWindow = user32.func('void* GetForegroundWindow()');
const GetWindowTextW = user32.func('int GetWindowTextW(void* hWnd, wchar_t* lpString, int nMaxCount)');
const hwnd = GetForegroundWindow();
const buf = Buffer.alloc(256 * 2); // UTF-16 缓冲,长度按宽字符算
const len = GetWindowTextW(hwnd, buf, 256);
const title = buf.toString('utf16le', 0, len * 2);
console.log('当前前台窗口:', title);
缓冲处理按 koffi 文档来:Win32 的
W后缀函数是 UTF-16,用Buffer当wchar_t*缓冲,读出来按utf16le解码。
例 2:拿系统目录路径
const kernel32 = koffi.load('kernel32.dll');
const GetSystemDirectoryW = kernel32.func('int GetSystemDirectoryW(wchar_t* lpBuffer, int uSize)');
const dir = Buffer.alloc(260 * 2);
const n = GetSystemDirectoryW(dir, 260);
console.log('系统目录:', dir.toString('utf16le', 0, n * 2)); // 如 C:\WINDOWS\system32
这两个例子看着平平无奇,但它们证明了一件事:系统 API 是「拿来就能用」,不需要 Electron 官方提供任何封装。你要做的只是把 DLL 名和函数签名填对。
我为什么加了一层「懒加载」
直接在每个文件里 koffi.load('user32.dll') 也能跑,但有两个问题:一是每次加载都 dlopen 一次,重复;二是一上来把所有 DLL 全加载完,启动会变慢。我的桌面应用要用的 Win32 函数散在十几个地方,全预加载会拖慢首屏。
于是我加了一层 winapi-ffi 懒加载模块:用到才加载,加载过的缓存起来。
// winapi-ffi.js —— 用到才加载,不预加载
const koffi = require('koffi');
const cache = new Map();
function lib(name) {
if (!cache.has(name)) cache.set(name, koffi.load(name));
return cache.get(name);
}
function api(dll, sig) { return lib(dll).func(sig); }
module.exports = { lib, api };
这样一来,启动时不碰任何系统 DLL,哪个功能第一次被触发,对应的库才在那一刻加载。用户感知到的启动速度和「纯网页 Electron」没区别,但背后随时能调系统 API。
真实架构:3 个 utilityProcess 各管各的 DLL
更进一步,我没有把所有 Win32 调用堆在主进程里,而是把它们分散到独立的 utilityProcess 子进程。我的桌面应用现在跑着 3 个常驻的 Node 子进程:
| 子进程 | 职责 | 用到的系统能力 |
|---|---|---|
collector-engine | 数据采集引擎(worker pool + 垃圾过滤) | 文件遍历、磁盘信息 |
flowlab-engine | flowlab 执行引擎(sandbox 双执行需重入) | 进程内沙箱,不直接碰 Win32 |
mycomputer-engine | 「我的电脑」类系统信息 | Win32 调用全在这里(winapi-ffi) |
把系统调用集中到 mycomputer-engine 有俩好处:
- 崩溃隔离:Win32 签名写错、DLL 位数不对,崩的只是这个子进程,主进程和界面都活着;
- 懒加载本地化:只有这个子进程按需
require('./winapi-ffi'),其余两个进程根本不碰系统 DLL,内存更干净。
关于 utilityProcess 本身怎么拆、进程间怎么通信,我另外写了一篇《Electron 性能差?那是你没把重活拆进 utilityProcess》,这篇专注讲「系统调用放哪一层」。
踩过的坑
- 类型匹配是头等大事。
HWND、HANDLE在 koffi 里用void*;wchar_t*一定要配 UTF-16 缓冲。类型瞎写不会报 JS 错,而是直接访问越界、进程崩,而且崩得毫无征兆。 - 32 位 / 64 位 DLL 必须和 Electron 架构一致。你打包的是 64 位 Electron,就别去
load一个 32 位的 DLL,否则LoadLibrary失败或运行时崩。这条在 CI 里最容易翻车。 - 异常会带崩整个进程。Win32 调用不抛 JS 异常,它直接段错误。所以必须放在独立子进程里,绝不能放在主进程——主进程一崩,整个应用就没了。
- 别在渲染进程碰这些东西。即使你不开启沙箱,也别图省事在渲染层挂原生模块:一是破坏跨平台,二是把系统权限敞口给网页代码,安全隐患很大。
一句话:Electron 不是不能做系统级的事,是你得把系统级的事放到正确的进程里去做。渲染进程管界面,主进程管调度,Win32 调用丢进带懒加载的 utilityProcess 子进程。
调系统 API 这件事,Electron 和 C# 之间没有能力鸿沟,只有一层「该放在哪个进程」的工程分寸。把浏览器做不到的留给 Node,把界面交给浏览器——这就是 Electron 最舒服的地方。
更多桌面端的折腾,B站 / 抖音都搜 codevtool,从架构到打包的实战都在那儿。