08. spawn():长任务与流式输出

本章解决的问题

spawn(command, args, options) 是日常调用外部程序最通用的 API。它不等待整个输出完成,而是立即返回 ChildProcess,通过流读取结果。

import { spawn } from "node:child_process";

const child = spawn("python", [
  "scripts/analyze.py",
  "--input",
  "data/sample.csv",
], {
  cwd: process.cwd(),
  env: { ...process.env, PYTHONUNBUFFERED: "1" },
  shell: false,
  stdio: ["ignore", "pipe", "pipe"],
  windowsHide: true,
});

参数数组不会被 Shell 再次拆词,因此带空格的文件路径仍是一个参数,也降低了命令注入风险。shell: true 会改变这一点。

高频属性和方法

成员 含义
child.pid 成功创建后通常是子进程 PID
child.stdin 写入子进程标准输入,可能为 null
child.stdout 读取标准输出,可能为 null
child.stderr 读取标准错误,可能为 null
child.exitCode 尚未退出时为 null
child.signalCode 被信号结束时记录信号
child.kill() 请求操作系统发送信号
child.killed 是否成功发出过信号,不表示已经退出

Chunk 不是一行

child.stdout.setEncoding("utf8");

let remainder = "";
child.stdout.on("data", (chunk: string) => {
  remainder += chunk;
  const lines = remainder.split(/\r?\n/);
  remainder = lines.pop() ?? "";

  for (const line of lines) {
    if (line.length > 0) {
      console.log("完整行:", line);
    }
  }
});

child.stdout.on("end", () => {
  if (remainder.length > 0) {
    console.log("最后一行:", remainder);
  }
});

一次 data 可能包含半行、多行,甚至一个 UTF-8 字符的部分字节。设置编码后,Node.js 的解码器会处理跨 Chunk 字符;行边界仍需自己处理。生产代码也可以使用 node:readline

大输出必须流式处理

import { createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { spawn } from "node:child_process";

async function exportReport(): Promise<void> {
  const child = spawn("python", ["scripts/export.py"], {
    stdio: ["ignore", "pipe", "pipe"],
  });

  const stderrChunks: Buffer[] = [];
  child.stderr.on("data", (chunk: Buffer) => {
    // 实际项目还应限制收集大小。
    stderrChunks.push(chunk);
  });

  const [streamResult, processResult] = await Promise.allSettled([
    pipeline(child.stdout, createWriteStream("report.csv")),
    waitForClose(child),
  ]);

  // 等待两边都完成后再抛错,避免一个 Promise 提前失败时遗漏另一边的收尾。
  if (processResult.status === "rejected") {
    throw processResult.reason;
  }

  if (streamResult.status === "rejected") {
    throw streamResult.reason;
  }
}

function waitForClose(child: ReturnType<typeof spawn>): Promise<void> {
  return new Promise((resolve, reject) => {
    let startError: Error | undefined;

    child.once("error", (error) => {
      startError = error;
    });

    child.once("close", (code, signal) => {
      if (startError !== undefined) {
        reject(startError);
        return;
      }

      if (code === 0) resolve();
      else reject(new Error(`子进程失败:code=${code}, signal=${signal}`));
    });
  });
}

如果配置为 pipe 却不消费输出,操作系统管道缓冲区可能填满,子进程会阻塞在写操作上,看起来像“莫名卡死”。示例在失败时还应按业务策略删除部分生成的 report.csv

向 stdin 写入

const child = spawn(process.execPath, ["parser.js"], {
  stdio: ["pipe", "inherit", "inherit"],
});

child.stdin.write('{"name":"Alice"}\n');
child.stdin.end(); // 告诉子进程:输入结束

忘记 end() 时,等待 EOF 的子程序可能永远不退出。大输入也应使用 pipeline() 处理背压。

事件时间线

调用 spawn
  → spawn:操作系统进程创建成功
  → data:可触发 0 次或多次
  → exit:进程退出
  → close:stdio 已关闭,适合最终结算

错误案例

// 错误:每个 Chunk 当作一个 JSON 对象。
child.stdout.on("data", (chunk: Buffer) => {
  JSON.parse(chunk.toString("utf8"));
});
// 错误:无限累积,外部程序可耗尽 Node.js 内存。
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
  output += chunk.toString("utf8");
});

最佳实践

练习

  1. 子脚本每 100 毫秒输出一行 JSON,父进程逐行解析进度。
  2. 将子进程 stdout 用 pipeline() 写入文件。
  3. 向子进程 stdin 连续写入 100 MB 数据,正确处理背压。

验收清单