Electron 实战:utilityProcess 服务脚本热更新、用户目录优先启动与 asar 依赖解析
在 Electron 里用 utilityProcess 承载本地服务时,很多 demo 只演示了“开发环境能跑起来”。但真正进入生产包后,会马上遇到几个更现实的问题:
utilityProcess.fork()启动的必须是一个真实可访问的 JS 文件。- 服务脚本可能需要热更新,不能每次都等整个 Electron 主包升级。
- 用户目录里的热更新脚本要能复用当前安装包里的
node_modules。 - 打包后依赖通常在
resources/app.asar/node_modules里,不是普通展开目录。 - 出问题时必须有日志定位到底是找文件失败、依赖解析失败,还是管道服务没监听起来。
本文基于一个真实 demo 的改造过程,完整记录如何实现:
- 主进程启动时优先检测用户目录下的
utility-server.js - 用户目录有文件就从用户目录启动,实现服务脚本热更新
- 用户目录没有文件就回退到安装包内的
app.asar版本 utilityProcess内的 Express 服务通过 Windows named pipe 对渲染进程提供能力- 用户目录脚本复用当前安装包
app.asar/node_modules下的依赖 - 通过日志和健康检查定位生产包问题
一、目标效果
最终我们希望启动链路是这样的:
Electron main process
|
| 1. 检查用户目录是否存在热更新脚本
v
%APPDATA%/electron-demo/utility-process/utility-server.js
|
| 存在:直接 utilityProcess.fork(user script)
| 不存在:utilityProcess.fork(app.asar bundled script)
v
utilityProcess
|
| require('express') from current app.asar/node_modules
v
Express server listen on named pipe
|
v
Renderer localRequest -> named pipe -> utilityProcess Express
Windows 下用户目录大概是:
C:\Users\senzo\AppData\Roaming\electron-demo\utility-process\utility-server.js
安装包内 fallback 脚本大概是:
C:\Users\senzo\AppData\Local\Programs\electron-demo\resources\app.asar\dist\electron\server\utility-server.js
依赖路径大概是:
C:\Users\senzo\AppData\Local\Programs\electron-demo\resources\app.asar\node_modules
这样做以后,热更新只需要把新的 utility-server.js 放到用户目录。下次应用启动时主进程会优先使用用户目录版本。如果用户目录没有热更新文件,仍然使用包内版本兜底。
二、为什么不直接把 node_modules 也复制到用户目录
这个点很关键。
很多人第一反应是:既然服务脚本放到用户目录,那依赖也复制一份到用户目录。这个方案短期能跑,但生产上不划算:
- 依赖体积大,复制和清理成本高。
- 依赖版本和当前安装包容易不一致。
- 多次热更新后用户目录会沉淀大量不可控文件。
- 原本由安装包管理的依赖变成了用户目录散落文件,排查问题更困难。
- 如果包含 native
.node依赖,还会涉及 ABI、平台和解压路径问题。
更稳的思路是:
- 用户目录只放“可热更新的业务脚本”
- 依赖仍然由当前安装包提供
- 当前安装包是什么版本,脚本就使用这个版本的依赖
也就是说,热更新脚本不是独立运行时,它是“外挂到当前 Electron 安装包运行时上的服务入口”。
三、主进程:选择启动脚本
主进程里保留一个包内脚本路径解析函数:
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(candidate => fs.existsSync(candidate))
if (!servicePath) {
throw new Error(`utility server bundle not found. Tried: ${candidates.join(', ')}`)
}
return servicePath
}
再加一个用户目录路径:
function resolveUserInstallUtilityServerPath() {
return path.join(app.getPath('userData'), 'utility-process', 'utility-server.js')
}
真正启动前做选择:
function resolveUtilityServerStartPath(bundledServicePath) {
const userServicePath = resolveUserInstallUtilityServerPath()
if (fs.existsSync(userServicePath)) {
writeUtilityBootstrapLog(`user utility server exists, using user file: ${userServicePath}`)
return userServicePath
}
writeUtilityBootstrapLog(`user utility server missing, using bundled file: ${bundledServicePath}`)
return bundledServicePath
}
这里没有“自动复制”概念。用户目录存在脚本就用用户目录,不存在就用包内版本。这更接近生产热更新模型:热更新系统负责投放文件,主程序只负责选择和启动。
四、主进程:给 utilityProcess 注入依赖路径
启动 utilityProcess 前,需要找到当前环境的 node_modules:
function resolveUtilityServerNodePath() {
const candidates = [
path.resolve(app.getAppPath(), 'node_modules'),
path.resolve(process.resourcesPath || '', 'app.asar', 'node_modules'),
path.resolve(process.resourcesPath || '', 'app', 'node_modules'),
path.resolve(__dirname, '..', '..', 'node_modules'),
path.resolve(__dirname, '..', 'node_modules')
]
return candidates.find(candidate => candidate && fs.existsSync(candidate))
}
生产包里通常命中:
resources/app.asar/node_modules
开发环境可能命中项目根目录的:
node_modules
然后启动:
const bundledServicePath = resolveBundledUtilityServerPath()
const servicePath = resolveUtilityServerStartPath(bundledServicePath)
const nodePath = resolveUtilityServerNodePath()
if (!nodePath) {
throw new Error(`utility server node_modules path not found. appPath=${app.getAppPath()}, resourcesPath=${process.resourcesPath}, dirname=${__dirname}`)
}
protocolServiceProcess = utilityProcess.fork(servicePath, [PIPE_NAME], {
cwd: path.dirname(servicePath),
env: {
...process.env,
UTILITY_SERVER_NODE_PATH: nodePath
},
stdio: 'pipe'
})
这里不要只依赖 NODE_PATH。
实际排查时发现,utilityProcess.fork() 时直接在 env 里传 NODE_PATH,对这个场景并不可靠。用户目录下的脚本启动后,require('express') 仍然可能找不到 app.asar/node_modules 里的依赖。
更稳的做法是传一个业务自定义变量,例如:
env: {
...process.env,
UTILITY_SERVER_NODE_PATH: nodePath
}
也就是说,主进程只告诉服务脚本“当前安装包的依赖目录在哪里”,不要指望 fork 时传入的 NODE_PATH 自动完成模块解析。真正让依赖搜索路径生效的动作,放到 utility-server.js 自己启动时完成。
五、服务脚本:启动前初始化模块搜索路径
utility-server.js 顶部要放这段逻辑,而且必须放在 require('express') 之前:
'use strict'
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')
const http = require('http')
这两行是整个方案里最关键的地方:
process.env.NODE_PATH = utilityServerNodePath
Module._initPaths()
第一行:
process.env.NODE_PATH = utilityServerNodePath
意思是把当前进程的 NODE_PATH 环境变量改成安装包里的依赖目录,比如:
C:\Users\senzo\AppData\Local\Programs\electron-demo\resources\app.asar\node_modules
但只改 process.env.NODE_PATH 还不够。Node 在进程启动时已经初始化过模块搜索路径了,后面再改环境变量,不会自动刷新 require() 的搜索路径。
所以还需要第二行:
Module._initPaths()
它的作用是让 Node 的 CommonJS 模块系统重新根据当前的 process.env.NODE_PATH 初始化全局模块搜索路径。执行完之后,后面的:
require('express')
才会去 app.asar/node_modules 里找依赖。
需要注意,Module._initPaths() 是 Node 的内部 API,不是公开类型定义里的稳定方法,所以 IDE 可能会提示 _initPaths 不存在。运行时它存在,Electron/Node 的 CommonJS loader 也会用到这套机制。这里使用它,是为了在进程启动后手动刷新模块搜索路径。
顺序非常重要。
错误顺序是:
const express = require('express')
// 后面才设置 NODE_PATH
这样还没来得及把 app.asar/node_modules 加进搜索路径,就已经开始解析 express 了,必然可能报:
Error: Cannot find module 'express'
Require stack:
- C:\Users\senzo\AppData\Roaming\electron-demo\utility-process\utility-server.js
正确做法是:
- 读取
UTILITY_SERVER_NODE_PATH - 写回
process.env.NODE_PATH - 执行
Module._initPaths() - 再
require('express')
六、asar 里的 node_modules 到底是什么
生产包里看到的路径可能是:
C:\Users\senzo\AppData\Local\Programs\electron-demo\resources\app.asar\node_modules
这不是一个普通解压目录。
app.asar 是 Electron 支持的归档文件。它在文件系统上是一个文件,但 Electron 的 Node 运行时对 asar 路径做了特殊处理,所以下面这种路径可以被 require() 使用:
resources/app.asar/node_modules/express
但你不能把它当普通目录来理解。它不是:
resources/app.asar.unpacked/node_modules
app.asar.unpacked 是另一种机制。只有配置了 asarUnpack,或者 electron-builder 判断某些 native 模块必须解包时,文件才会出现在:
resources/app.asar.unpacked
纯 JS 依赖,比如 express,通常可以直接留在 app.asar/node_modules 里,由 Electron 的 asar 支持完成读取。
什么时候必须用 app.asar.unpacked
下面这些情况更适合放到 app.asar.unpacked:
- native
.node模块 - 需要被第三方可执行文件直接读取的文件
- 依赖要求真实文件系统路径,不能接受 asar 虚拟路径
- 要执行的二进制文件
- 某些需要动态扫描目录结构的库
而 express、body-parser、qs 这类纯 JS 依赖,一般不需要解包。
七、named pipe 服务与健康检查
服务内部仍然是一个 Express 应用,只是不监听 TCP 端口,而是监听 Windows named pipe:
const pipeName = process.argv[2] || '\\\\.\\pipe\\wadesk-protocol-bridge'
const app = express()
app.use(express.json({ limit: '1mb' }))
app.get('/health', (req, res) => {
res.json({
ok: true,
pid: process.pid,
pipeName,
startedFrom: __filename,
cwd: process.cwd(),
utilityServerNodePath: process.env.UTILITY_SERVER_NODE_PATH || ''
})
})
const server = http.createServer(app)
server.listen(pipeName, () => {
reportStatus({
type: 'server-ready',
pipeName,
pid: process.pid
})
})
健康检查返回的几个字段很有用:
startedFrom:证明当前服务脚本到底从用户目录启动,还是从包内启动cwd:证明utilityProcess.fork()的工作目录utilityServerNodePath:证明主进程注入的依赖路径
如果用户目录有热更新脚本,startedFrom 应该类似:
C:\Users\senzo\AppData\Roaming\electron-demo\utility-process\utility-server.js
如果用户目录没有热更新脚本,startedFrom 应该类似:
C:\Users\senzo\AppData\Local\Programs\electron-demo\resources\app.asar\dist\electron\server\utility-server.js
八、渲染进程:通过管道请求服务
渲染进程通过 axios 的 Node http adapter 访问 named pipe:
const axios = require('axios')
const PIPE_NAME = '\\\\.\\pipe\\wadesk-protocol-bridge'
const wadeskPipe = axios.create({
baseURL: 'http://_',
adapter: 'http',
socketPath: PIPE_NAME,
headers: { 'Content-Type': 'application/json' }
})
封装请求时要注意:GET 请求不要发送 data: null。
曾经遇到过这个错误:
SyntaxError: Unexpected token 'n', "null" is not valid JSON
原因是渲染进程请求 GET /health 时,axios 仍然发送了 body:null。Express 的 express.json() 严格模式会尝试解析请求体,结果把 "null" 当成非法 JSON body 处理。
修复方式是:只有 data 真正存在时才放进 axios 请求配置。
async function localRequest(options) {
const requestOptions = normalizeLocalRequestOptions(options)
const axiosOptions = {
method: requestOptions.method,
url: requestOptions.path,
headers: requestOptions.headers
}
if (requestOptions.data !== null && requestOptions.data !== undefined) {
axiosOptions.data = requestOptions.data
}
const response = await wadeskPipe.request(axiosOptions)
return response.data
}
页面上可以加一个测试按钮:
async checkUtilityHealth() {
this.startLoading('utility health')
try {
this.latestResult = await localRequest({
method: 'GET',
path: '/health'
})
} catch (error) {
this.latestResult = { ok: false, message: error.message }
} finally {
this.stopLoading()
}
}
这个按钮是生产排查里非常有价值的自检入口。
九、启动日志:没有日志就没有真相
这次排查里最有效的手段,是在主进程启动链路里写文件日志。
日志路径:
C:\Users\senzo\AppData\Roaming\electron-demo\logs\utility-bootstrap.log
启动时记录:
function initUtilityBootstrapLog() {
const logDir = path.join(app.getPath('userData'), 'logs')
utilityBootstrapLogPath = path.join(logDir, 'utility-bootstrap.log')
fs.mkdirSync(logDir, { recursive: true })
fs.writeFileSync(utilityBootstrapLogPath, `[${new Date().toISOString()}] utility bootstrap log start\n`, 'utf8')
writeUtilityBootstrapLog(`appPath=${app.getAppPath()}`)
writeUtilityBootstrapLog(`userData=${app.getPath('userData')}`)
writeUtilityBootstrapLog(`resourcesPath=${process.resourcesPath || ''}`)
writeUtilityBootstrapLog(`execPath=${process.execPath}`)
writeUtilityBootstrapLog(`__dirname=${__dirname}`)
}
关键节点记录:
writeUtilityBootstrapLog('ensureProtocolService start')
writeUtilityBootstrapLog(`bundled service path: ${bundledServicePath}`)
writeUtilityBootstrapLog(`selected service path: ${servicePath}`)
writeUtilityBootstrapLog(`resolved nodePath: ${nodePath || '<not found>'}`)
writeUtilityBootstrapLog(`fork utility process: ${servicePath}`)
writeUtilityBootstrapLog('utilityProcess.fork returned process object')
子进程输出也要记录:
protocolServiceProcess.stderr.on('data', data => {
const text = data.toString().trim()
writeUtilityBootstrapLog(`stderr: ${text}`)
console.error('[utility-server]', text)
})
这样线上遇到问题时,不用猜。用户只要把日志贴出来,就能看到问题发生在哪一层。
十、真实问题复盘
问题一:用户目录没有生成 utility-server.js
一开始以为应该自动复制脚本到用户目录。后来调整成更接近生产热更新的模型:
- 主程序不负责自动投放热更新文件
- 主程序只负责启动前检测
- 用户目录有热更新文件就用热更新文件
- 用户目录没有热更新文件就用包内文件
这样职责更清楚。
问题二:服务脚本启动后找不到 express
日志:
Error: Cannot find module 'express'
Require stack:
- C:\Users\senzo\AppData\Roaming\electron-demo\utility-process\utility-server.js
原因:
utility-server.js在用户目录- Node 默认会从用户目录开始向上找
node_modules - 用户目录没有
node_modules - 主进程只传了自定义的
UTILITY_SERVER_NODE_PATH,服务脚本如果不自己写回process.env.NODE_PATH并执行Module._initPaths(),模块搜索路径不会生效
解决:
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')
问题三:管道连接 ENOENT
页面报:
connect ENOENT \\.\pipe\wadesk-protocol-bridge
这说明渲染进程请求管道时,服务没有监听成功。根因不是管道本身,而是 utilityProcess 启动后因为找不到 express 直接退出了。
解决 express 依赖解析后,日志出现:
server-ready: {"type":"server-ready","pipeName":"\\\\.\\pipe\\wadesk-protocol-bridge","pid":33884}
这就证明管道服务已经监听成功。
问题四:GET /health 报 JSON parse 错误
日志:
SyntaxError: Unexpected token 'n', "null" is not valid JSON
原因:
GET /health不应该有 body- axios 请求配置里传了
data: null - Express JSON body parser 尝试解析
"null",严格模式报错
解决:
if (requestOptions.data !== null && requestOptions.data !== undefined) {
axiosOptions.data = requestOptions.data
}
十一、热更新发布流程建议
生产上可以这样设计:
- 主包内永远带一个稳定 fallback 版本:
resources/app.asar/dist/electron/server/utility-server.js
- 热更新系统下载新服务脚本到临时文件:
%APPDATA%/electron-demo/utility-process/utility-server.js.tmp
-
校验文件 hash、版本号、签名。
-
校验通过后原子替换:
utility-server.js.tmp -> utility-server.js
-
下次应用启动时自动使用用户目录版本。
-
如果热更新版本异常,可以删除用户目录脚本,自动回退包内版本。
这套机制的好处是:
- fallback 永远在安装包内
- 用户目录脚本可独立热更新
- 不污染用户目录的
node_modules - 回滚简单
- 主进程只需要做启动前选择
十二、几个工程细节
1. 不要把所有 node_modules 复制到用户目录
热更新脚本要尽量薄,只放业务入口和业务逻辑。依赖复用安装包。
2. 用户目录脚本要和当前包依赖版本兼容
因为脚本用的是当前安装包里的依赖,所以热更新脚本不能随便使用当前包没有的依赖版本。可以在脚本里带一个 manifest,声明需要的 app version 或 dependency version。
3. native 依赖要单独处理
如果热更新脚本依赖 native .node 模块,不建议直接走 asar 虚拟路径。要考虑:
asarUnpack- ABI 匹配
- Electron 版本匹配
- 平台和架构
- 真实文件路径要求
4. 日志要保留启动链路信息
至少记录:
- 选择了用户脚本还是包内脚本
nodePathservicePathstdoutstderrserver-readyexit code
5. 健康检查要保留启动来源
/health 返回里保留:
startedFrom: __filename,
cwd: process.cwd(),
utilityServerNodePath: process.env.UTILITY_SERVER_NODE_PATH
这比肉眼看目录更可靠。
6. 用户目录 preload.js 也可以复用包内 node_modules,但路径要从网页 URL 透传
如果热更新的不只是 utility-server.js,还包括用户目录下的 preload.js,它同样可能需要引用当前安装包里的依赖。
例如:
const axios = require('axios')
这时也会遇到同一个问题:用户目录下的 preload 文件默认不会从 app.asar/node_modules 里解析依赖。
但 preload 的参数传递方式要特别注意。
错误理解是把参数挂到 preload 文件 URL 上:
<webview
src="https://www.baidu.com"
preload="file://C:/Users/senzo/AppData/Roaming/electron-demo/preloads/webview-preload.js?node_path=xxx">
</webview>
这种方式对 preload 自己通常没有意义。因为 preload 运行时看到的 window.location 是被嵌入网页的 URL,不是 preload 文件自己的 URL。
也就是说,preload 里:
window.location.href
拿到的是:
https://www.baidu.com/...
而不是:
file://.../webview-preload.js?node_path=xxx
所以如果要让 preload 通过 location.search 拿到参数,参数应该挂在 webview src 的目标网页 URL 上:
<webview
src="https://www.baidu.com?node_path=xxx"
preload="file://C:/Users/senzo/AppData/Roaming/electron-demo/preloads/webview-preload.js">
</webview>
主进程或渲染进程在拼接 webview src 时,可以这样做:
const targetUrl = new URL('https://www.baidu.com')
targetUrl.searchParams.set('node_path', nodeModulesPath)
const webviewSrc = targetUrl.toString()
preload 里读取:
const Module = require('module')
const params = new URLSearchParams(window.location.search)
const nodePath = params.get('node_path')
if (nodePath) {
process.env.NODE_PATH = nodePath
Module._initPaths()
}
const axios = require('axios')
这和 utility-server.js 的思路一致:真正让依赖路径生效的,不是“外部传了一个字符串”本身,而是 preload 自己在 require() 之前执行:
process.env.NODE_PATH = nodePath
Module._initPaths()
不过这里有一个安全问题:node_path 在形式上来自网页 URL,不能无条件信任。
如果 webview 加载的是远程页面,理论上这个 URL 可能被跳转、被拼接、被用户篡改。preload 不应该随便接受任意页面传来的 node_path。至少要做来源校验:
const allowedHosts = new Set(['www.baidu.com'])
if (allowedHosts.has(window.location.hostname)) {
const nodePath = new URLSearchParams(window.location.search).get('node_path')
if (nodePath) {
process.env.NODE_PATH = nodePath
Module._initPaths()
}
}
更稳的做法是不要完全相信 URL 里的路径值,而是只把它当一个开关或版本标识。真正的依赖路径仍由 preload 内部根据固定规则推导,或者对传入路径做白名单前缀校验:
const expectedPrefix = 'C:\\Users\\senzo\\AppData\\Local\\Programs\\electron-demo\\resources\\app.asar\\node_modules'
if (nodePath && nodePath === expectedPrefix) {
process.env.NODE_PATH = nodePath
Module._initPaths()
}
总结一下:
preload="file://...preload.js?node_path=xxx"不是合适的透传方式webview src="https://target.com?node_path=xxx"才能让 preload 通过window.location.search读取- preload 必须在
require('第三方库')之前设置process.env.NODE_PATH并执行Module._initPaths() - 远程网页 URL 传入的路径必须校验,不能无条件信任
- 对安全要求更高的场景,应尽量用固定路径推导或白名单,而不是完全相信 query 参数
十三、总结
这套方案的核心不是“把文件复制出来运行”,而是“启动前选择可热更新的用户脚本,缺省回退包内脚本”。
最终职责边界是:
- Electron 安装包:提供主进程、渲染进程、fallback 服务脚本、稳定依赖
- 用户目录:提供可热更新服务脚本
- 主进程:选择启动文件、注入依赖路径、管理 utilityProcess 生命周期
- utilityProcess:初始化模块搜索路径、启动 Express、监听 named pipe
- 渲染进程:通过 named pipe 调用服务,使用
/health验证链路
最容易踩坑的是 node_modules。用户目录脚本不在 app.asar 里面,默认不会从 app.asar/node_modules 找依赖。必须显式传入依赖路径,并在服务脚本里用 Module._initPaths() 初始化搜索路径。
只要把这个点处理好,用户目录热更新脚本 + 包内依赖复用 + utilityProcess 隔离执行这条链路,就能比较稳地落到生产包里。