Node.js中Child Process的实战应用与常见陷阱解析
我的写法,亲测靠谱
在 Node.js 项目里调用子进程(Child Process)这事儿,我干过太多次了——从打包脚本、自动化测试,到调用 Python 脚本处理图像,甚至跑 shell 命令部署服务。一开始图快,直接 exec('some-command') 一扔,结果日志乱码、进程卡死、内存泄漏轮番上阵,折腾得我半夜三点还在看 top。
后来我总结了一套自己的写法,核心就两点:永远用 spawn,别用 exec;永远监听所有事件,别只管 stdout。
下面这段是我现在项目里通用的封装:
const { spawn } = require('child_process');
function runCommand(command, args = [], options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'pipe',
...options,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('error', (err) => {
reject(new Error(Child process failed to start: ${err.message}));
});
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(Process exited with code ${code}: ${stderr}));
}
});
});
}
为什么这么写?首先,spawn 是流式处理,不会像 exec 那样把整个输出缓存到内存里。我曾经用 exec 跑一个输出 50MB 日志的命令,直接 OOM 挂掉。spawn 就稳多了,边读边处理,内存压力小。
其次,必须同时监听 stdout 和 stderr。很多命令出错时会把错误信息写到 stderr,如果你只监听 stdout,那错误就静默了,你以为成功了,其实早就崩了。
最后,close 事件比 exit 更可靠,因为它确保所有 I/O 都完成了。我之前用 exit,偶尔会漏掉最后一行日志,改用 close 后再没出现过。
这几种错误写法,别再踩坑了
我见过也写过不少反面教材,列几个高频雷区:
- 直接用
exec处理大输出:exec默认 buffer 限制是 1MB,超过就报错maxBuffer exceeded。你可能觉得“我这个命令输出不大”,但现实是,日志量不可控,尤其在生产环境。别赌,直接用spawn。 - 忽略
stderr:比如这样:exec('my-script.sh', (error, stdout) => { if (error) console.error(error); else console.log(stdout); });看似没问题,但如果
my-script.sh写了echo "error" >&2,你就收不到。更糟的是,有些工具即使退出码为 0 也会往stderr打 warning,你完全不知道。 - 不处理
error事件:如果子进程根本没启动成功(比如命令不存在),spawn会触发error事件,但不会触发close。如果你只监听close,那 Promise 就永远 pending,内存泄漏+逻辑卡死。 - 用
shell: true图方便:虽然spawn('ls -l', { shell: true })看着爽,但这是安全隐患。万一参数里有用户输入,比如'; rm -rf /',你就完蛋了。永远拆成[command, arg1, arg2]形式,让系统直接调用,绕过 shell 解析。
我去年在一个 CI 脚本里用了 shell: true + 用户传入的分支名,结果某次分支名带了个空格和分号,直接把临时目录清空了。从那以后,我对 shell: true 敬而远之。
实际项目中的坑
除了代码层面,实战中还有几个细节容易翻车:
1. 编码问题:Windows 上默认是 GBK,Linux 是 UTF-8。如果你在跨平台项目里跑子进程,输出中文可能会乱码。我的做法是统一设置 env:
const child = spawn('my-command', [], {
env: {
...process.env,
LC_ALL: 'en_US.UTF-8',
},
});
不过 Windows 不认 LC_ALL,所以更稳妥的方式是在读取 data 后手动转码,或者干脆避免输出非 ASCII 字符。
2. 进程残留:如果父进程 crash 了,子进程可能变成孤儿进程继续跑。我在长时间运行的服务里加了清理逻辑:
const children = [];
process.on('exit', () => {
children.forEach(child => {
if (!child.killed) child.kill();
});
});
// 创建子进程时 push 到 children
const child = spawn(...);
children.push(child);
虽然不能 100% 保证(比如 kill -9 父进程),但至少能覆盖大部分优雅退出场景。
3. 超时控制:子进程可能卡死(比如等用户输入)。我一般在外层加个超时:
const timeout = 60000; // 60s
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
child.kill();
}, timeout);
try {
await runCommand(...);
} catch (err) {
if (controller.signal.aborted) {
throw new Error('Command timed out');
}
throw err;
} finally {
clearTimeout(timer);
}
注意:child.kill() 只是发信号,不一定立刻终止,所以配合 AbortController 标记状态更安全。
4. 权限和路径:在容器或 CI 环境里,经常遇到 “command not found”。别假设 PATH 一致,最好用绝对路径,或者通过 which 先查一下。比如:
const { stdout } = await runCommand('which', ['ffmpeg']);
const ffmpegPath = stdout.trim();
await runCommand(ffmpegPath, [...]);
结尾唠叨两句
以上是我踩坑后总结的 Child Process 使用姿势。说实话,这套方案也不是完美的——比如跨平台编码问题还是有点麻烦,超时控制在极端情况下也可能失效。但至少在我们几十个 Node 项目里跑了一年多,没再出过子进程相关的线上事故。
核心思想就一句:别偷懒,把每个事件都兜住,把每个边界都考虑到。 子进程不是黑盒,它是个随时可能爆炸的定时炸弹,你得给它装上保险丝。
以上是我个人对 Child Process 的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多(比如和 Worker Threads 结合),后续会继续分享这类博客。

暂无评论