Skip to content
0

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/stderrdata 事件来获得子进程的输出内容

返回值

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)

还可以通过监听 stdoutstderr,获取子进程的输出内容:

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}`)

选项

介绍 spawnSyncspawn 的一些选项

option-encoding

spawnSyncspawn 的设置方法略有不同

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) //=> VW50

option-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
});

spawnspawn 默认不开启, execexecSync 会默认开启新的 shell 来执行,fork 则是不可指定

spawnspawnSyncexecexecSyncexecFilefork
默认不启动不启动启动启动不启动不启动
类型boolean/stringboolean/stringstringstringboolean/stringnull

stdio

上面说到,stdio 选项的值可以是 IOType 或者是一个长度为3的数组,分别控制子进程的 stdinstdoutstderr

stdio 的默认值是 pipe,也就是说,默认情况下子进程的标准输入、标准输出和标准错误都会通过管道和父进程交互

inherit

inherit 选项代表 stdio(stdin/stdout/stderr) 都从父进程继承,举个例子

  1. stdout 的影响:
console.log('helloworld')
const ls = spawnSync('node', ['child.js'], {
    stdio: ['inherit', 'inherit', 'inherit'] // 或者 'inherit'
})

当执行 index.js 的时候,可以,子进程的输出会输出到父进程中(因为是用的父进程的 stdout),所以我们能看到控制台中打印了 helloworld

$ node index.js
helloworld
  1. 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: world

ignore

设置后即忽略子进程对于的流

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
child

ipc

根据之前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 中运行命令,完成后将 stdoutstderr 传递给回调函数
  • child_process.execFile(): 与 exec() 类似,不同之处在于它直接生成命令,而无需默认先生成 shell。
  • child_process.fork(): 生成一个新的 Node.js 进程并调用指定的模块,并建立 IPC 通信通道,允许在父级和子级之间发送消息。
  • child_process.execSync(): exec() 的同步版本将阻止 Node.js 事件循环
  • child_process.execFileSync(): execFile() 的同步版本,将阻止事件循环。

fork

forkspawn 的一种特殊形式,返回的子进程附带和父进程的 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() 在子进程运行时,会实时通过监听子进程的 stdoutstderr 流事件来获取输出。也就是说它会逐块获取子进程的输出,实现流式传输。

例子:

exec('node child.js', (error, stdout, stderr) => {
    if (error) {
      console.error(`exec error: ${error}`);
      return;
    }
    console.log(`stdout: ${stdout}`);
    console.error(`stderr: ${stderr}`);
})

execSync

execSyncexec 的同步版本,直接返回 stdout,所以经常用于获取某个 shell 执行后的输出

const stdout = execSync('git config user.name', {
    encoding: 'utf-8'
})

console.log(stdout) //=> peterroe

execFile

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仓库

Released under the MIT License.