node:child_process
child_process 模块提供了生成子进程的能力
为何要创建子进程?
常见的场景是:在 JS 逻辑调用某个 shell 命令。
这个时候我们就可以创建一个子进程去执行,以 execa 为例子,假设我们想获得当前用户的 git 用户名,可以这样做:
import { execaSync } from 'execa'
const { stdout } = execaSync('git', ['config', '--get', 'user.name'])
console.log(stdout) //=> peterroe或者是一些打包命令
import { execaSync } from 'execa'
execaSync('vite', ['build', '--watch', '--minify'])spawn
通过 spawn 来执行 shell 命令,创建了一个子进程,可以监听 stdout/stderr 的 data 事件来获得子进程的输出内容
返回值
spawn 方法会返回一个 ChildProcess 实例,具有如下属性:
import { spawn } from 'node:child_process'
const child = spawn('node', ['./index.js'])
console.log(child)
/*
ChildProcess {
_events: [Object: null prototype] {},
_eventsCount: 0,
_maxListeners: undefined,
_closesNeeded: 3,
_closesGot: 0,
connected: false,
signalCode: null,
exitCode: null,
killed: false,
spawnfile: 'tsx',
_handle: [Process],
spawnargs: [Array],
pid: 37438,
stdin: [Socket],
stdout: [Socket],
stderr: [Socket],
stdio: [Array],
}
*/我们可以通过 child.pid 获取子进程的 pid,或者在适当的时机调用 child.kill() 结束子进程
// 例如大部分工具的 watch 模式下,子进程不会自己结束
const child = spawn('vue-cli-service', ['build', '--watch'])
// 在合适的时机调用 kill 手动结束进程
child.kill()
// 也可以通过 pid,指定结束某个子进程
process.kill(child.pid)还可以通过监听 stdout 和 stderr,获取子进程的输出内容:
import { spawn } from 'node:child_process'
const ls = spawn('ls', ['-l', '/Users'])
ls.stdout.on('data', (data) => {
console.log(`stdout: ${data}`); // 默认是 buffer 格式
});
ls.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});
ls.on('close', (code) => {
console.log(`child process exited with code ${code}`);
});spawnSync
是 spawn 的同步方法,效果类似,使用方式略有不同
import { spawnSync } from 'node:child_process'
const ls = spawnSync('ls', ['-l', '/Users'])
console.log(`stdout: ${ls.stdout}`) // 默认是 buffer 格式
console.log(`stderr: ${ls.stderr}`)
console.log(`child process exited with code ${ls.status}`)选项
介绍 spawnSync 和 spawn 的一些选项
option-encoding
spawnSync 和 spawn 的设置方法略有不同
const ls = spawnSync('node', ['child.js'], {
encoding: 'utf8',
})import { spawn } from 'node:child_process'
const ls = spawn('ls', ['-l', '/Users'])
ls.stdout.setEncoding('utf8').on('data', (data) => {
console.log(`stdout: ${data}`); // 获得 utf-8 输出格式的内容
});option-env
通过 env 选项传入环境变量,如果是要扩展 env,最好先从父进程的 process.env 继承
const ls = spawnSync('node', ['child.js'], {
env: Object.assign(process.env, { // 定义环境变量
KFC: 'VW50'
}),
})console.log(process.env.KFC) //=> VW50option-timeout
设置超时时间
const ls = spawnSync('node', ['child.js'], {
timeout: 10000, // 如果超过该时间子进程将被杀死
})option-cwd
设置子进程的当前工作目录,默认是 process.cwd()
const ls = spawnSync('node', ['child.js'], {
cwd: path.resolve(process.cwd(), '../'), // 设置子进程的当前工作目录
})option-stdio
这个选项控制子进程的 io 流,它的值是 options.stdio 类型
简而言之,可以是以下值或者值的组合,用 TS 表示 StdioOptions 就是:
type IOType = 'overlapped' | 'pipe' | 'ignore' | 'inherit';
type StdioOptions = IOType | Array<IOType | 'ipc' | Stream | number | null | undefined>;pipe: 相当于['pipe', 'pipe', 'pipe'](默认值)overlapped: 相当于['overlapped', 'overlapped', 'overlapped']ignore: 相当于['ignore', 'ignore', 'ignore']inherit: 相当于['inherit', 'inherit', 'inherit']or[0, 1, 2]
接下来详细介绍每一个选项的作用
option-shell
创建子进程可以指定新的某个 shell 来运行,默认情况是 false ,即不启动新的 shell 执行
如果为 true,则在 shell 内运行命令。在 Unix 上使用“/bin/sh”,在 Windows 上使用 process.env.ComSpec。可以将不同的 shell 指定为字符串。请参阅 Shell 要求和默认 Windows shell。默认值: false(无 shell)。
这有什么用呢?举个例子,想要执行 echo $ZSH_VERSION,显然得在 zsh 中执行才能得到结果,这个时候就得手动指定 shell 为 /bin/zsh
exec('echo $ZSH_VERSION', { shell: '/bin/zsh' }, (err, stdout) => {
console.log(stdout); // => 5.9
});spawn 和 spawn 默认不开启, exec 和 execSync 会默认开启新的 shell 来执行,fork 则是不可指定
| spawn | spawnSync | exec | execSync | execFile | fork | |
|---|---|---|---|---|---|---|
| 默认 | 不启动 | 不启动 | 启动 | 启动 | 不启动 | 不启动 |
| 类型 | boolean/string | boolean/string | string | string | boolean/string | null |
stdio
上面说到,stdio 选项的值可以是 IOType 或者是一个长度为3的数组,分别控制子进程的 stdin、stdout、stderr 流
stdio 的默认值是 pipe,也就是说,默认情况下子进程的标准输入、标准输出和标准错误都会通过管道和父进程交互
inherit
inherit 选项代表 stdio(stdin/stdout/stderr) 都从父进程继承,举个例子
- 对
stdout的影响:
console.log('helloworld')const ls = spawnSync('node', ['child.js'], {
stdio: ['inherit', 'inherit', 'inherit'] // 或者 'inherit'
})当执行 index.js 的时候,可以,子进程的输出会输出到父进程中(因为是用的父进程的 stdout),所以我们能看到控制台中打印了 helloworld:
$ node index.js
helloworld- 对
stdin的影响:
process.stdin.on('data', (chunk) => {
const input = chunk.toString().trim();
console.log('User input: ' + input);
});const ls = spawnSync('node', ['child.js'], {
stdio: ['inherit', 'inherit', 'inherit'] // 或者 'inherit'
})如下所示,在命令行输入的内容,会从父进程传到子进程。同时又因为子进程的 stdout 也是继承的,所以也能在父进程中看到来自子进程的输出:
$ node index.js
hello # 手动输入 hello
User input: hello
world # 手动输入 world
User input: worldignore
设置后即忽略子进程对于的流
const ls = spawnSync('node', ['child.js'], {
stdio: 'ignore'
})常见的场景包括:
- 只关注子进程退出状态,忽略其输出
- 子进程不需要任何输入
- 避免子进程输出混乱父进程中的日志
overlapped
一般用于 stdin 处, overlapped 的作用是:
- 阻塞子进程,防止子进程在父进程前完成
- 父进程的
stdout/stderr会和子进程共用,防止混乱 - 可以简化父子进程间的同步
基于上面的第一点原因,所以当 stdio 选项启用 overlapped 的时候,只能使用 spawn 而不是 spawnSync
const ls = spawn('node', ['child.js'], {
stdio: ['overlapped', 'inherit', 'pipe']
})
console.log('father')console.log('child')我们将得到如下结果:
$ node index.js
father
childipc
根据之前stdio的类型定义,stdio 还可以为 ipc 选项
举一个有意思的例子,我们将子进程的 stdout 设为 ipc,让子进程的输出输到和父进程的 ipc 通道里面去
WARNING
Child process can have only one IPC pipe
const child = spawn('node', ['child.js'], {
stdio: ['pipe', 'ipc', 'pipe']
})
child.on('message', (data) => {
console.log(`output: ${data}`);
});console.log('child')上面如此一来,子进程的 stdout 会被父进程当作和子进程的 ipc 通信的信息,所以 child.on('message') 能够监听到子进程的输出信息。
但是这种方式太麻烦了,我们可以使用 fork 方法直接实现 ipc 通信,node:child_process 中只有 fork 方法才能做到!
$ node index.js
output: child其他方法
为了方便起见,node:child_process 模块提供了一些 child_process.spawn() 和 child_process.spawnSync() 的同步和异步替代方案。这些替代方案中的每一个都是在 child_process.spawn() 或 child_process.spawnSync() 之上实现的:
child_process.exec(): 生成一个shell并在该shell中运行命令,完成后将stdout和stderr传递给回调函数child_process.execFile(): 与exec()类似,不同之处在于它直接生成命令,而无需默认先生成shell。child_process.fork(): 生成一个新的Node.js进程并调用指定的模块,并建立IPC通信通道,允许在父级和子级之间发送消息。child_process.execSync():exec()的同步版本将阻止 Node.js 事件循环。child_process.execFileSync():execFile()的同步版本,将阻止事件循环。
fork
fork 是 spawn 的一种特殊形式,返回的子进程附带和父进程的 ipc 通信信道,允许 Node 进程之间通过 ipc 信道直接传递消息!!!
TIP
但是不好的地方就是内部是调用的 node 命令,这意味着只能执行 JS 文件,而不能调用任意的 shell 命令 😦,如果想任意两个进程通信,可以使用 node-ipc
spawn 可以启动任何进程,而 fork 经常用于启动 node 进程,所以使用方法只需要传入文件的位置即可
import { fork } from 'node:child_process'
const child = fork('./child.js')
child.on('message', (data) => {
console.log(`output: ${data}`);
});
child.send('Here Here');process.send && process.send('child');
process.on('message', (msg) => {
console.log('from parent: ', msg);
});$ node index.js
output: child
from parent: Here Here遗憾的是 fork 只能用 node 执行文件,不能是其他的 shell 命令,如果想要执行任意 shell 命令的话,我们可以使用 node-ipc
exec
作用和 spawn 类似,shell 命令直接通过字符串形式传入即可,使用起来会更加简单
主要区别:
exec()会缓冲输出,spawn()输出实时流exec()在子进程完全退出后,才会通过回调函数一次性返回子进程的执行结果。也就是说它会缓冲子进程的全部输出,等待子进程结束后一次性返回。spawn()在子进程运行时,会实时通过监听子进程的stdout和stderr流事件来获取输出。也就是说它会逐块获取子进程的输出,实现流式传输。
例子:
exec('node child.js', (error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
return;
}
console.log(`stdout: ${stdout}`);
console.error(`stderr: ${stderr}`);
})execSync
execSync 是 exec 的同步版本,直接返回 stdout,所以经常用于获取某个 shell 执行后的输出
const stdout = execSync('git config user.name', {
encoding: 'utf-8'
})
console.log(stdout) //=> peterroeexecFile
和 exec 的主要区别
- 当需要直接执行一个外部可执行文件的子进程时,用
execFile更高效。 - 当命令简单,不需要重定向、管道等
bash功能时,用execFile更简洁。 - 当不需要处理子进程
IO,只关心退出码时,execFile用起来更简单。
execa
但是其原生的使用方式过于繁琐,实际上,我们经常使用它的第三方包替代品 execa,下面是一些在 execa 中更好的替代方法:
preferLocal
这个属性执行项目中安装的 node 包很有用,譬如假设 tsup 只安装在当前项目,不是全局安装,就必须要设置这个选项,不然就找不到 tsup 这个命令
execa('tsup', ['src/index.js'], {
preferLocal: true
})env
启动 shell 的时候,可以注入一些环境变量,会自动变成 process.env.xxx 的形式被访问
execa('tsup', ['src/index.js'], {
env: {
'MODE': 'production',
}
})更多选项
详情查看 execa仓库