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

在前后端分离或 Monorepo 全栈项目中,本地开发往往需要同时运行多个进程:前端构建、后端 API、数据库容器等。这通常意味着要打开多个终端窗口,分别执行 npm run dev 或启动脚本。当项目规模扩大,手动管理这些进程不仅占用屏幕空间,还增加了维护成本。

一旦某个子进程异常退出,开发者很难在多个窗口中第一时间发现。更麻烦的是,直接关闭终端窗口往往无法彻底清理后台残留的 Node 进程,导致端口被占用或内存泄漏。

通过 Node.js 的 child_process 模块实现一个轻量级的进程管理器,可以将这些子进程收拢到同一个生命周期中,统一处理启动、日志聚合和优雅退出。

一、多窗口管理的实际困扰

日常开发中,最直接的痛点是上下文切换。每次启动项目,都要依次打开终端、切换目录、输入命令。如果后端服务挂了,你需要在多个窗口间切换查看日志。

关闭项目时的问题更隐蔽。直接关闭终端窗口(尤其是使用 Ctrl+C 时)有时无法正确传递信号给所有子进程,导致“孤儿进程”继续在后台运行。这不仅浪费资源,还会在下次启动时报“端口已被占用”的错误。

核心需求其实很简单:用一个命令启动所有相关进程,在一个窗口里看所有日志,并且能干净利落地关闭它们。

二、进程编排模型

为了避免引入 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[Ctrl+C / SIGINT] --> I[主进程拦截信号]
    I -->|发送 SIGTERM| C
    I -->|发送 SIGTERM| D
    I -->|发送 SIGTERM| E
    J{等待子进程退出} -->|超时| K[强制 SIGKILL]
    J -->|全部退出| L[主进程退出]

这个模型的核心在于主进程对子进程生命周期的完全掌控。

三、实现方案

以下代码使用 child_processpath 模块实现了一个基础的并发管理器。它不依赖 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();
}

四、需要注意的实际问题

在落地这个方案时,有几个细节需要处理:

  1. 日志交织(Log Interleaving):多个进程同时输出会导致日志行混杂。上面的代码通过给每一行添加 [task-name] 前缀来区分来源。如果日志量极大,可能需要引入缓冲区或日志文件轮转。
  2. 跨平台兼容性:Windows 和 Unix 系统对命令解析不同。在 spawn 选项中设置 shell: true 通常能解决大部分路径解析问题,但在某些 CI 环境中可能需要调整。
  3. 孤儿进程防护:如果主进程本身崩溃(如 OOM),子进程可能会变成孤儿。在生产环境或长期运行的场景中,建议配合系统级的进程监控工具(如 systemd 或 Docker 的 restart policy)使用。

五、总结

通过 Node.js 原生 API 管理子进程,可以省去引入重型依赖的成本。这个脚本的核心价值在于统一了启动和退出的入口,减少了开发过程中的手动操作,让本地环境的维护变得更加可控。

更多推荐