用 Node.js 原生 API 管理多子进程并发

用 Node.js 原生 API 管理多子进程并发 用 Node.js 原生 API 管理多子进程并发在前后端分离或 Monorepo 全栈项目中本地开发往往需要同时运行多个进程前端构建、后端 API、数据库容器等。这通常意味着要打开多个终端窗口分别执行npm run dev或启动脚本。当项目规模扩大手动管理这些进程不仅占用屏幕空间还增加了维护成本。一旦某个子进程异常退出开发者很难在多个窗口中第一时间发现。更麻烦的是直接关闭终端窗口往往无法彻底清理后台残留的 Node 进程导致端口被占用或内存泄漏。通过 Node.js 的child_process模块实现一个轻量级的进程管理器可以将这些子进程收拢到同一个生命周期中统一处理启动、日志聚合和优雅退出。一、多窗口管理的实际困扰日常开发中最直接的痛点是上下文切换。每次启动项目都要依次打开终端、切换目录、输入命令。如果后端服务挂了你需要在多个窗口间切换查看日志。关闭项目时的问题更隐蔽。直接关闭终端窗口尤其是使用CtrlC时有时无法正确传递信号给所有子进程导致“孤儿进程”继续在后台运行。这不仅浪费资源还会在下次启动时报“端口已被占用”的错误。核心需求其实很简单用一个命令启动所有相关进程在一个窗口里看所有日志并且能干净利落地关闭它们。二、进程编排模型为了避免引入 PM2 等重型工具我们可以利用 Node.js 原生的spawn机制构建一个极简的编排层。graph TD A[执行 npm run start:all] -- B[主进程 Process Manager] B --|spawn| C[前端构建] B --|spawn| D[后端 API] B --|spawn| E[数据库容器] C --|stdout/stderr| F[日志聚合] D --|stdout/stderr| F E --|stdout/stderr| F F --|带前缀输出| G[终端统一显示] H[CtrlC / SIGINT] -- I[主进程拦截信号] I --|发送 SIGTERM| C I --|发送 SIGTERM| D I --|发送 SIGTERM| E J{等待子进程退出} --|超时| K[强制 SIGKILL] J --|全部退出| L[主进程退出]这个模型的核心在于主进程对子进程生命周期的完全掌控。三、实现方案以下代码使用child_process和path模块实现了一个基础的并发管理器。它不依赖concurrently等第三方库重点在于信号处理和日志前缀。// run_all.js const { spawn } require(child_process); const path require(path); const projectRoot __dirname; // 定义需要启动的子任务 const TASKS [ { name: frontend, cmd: npm, args: [run, dev], color: \x1b[32m // 绿色 }, { name: backend, cmd: npm, args: [run, server], color: \x1b[34m // 蓝色 } ]; const spawnedProcesses []; function log(msg) { console.log(\x1b[35m[Manager]\x1b[0m ${msg}); } function startAllTasks() { log(Starting subsystems...); TASKS.forEach(task { const child spawn(task.cmd, task.args, { cwd: projectRoot, shell: true, env: { ...process.env, FORCE_COLOR: true } }); spawnedProcesses.push({ name: task.name, process: child }); // 输出 stdout添加颜色前缀 child.stdout.on(data, data { const lines data.toString().trim().split(\n); lines.forEach(line { console.log(${task.color}[${task.name}]\x1b[0m ${line}); }); }); // 输出 stderr child.stderr.on(data, data { const lines data.toString().trim().split(\n); lines.forEach(line { console.error(${task.color}[${task.name}-ERR]\x1b[31m ${line}\x1b[0m); }); }); child.on(close, code { log([${task.name}] exited with code ${code}); }); }); } // 处理退出信号确保子进程被清理 function setupSignalHandler() { const cleanShutdown () { log(Shutting down...); let pending spawnedProcesses.length; if (pending 0) { process.exit(0); } spawnedProcesses.forEach(item { // 尝试优雅关闭 item.process.kill(SIGTERM); }); // 设置超时防止子进程卡死 setTimeout(() { log(Timeout reached, forcing kill.); spawnedProcesses.forEach(item item.process.kill(SIGKILL)); process.exit(1); }, 3000); }; process.on(SIGINT, cleanShutdown); process.on(SIGTERM, cleanShutdown); } if (require.main module) { setupSignalHandler(); startAllTasks(); }四、需要注意的实际问题在落地这个方案时有几个细节需要处理日志交织Log Interleaving多个进程同时输出会导致日志行混杂。上面的代码通过给每一行添加[task-name]前缀来区分来源。如果日志量极大可能需要引入缓冲区或日志文件轮转。跨平台兼容性Windows 和 Unix 系统对命令解析不同。在spawn选项中设置shell: true通常能解决大部分路径解析问题但在某些 CI 环境中可能需要调整。孤儿进程防护如果主进程本身崩溃如 OOM子进程可能会变成孤儿。在生产环境或长期运行的场景中建议配合系统级的进程监控工具如 systemd 或 Docker 的 restart policy使用。五、总结通过 Node.js 原生 API 管理子进程可以省去引入重型依赖的成本。这个脚本的核心价值在于统一了启动和退出的入口减少了开发过程中的手动操作让本地环境的维护变得更加可控。