很多人写 Electron 时对 require 的认知还停留在"装了包就能 require"。但 Electron 是一个多进程模型,一旦涉及主进程、渲染进程、utilityProcess、再叠加打包后的目录变化,require 能不能成功就变成了两个完全不同维度的问题:
- 路径维度:这个模块的文件到底在不在我能解析到的地方?(解析问题,可修)
- 身份维度:这个能力到底属不属于"我这个进程"?(进程边界问题,不可修,只能换进程)
这篇文档把 Electron 里会遇到的五类"库"逐一拆开讲清楚,并结合本 demo 的真实代码,讲透:把这些文件放到安装包之外(比如用户数据目录)后,怎么处理才能让它们跑起来,以及 webpack 打包时必须注意的点。
一、先建立正确的心智模型:三种进程不是"同一个 Node"
Electron 不是"一个 Node 进程里跑了个浏览器"。它是 Chromium 的多进程架构,每个进程内嵌了 Node 运行时(V8 + libuv),但进程身份不同,能拿到的 native 绑定就不同。
| 主进程 main | Chromium 的 browser process(根进程) | ✅ 完整 | ✅ 完整 |
| 渲染进程 renderer | Chromium 渲染进程 | ⚠️ 默认关闭,可开启 | ⚠️ 受限(主要是 ipcRenderer 等) |
| utilityProcess | Chromium Services 子进程 | ✅ 完整 | ❌ 几乎只有 process.parentPort |
记住一句话贯穿全文:
能不能用 Node 生态 → 看模块在不在能被解析到的 node_modules(路径问题)。 能不能用 Electron 能力 → 看你是不是那个持有 Chromium 子系统的进程(身份问题)。
二、五类库逐一拆解
1. Node.js 核心模块(内置模块)
例子:path fs http https net module crypto os url child_process…
特征:它们被编译进了 Electron 二进制本身,require('path') 根本不碰文件系统,直接从二进制内部的模块表返回。
结论:
- 在任何进程(main / renderer 开了 nodeIntegration / utilityProcess)里都能用。
- 不管脚本文件被搬到哪(安装目录、asar、用户数据目录、临时目录),永远解析得到。
- 它跟 node_modules 没有任何关系,也跟 NODE_PATH 无关。
本 demo 的 utility-server.js 里 require('http')、require('module')、require('path') 之所以"搬到用户目录也能用",就是因为它们是核心模块。
⚠️ 易混点:net 这个名字同时存在两套东西——Node 自带的 require('net')(裸 TCP/IPC socket,核心模块,到处能用)和 Electron 的 require('electron').net(基于 Chromium 网络栈的 HTTP 客户端,主进程专属)。名字一样,性质天差地别,见下一节。
2. Electron 扩展库(主进程专属 API)
例子:app BrowserWindow session net(electron 的) dialog protocol ipcMain Menu Tray globalShortcut…
特征:这些不是普通可被搬运的 JS 库,而是指向"正在运行的 browser process"内部 native 对象的绑定(handle):
- app 绑定的是这个进程的生命周期和 Chromium 消息循环;
- net(electron)绑定的是 browser process 里那个唯一的网络服务实例,要走系统代理、复用 session 的 cookie;
- session / BrowserWindow 绑定的是窗口与会话子系统。
这些对象只在主进程这一个进程里存在。
结论:
- 只能在主进程里用。
- 即使在 utilityProcess 里 require('electron'),也拿不到这些 handle —— 因为它本身就不是那个持有这些对象的进程。这不是"包没装",搬库也搬不过来。
- 在 utilityProcess 里需要这些能力时:让主进程调用,结果通过 process.parentPort 回传。本 demo 的 deep-link 转发就是这个模式——所有 Electron 侧的事(fork、建管道、协议注册)都在主进程,utility 只管业务。
// ❌ 在 utilityProcess 里这样做,net 是 undefined
const { net } = require('electron')
// ✅ utilityProcess 里只能用 Node 自带的网络能力
const http = require('http') // 或 https / net(node)
3. 渲染进程可开启的库(nodeIntegration / contextIsolation)
渲染进程默认是一个纯浏览器环境,没有 require、没有 Node。能否在 renderer 里 require 取决于 BrowserWindow 的 webPreferences:
| nodeIntegration: false(默认) | renderer 里没有 require/process/Buffer | ✅ 安全,推荐 |
| nodeIntegration: true | renderer 里可直接 require('fs') 等 | ❌ 危险,加载远程内容时等于把 Node 暴露给网页 |
| contextIsolation: true(默认) | preload 与页面 JS 隔离上下文 | ✅ 安全,推荐 |
推荐做法(也是 Electron 官方方向):renderer 不开 nodeIntegration,需要的 Node/Electron 能力通过 preload + contextBridge 白名单暴露,或走 ipcRenderer → ipcMain 让主进程代劳。
// preload.js —— 只把需要的能力按白名单暴露给渲染层
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
handleProtocol: url => ipcRenderer.invoke('protocol:handle', url)
})
关键认知:renderer 的"业务依赖"(Vue、axios 等)不是靠运行时 require('node_modules') 来的,而是 webpack 在构建时直接打进了 renderer bundle。所以 renderer 反而最"自包含",它不依赖 NODE_PATH,依赖的是资源路径对不对(见第四节)。
4. 第三方库(npm 依赖,住在 node_modules)
例子:本 demo 的 express axios vue。
特征:它们是真实存在于 node_modules 里的文件,require('express') 走 Node 的文件系统解析算法:从脚本所在目录开始,逐级向上找 node_modules/express。
结论:
- 这是纯路径问题。脚本旁边能向上找到 node_modules/express 就成功,找不到就报 Cannot find module 'express'。
- 打包后这个目录关系经常被破坏(asar、用户目录热更新等),需要手动修复解析路径,见第三、四节。
但这里有个分叉,取决于 webpack 怎么处理它(见第五节):
- 如果 webpack 把第三方库打进 bundle(renderer 通常如此)→ 运行时根本不 require,无所谓 node_modules。
- 如果 webpack 把它列为 external(本 demo 主进程/utility 如此)→ 产物里保留了运行时 require('express'),那就必须保证运行时能解析到 node_modules。
5. utilityProcess 进程里的库
utilityProcess 是被主进程 utilityProcess.fork(scriptPath, args, options) 拉起的轻量 Node 子进程。它的依赖能力等于:
Node 核心模块(永远有) + 能被解析到的 node_modules 里的第三方库(要修路径) + 几乎为零的 Electron API(只有 parentPort)
本 demo 的 utility-server.js 是一个独立打包入口,它要用 express,于是问题就来了:当这个脚本被放到用户数据目录后,它旁边没有 node_modules,怎么 require('express')?
答案就是下一节的核心。
三、把文件放到"安装包之外"后,怎么让它跑起来
本 demo 已经实现了 utility-server 脚本的"用户目录优先 + 热更新"。核心代码在 src/main/index.js。拆开看它解决了两件事。
3.1 文件本身:脚本放哪、优先用哪个
utilityProcess.fork() 要的是一个真实可访问的 JS 文件路径。demo 的策略是"用户目录有就用用户目录的,否则用包内的":
function resolveUserInstallUtilityServerPath() {
// %APPDATA%/electron-demo/utility-process/utility-server.js
return path.join(app.getPath('userData'), 'utility-process', 'utility-server.js')
}
function resolveUtilityServerStartPath(bundledServicePath) {
const userServicePath = resolveUserInstallUtilityServerPath()
if (fs.existsSync(userServicePath)) {
return userServicePath // 用户目录热更新脚本优先
}
return bundledServicePath // 回落到安装包内 dist/electron/server/utility-server.js
}
包内脚本也要多路径兜底,因为开发态、–dir、asar 后的 __dirname 不一样:
function resolveBundledUtilityServerPath() {
const candidates = [
path.resolve(__dirname, UTILITY_SERVER_BUNDLE),
path.resolve(__dirname, '..', UTILITY_SERVER_BUNDLE),
path.resolve(app.getAppPath(), 'dist', 'electron', UTILITY_SERVER_BUNDLE),
path.resolve(app.getAppPath(), UTILITY_SERVER_BUNDLE)
]
const servicePath = candidates.find(c => fs.existsSync(c))
if (!servicePath) throw new Error(`utility server bundle not found…`)
return servicePath
}
3.2 依赖解析:用户目录脚本怎么找到包内的 node_modules
这是整个方案最关键、最容易踩坑的地方。脚本搬到用户目录后,它旁边没有 node_modules,但当前安装包里有(通常在 app.asar/node_modules)。主进程要把这个路径注入给子进程。
第一步,主进程多路径找到当前安装包的 node_modules:
function resolveUtilityServerNodePath() {
const candidates = [
path.resolve(app.getAppPath(), 'node_modules'), // 开发态 / app 解包
path.resolve(process.resourcesPath || '', 'app.asar', 'node_modules'),// 打包后 asar
path.resolve(process.resourcesPath || '', 'app', 'node_modules'), // asar 解包
path.resolve(__dirname, '..', '..', 'node_modules'),
path.resolve(__dirname, '..', 'node_modules')
]
return candidates.find(c => c && fs.existsSync(c))
}
第二步,fork 时通过 env 自定义变量把这个路径传给子进程(注意 cwd 也指到脚本目录):
const nodePath = resolveUtilityServerNodePath()
if (!nodePath) {
throw new Error(`utility server node_modules path not found…`)
}
protocolServiceProcess = utilityProcess.fork(servicePath, [PIPE_NAME], {
cwd: path.dirname(servicePath),
env: {
…process.env,
UTILITY_SERVER_NODE_PATH: nodePath // ⭐ 业务自定义变量,不是直接塞 NODE_PATH
},
stdio: 'pipe'
})
第三步,子进程脚本在 require 第三方库之前,用 Node 核心模块 module 把这个路径注册进解析算法:
// dist/electron/server/utility-server.js 开头
const Module = require('module') // 核心模块,一定找得到
const path = require('path')
const utilityServerNodePath = process.env.UTILITY_SERVER_NODE_PATH
if (utilityServerNodePath) {
process.env.NODE_PATH = utilityServerNodePath
Module._initPaths() // ⭐ 重新初始化解析路径
}
const express = require('express') // 必须在 _initPaths 之后!
const http = require('http')
为什么这么绕?三个细节:
3.3 验证:用 /health 自证从哪启动、依赖路径是什么
排查这类问题,光看"能不能跑"不够,要让服务自己报告它的处境。demo 的 /health 返回了关键字段:
app.get('/health', (req, res) => {
res.json({
startedFrom: __filename, // 证明从用户目录还是包内启动
cwd: process.cwd(), // 证明 fork 的工作目录
utilityServerNodePath: process.env.UTILITY_SERVER_NODE_PATH || '' // 证明注入的依赖路径
})
})
- 用户目录热更新生效时,startedFrom 应指向 %APPDATA%/electron-demo/utility-process/utility-server.js。
- utilityServerNodePath 应指向当前安装包的 app.asar/node_modules。
3.4 渲染进程放到安装包之外,处理方式完全不同
注意:renderer 的"用户目录优先/热更新"和 utility-server 不是一回事。
- utility-server 是被 fork 当 Node 脚本跑的 → 要解决"依赖在哪"(NODE_PATH)。
- renderer 是被主进程 win.loadFile(…) 加载进 BrowserWindow 的页面 → 它的业务依赖已被 webpack 打进 bundle,不需要 node_modules;要解决的是"资源相对路径/协议对不对"。
所以 renderer 的热更新形态是:把构建好的 renderer 目录放用户目录 → 主进程检测到就 loadFile(用户目录/index.html),否则 loadFile(包内/index.html)。两者结构同构(用户目录优先),但触发机制不同(fork vs loadFile),要解决的根因也不同(依赖解析 vs 资源路径)。并且无论哪种,utilityProcess 只能由主进程启动,renderer 不能自己 fork,主进程都绕不开。
四、一张表收口:遇到 require 报错该往哪查
| Cannot find module 'path'/'http' | 核心模块 | 几乎不可能,除非进程类型不对 | 检查是不是在浏览器纯环境里跑 |
| utilityProcess 里 electron.net 是 undefined | Electron 扩展库 | 进程身份问题,utility 不是主进程 | 改用 Node 的 http/net,或主进程代劳 + parentPort |
| renderer 里 require is not defined | 渲染进程库 | nodeIntegration 没开(且不该开) | 用 preload + contextBridge / ipc |
| 用户目录脚本 Cannot find module 'express' | 第三方库 | 旁边没有 node_modules,解析失败 | 注入 NODE_PATH + Module._initPaths() |
| 打包后 utility server bundle not found | 文件路径 | asar 后 __dirname 变了 | 多 candidate 路径兜底 fs.existsSync |
五、webpack 打包时的注意事项
本 demo 主进程和 utility 用 .electron-vue/configs/webpack.main.config.js 打包,几个点必须注意。
5.1 utility 脚本要作为独立入口打包
utilityProcess.fork() 需要一个真实文件。开发态直接指向 src/main/utility-server.js 可能能跑,但生产包目录结构变化后极易找不到。所以把它配成独立 entry,输出到固定子目录:
entry: {
main: path.join(__dirname, '../../src/main/index.js'),
'utility-server': path.join(__dirname, '../../src/main/utility-server.js')
},
output: {
filename: pathData => {
if (pathData.chunk?.name === utilityServerBundle.entryName) {
return utilityServerBundle.relativeOutputPath // server/utility-server.js
}
return '[name].js'
},
library: { type: 'commonjs2' }, // 产物是 CommonJS,能被 fork 当 Node 脚本跑
path: electronDistDir
}
入口路径还通过 DefinePlugin 注入成常量 __UTILITY_SERVER_BUNDLE__,主进程用它来定位脚本,避免硬编码字符串。
5.2 第三方依赖列为 externals,不要打进 bundle
这是主进程/utility 打包与 renderer 最大的区别:
const { dependencies } = require('../../package.json')
externals: [
…Object.keys(dependencies || {}) // express / axios / vue 全部 external
],
含义:webpack 不把 express 打进 bundle,产物里保留运行时 require('express')(你能在产物里看到 module.exports = require("express") 这样的 external 包装)。
- 好处:bundle 小、原生模块(.node)不会被 webpack 破坏、依赖走 Node 真实解析。
- 代价:运行时必须能解析到 node_modules —— 这正是第三节 NODE_PATH 方案存在的原因。
- 推论:凡是列进 dependencies 的,运行时都得在 node_modules 里真实存在;凡是只在构建期用的,放 devDependencies,不会进 externals。本 demo 里 express/axios/vue/vue-electron 在 dependencies,所以会随安装包进 app.asar/node_modules。
5.3 target 要选对
target: 'electron-main' // 主进程/utility 用这个
- 主进程 / utility:electron-main,webpack 会把 Node 核心模块和 electron 当外部依赖,不去 polyfill。
- 渲染进程:electron-renderer 或 web,并把 Vue 等依赖打进 bundle(renderer 不用 externals 那套),所以 renderer 产物自包含,依赖的是资源路径而非 node_modules。
5.4 原生模块 / .node 文件
module: { rules: [{ test: /\\.node$/, use: 'node-loader' }] }
涉及原生 .node 的依赖更要走 externals + node-loader,绝不能让 webpack 试图把二进制打进 bundle。
5.5 electron-builder 的 files 要包含产物
"build": {
"files": ["dist/electron/**/*"] // 确保 server/utility-server.js 被打进安装包
}
否则 fork 时 resolveBundledUtilityServerPath() 在包内根本找不到脚本。
六、渲染进程"放哪都能用"的真正条件
经常有人得出这样一个结论:
“渲染进程开启了 node 集成、上下文不隔离、Vue 又打进了 renderer,那这个 renderer 放哪都能用。”
这个因果是错配的。 让 renderer 自包含/可搬运的,只有"打进 bundle"这一件事,跟 nodeIntegration、contextIsolation 没有任何关系。
| 开启 node 集成 | ❌ 无关,甚至有反作用(见下) |
| 上下文不隔离 | ❌ 无关,纯安全设置 |
| Vue 等打进 renderer bundle | ✅ 这才是自包含的唯一原因 |
Vue 能用,是因为 webpack 在构建时把它的代码直接塞进了 renderer 的 JS bundle,运行时根本不发生 require('vue')、不碰 node_modules。这件事在 nodeIntegration 关闭时一样成立。
6.1 nodeIntegration 开启反而是新的"不可搬运"风险
很反直觉但很关键:开 node 集成的意义是"让页面能 require",而一旦你真的去 require 一个没被打进 bundle 的第三方 node 库,你就立刻把第四类库的"路径解析问题"引回了 renderer。
// renderer 里开了 nodeIntegration 后:
const fs = require('fs') // ✅ 核心模块,在二进制里,搬哪都行
const express = require('express') // ⚠️ 运行时解析!旁边没有 node_modules 就炸
- 只 require Node 核心模块 → 还能搬。
- 运行时 require 外部 npm 库(没打进 bundle 的)→ 又回到"必须能解析到 node_modules",反而变得不可搬运。
所以真正可搬运的 renderer,是"什么都打进 bundle、运行时不做任何外部 require"的 renderer——这种情况下你压根不需要开 node 集成。开了又去用外部库,是在给自己制造搬运障碍;何况 contextIsolation:false + nodeIntegration:true 是公认的安全反模式,加载任何不完全可信的内容时等于把 Node 暴露给页面。
6.2 即便 bundle 自包含,"放哪都行"还有两个边界
准确表述:renderer 的可搬运性 = “依赖打没打进 bundle”(构建期决定)+ “资源路径对不对”(加载期决定),跟"进程身份/node 集成"是两套维度。
七、都打进 bundle,会不会包体大、影响性能
会让安装包变大,但"包体大"和"性能差"在 Electron 里不是同一件事,也不像网页那样强相关。关键差异:Electron 的 renderer 从本地磁盘走 file:// 加载,没有网络下载这一环。
| 网络下载耗时 | 网页里很关键 | ❌ 几乎不存在,本地磁盘读取 |
| 安装包 / 磁盘体积 | ✅ 直接变大 | ⚠️ 影响分发体积,不影响运行性能 |
| JS 解析+编译+执行耗时 | ✅ 真正影响启动 | ✅ 这才是要管的 |
7.1 不打进 bundle ≠ 更快
容易误以为"把 Vue 留在 node_modules、不打进 bundle"更轻,其实不是:
- 那些代码该被解析执行一行都不会少,只是文件换了位置。
- node_modules 是几百上千个小文件,运行时一堆 fs 查找 + asar 解析,往往比一个打好的 bundle 更慢。
- 打进 bundle 还能吃到 tree-shaking + 压缩混淆,external 的 node_modules 这两样都享受不到。
所以 renderer 这边"打进 bundle"通常是更优选择。external 那套是 utility-server 为了可解析/可热更才用的,不是为了性能。
7.2 体积/启动真的大了,正确解法是"延迟执行"而非"踢出 bundle"
落到本 demo:renderer 依赖就是 vue@2 + axios,体量很小,打进去完全无所谓。这类担忧通常在依赖树很重(大型 UI 库、地图、富文本、ECharts 全量)时才需要认真对待。
八、本 demo 的最优选择:第三方包直接打进 utility-server,省掉 NODE_PATH
第三节为了通用性讲了 UTILITY_SERVER_NODE_PATH + Module._initPaths() 的完整方案。但针对当前这个 demo,有更简单的路子:utility-server 运行时唯一的第三方 require 就是 express(http/module/path 都是核心模块)。只要把 express 打进 utility-server 的 bundle,运行时不再发生 require('express'),这个脚本就是单文件自包含——丢用户目录也好、丢哪都好,不依赖任何 node_modules,于是整套 NODE_PATH 机制可以全删掉。
对热更新反而更友好:用户目录里的脚本自带 express,不用再"复用当前安装包的 node_modules",少一个出错点。
8.1 具体改三处
externals: [
…Object.keys(dependencies || {}).filter(d => d !== 'express')
],
main 进程没 import express,所以只有 utility-server 入口会把它打进去,main 不受影响。
// 这一整段可以删了
const Module = require('module')
if (utilityServerNodePath) {
process.env.NODE_PATH = utilityServerNodePath
Module._initPaths()
}
直接 const express = require('express') 即可,webpack 会把它替换成内联代码。
8.2 唯一要记住的边界
这条路只对"纯 JS 依赖"成立。express、axios 都是纯 JS,能干净打包。但如果 utility-server 引入了带原生 .node 二进制的依赖(如 better-sqlite3、serialport),webpack 没法把二进制打进 bundle,那就得退回 external + node_modules + NODE_PATH 那套。
纯 JS 依赖 → 打进 bundle,最省心;含原生模块 → 必须 external + 传 node 路径。
当前 demo 全是纯 JS,直接打进去即可。
九、总结
把 Electron 的"库引入"想成两条独立的判断线,绝大多数 require 问题都能秒定位:
- Electron 主进程 API(app/net/session…)→ 身份问题,只有主进程有,utility/renderer 拿不到,改路径无效,只能换进程或让主进程代劳。
- 核心模块(path/http/net…)→ 在二进制里,永远 OK。
- 第三方(express…)→ 看 webpack 是打进 bundle 还是 external;若 external,运行时必须能解析到 node_modules,搬到安装包外时用 UTILITY_SERVER_NODE_PATH + Module._initPaths() 修复。
两条工程取舍:
- 打不打进 bundle? 纯 JS 依赖优先打进 bundle(更快、可 tree-shaking、自包含、可搬运);只有含原生 .node 的依赖才必须 external + 传 node 路径。包体大主要影响安装包体积,不等于性能差,启动慢用懒加载/压缩治理,而不是踢出 bundle。
- 本 demo 落地:utility-server 只依赖纯 JS 的 express,直接打进它的 bundle 即可,UTILITY_SERVER_NODE_PATH + Module._initPaths() 整套都能删掉(见第八节)。
一句话收尾:utilityProcess 复用的是 Node 生态和 app.asar 的 node_modules,但拿不到任何"主进程专属"的 Electron 能力;主进程才是那个持有全部 Chromium 子系统的根进程。
相关文档:
- Electron utilityProcess 承载 deep-link 服务
- utilityProcess 脚本热更新、用户目录优先启动与 asar 依赖解析



