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");
});
最佳实践
- 固定可执行程序,动态内容只进入参数数组。
- 大结果写文件或传给下游流;诊断输出设置字节上限。
- 同时消费 stdout 和 stderr,不能只读一个。
- 用
close做最终状态结算,用error识别启动失败。 - 为逐行协议处理
\r\n、半行和最后无换行的一行。
练习
- 子脚本每 100 毫秒输出一行 JSON,父进程逐行解析进度。
- 将子进程 stdout 用
pipeline()写入文件。 - 向子进程 stdin 连续写入 100 MB 数据,正确处理背压。
验收清单
- [ ] 不会把 Chunk 当作一行或完整 JSON。
- [ ] 能解释不消费 pipe 为什么会导致阻塞。
- [ ] 能处理 stdout 大输出而不无限占用内存。
- [ ] 知道何时关闭子进程 stdin。