我那个桌面应用里有个三维电路设计器,界面是前端的,生成和处理走本地一个 PHP 服务。最早我把服务直接在启动时拉起来,问题一堆:启动变慢、端口偶尔被占、关掉应用后进程还在后台占着端口。后来改成懒启动加独立进程,才算稳定下来。这篇把整个过程里踩到的东西按顺序写一遍。

为什么挂外部服务,而不是全塞进主进程

理由只有一个:那边的生态我不想重写。文件处理、模板、已有的业务逻辑都在 PHP 那套里,搬到 Node 里重写一遍要花的时间远超接入成本。

顺带还有一个好处是隔离。服务崩了主进程还在,可以提示用户重试;服务写进主进程,一个死循环就能把整个界面拖住。

代价也要说清楚:多一个要打包的二进制、多一套路径逻辑、多一份进程清理的活。这三样是后面所有坑的来源。

懒启动:第一次用到才起

用户不开那个功能,就永远不启动。这不只是省内存,更重要的是启动路径上少一个可能失败的东西,白屏和启动报错的概率都会小一点。

实现上要处理一个问题:两个请求同时进来,不能起两份服务。用一个 Promise 把启动过程缓存住,后来的请求等同一个 Promise 就行。

let starting = null;
let port = 0;

async function ensureService() {
  if (port) return port;
  if (!starting) {
    starting = startService().finally(() => { starting = null; });
  }
  return starting;
}

注意最后那个 finally,启动失败时要把缓存清掉,不然下次调用会拿到一个已经 reject 的 Promise,永远起不来。

别用 sleep 等它起来

子进程 spawn 出来不代表服务能用了,它还要初始化、绑端口、加载配置。用固定 sleep 等,在慢机器上一定翻车,在快机器上白等。

两种靠谱的做法。一种是读子进程的 stdout,服务启动成功会打一行就绪日志,看到关键字再继续。另一种是轮询一个健康检查接口,通了就算起来了。我用的是后者,因为换服务实现时不用去猜它打什么日志。

async function waitReady(p, timeout = 5000) {
  const t0 = Date.now();
  while (Date.now() - t0 < timeout) {
    try {
      const r = await fetch(`http://127.0.0.1:${p}/health`);
      if (r.ok) return true;
    } catch (e) { /* 还没起来,继续等 */ }
    await new Promise((r) => setTimeout(r, 50));
  }
  return false;
}

超时要给用户一个明确的话,别只转圈。我这里的处理是「本地服务启动超时」加一个重试按钮,比一直等好得多。

端口:固定还是让系统分配

固定端口的好处是调试方便,浏览器里直接敲地址就能看接口返回。坏处是用户机器上可能被别的东西占了,占了就起不来。

我的做法是两步:先试固定的那个端口,失败就让系统分配一个可用端口,实际端口记在内存里,前端通过 IPC 拿,不写死在前端代码里。

还有一个容易漏的:服务可能监听成功但端口已经被上一个残留进程占着。所以我启动前会先探一次这个端口,如果通,说明有残留,先清掉再起。这一条是踩过之后加的,现象是用户重装后功能一直打不开。

退出清理:Windows 上要杀进程树

这是最容易漏的一步,也是我吃过最多次亏的地方。

在 will-quit 里 kill 子进程,Linux 和 macOS 上基本没问题,Windows 上只杀掉父进程,它自己再拉起来的子进程会留着,继续占着端口。用户看到的现象是应用关了,下次打开功能却打不开,任务管理器里一堆残留。

Windows 上要用进程树:

const { execSync } = require('child_process');
app.on('will-quit', () => {
  if (!child) return;
  if (process.platform === 'win32') {
    try { execSync(`taskkill /PID ${child.pid} /T /F`); } catch (e) {}
  } else {
    child.kill('SIGTERM');
  }
});

还有更狠的情况:主进程被强杀或者崩溃,will-quit 根本不会触发。所以启动时那次端口探测不能省,它就是兜底。

打包后路径:可执行文件不能留在 asar 里

asar 是个归档,里面的 exe 不能直接执行。用到外部二进制的项目,要在打包配置里把它解出来:

"asarUnpack": [
  "resources/service/**"
]

然后路径从 resourcesPath 拼,不要用 __dirname。开发时 __dirname 指向源码目录,打包后指向 asar 内部,两个路径完全不一样,这就是「开发能跑、装完就坏」的典型原因。

const base = app.isPackaged
  ? path.join(process.resourcesPath, 'app.asar.unpacked', 'service')
  : path.join(__dirname, '..', 'service');

只监听 127.0.0.1

服务绑 0.0.0.0 会给局域网开一个口子,同一网段的人能直接访问,而且 Windows 防火墙会弹授权框,用户一看要放行网络访问就慌了。

绑本机,前端也用 127.0.0.1 访问,防火墙不弹窗,也没有外部入口。这一条没有例外,桌面应用不需要对外提供服务。

能不用就别用

如果只是算得慢,用 utilityProcess 起一个 Node 子进程就够了,不用多一个二进制、不用管路径和端口。加外部服务的唯一理由是那里有现成的生态你不想重写。

判断标准很实用:这段逻辑你能不能接受用 Node 重写一遍。能接受就重写,接入外部服务的隐藏成本比看上去高,路径、端口、清理、打包,每一项都能单独出一个线上故障。