很多人以为 Electron 只是「套了个浏览器壳」,干不了系统级的事——读窗口标题、查注册表、拿系统目录、控制其他进程,这些只有 C# / C++ 才行。这个印象是错的。

关键在于:Electron 的渲染进程确实是浏览器沙箱,但它的主进程是完整的 Node.js,能 require 任何原生模块。系统级的事,渲染进程做不了,主进程能做;主进程里用 koffi 动态加载 user32.dll / kernel32.dll,就能像 C# 的 DllImport 一样去 P/Invoke。C# 能做的,Electron 一样能做,差距在封装不在能力。

渲染进程为什么不能直接调 Win32

Electron 的窗口里跑的是 Chromium,渲染进程出于安全默认是沙箱化的(即使不开沙箱,它也只是个网页,没有 require 原生模块的能力)。两个硬约束:

所以正确的分工是:界面交给渲染进程,系统级的事交给主进程(或主进程派生的子进程),两者之间用 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-engineflowlab 执行引擎(sandbox 双执行需重入)进程内沙箱,不直接碰 Win32
mycomputer-engine「我的电脑」类系统信息Win32 调用全在这里(winapi-ffi)

把系统调用集中到 mycomputer-engine 有俩好处:

关于 utilityProcess 本身怎么拆、进程间怎么通信,我另外写了一篇《Electron 性能差?那是你没把重活拆进 utilityProcess》,这篇专注讲「系统调用放哪一层」。

踩过的坑

  1. 类型匹配是头等大事。HWND、HANDLE 在 koffi 里用 void*;wchar_t* 一定要配 UTF-16 缓冲。类型瞎写不会报 JS 错,而是直接访问越界、进程崩,而且崩得毫无征兆。
  2. 32 位 / 64 位 DLL 必须和 Electron 架构一致。你打包的是 64 位 Electron,就别去 load 一个 32 位的 DLL,否则 LoadLibrary 失败或运行时崩。这条在 CI 里最容易翻车。
  3. 异常会带崩整个进程。Win32 调用不抛 JS 异常,它直接段错误。所以必须放在独立子进程里,绝不能放在主进程——主进程一崩,整个应用就没了。
  4. 别在渲染进程碰这些东西。即使你不开启沙箱,也别图省事在渲染层挂原生模块:一是破坏跨平台,二是把系统权限敞口给网页代码,安全隐患很大。

一句话:Electron 不是不能做系统级的事,是你得把系统级的事放到正确的进程里去做。渲染进程管界面,主进程管调度,Win32 调用丢进带懒加载的 utilityProcess 子进程。


调系统 API 这件事,Electron 和 C# 之间没有能力鸿沟,只有一层「该放在哪个进程」的工程分寸。把浏览器做不到的留给 Node,把界面交给浏览器——这就是 Electron 最舒服的地方。

更多桌面端的折腾,B站 / 抖音都搜 codevtool,从架构到打包的实战都在那儿。