子进程与 Shell
本教程共 34 篇 · 第 28 篇 · 更新于 2026-08-06
本节目标:
- 用
Bun.spawn/Bun.spawnSync启动外部进程,理解 stdin/stdout/stderr 的配置方式。- 用
Bun.$以类 bash 的模板字符串写出跨平台的 Shell 命令。- 掌握管道
|、重定向>/<、命令替换$(...)等 Shell 语法。- 用
.text()/.json()/.lines()把命令输出解析成字符串、JSON 或逐行流。- 知道 Bun Shell 默认转义输入以防注入,以及超时、错误处理与两种 API 的取舍。
在 Bun 里驱动外部程序有两种路径:一是底层的 Bun.spawn(进程级控制,适合需要精细掌控流、信号、IPC 的场景);二是上层的 Bun.$(Shell 语法糖,写脚本最顺手)。两者都能跨平台运行。
先说清楚一个容易混淆的点:Bun.spawn 不经过 shell。你传进去的是一个字符串数组,Bun 直接 execve 那个可执行文件,命令里的 |、>、* 都只是普通字符,不会被解释。这既是它更安全的原因,也是它写不了管道的原因。想要 shell 语义,才该用 Bun.$。
28.1 Bun.spawn:底层进程控制
命令以「字符串数组」形式传入,返回 Bun.Subprocess 对象。
const proc = Bun.spawn(["bun", "--version"]);
console.log(await proc.exited); // 0(退出码)
第二个参数是可选的配置对象:
const proc = Bun.spawn(["bun", "--version"], {
cwd: "./path/to/subdir", // 工作目录
env: { ...process.env, FOO: "bar" }, // 环境变量
onExit(proc, exitCode, signalCode, error) {
// 退出回调
},
});
proc.pid; // 子进程 ID
输入与输出流
stdin 的可选值:null(默认、不提供输入)、"pipe"(返回可写入的 FileSink)、"inherit"(继承父进程)、Bun.file()、TypedArray、Response、Request、ReadableStream、Blob、或文件描述符数字。
const proc = Bun.spawn(["cat"], {
stdin: await fetch("https://example.com/data.txt"),
});
const text = await proc.stdout.text();
console.log(text);
stdout / stderr 默认是 ReadableStream;也可以设为 "pipe"(默认 stdout)、"inherit"(默认 stderr)、"ignore"、Bun.file() 或文件描述符数字。
用 "pipe" 时,父进程可以增量写入子进程的输入:
const proc = Bun.spawn(["cat"], { stdin: "pipe" });
proc.stdin.write("hello");
proc.stdin.write(new TextEncoder().encode(" world!"));
proc.stdin.flush();
proc.stdin.end();
退出处理、超时、信号
const proc = Bun.spawn(["bun", "--version"]);
await proc.exited;
proc.killed; // 是否曾被 kill
proc.exitCode; // number | null
proc.signalCode; // null | "SIGABRT" | ...
proc.kill(); // 发送 SIGTERM
proc.kill(15); // 用数字信号
proc.kill("SIGTERM"); // 用信号名
用 timeout / killSignal 让超时子进程自动退出:
const proc = Bun.spawn({
cmd: ["sleep", "10"],
timeout: 5000, // 5 秒后终止
killSignal: "SIGKILL" // 默认 SIGTERM
});
await proc.exited;
Bun.spawnSync:同步版本
需要「跑完再往下走」时用 Bun.spawnSync。它返回的不是流,而是已经读完的缓冲区:
const result = Bun.spawnSync(["echo", "hello"]);
result.stdout.toString(); // "hello\n"(Buffer)
result.stderr.toString();
result.exitCode; // 0
result.success; // true
它还支持 maxBuffer:输出超过该字节数时 Bun 会立刻停止读取并杀掉进程,这个行为与 Node.js 一致。
// 让 yes 吐出 100 字节就收手,否则它会一直打印下去
const r = Bun.spawnSync({ cmd: ["yes"], maxBuffer: 100 });
Tip父
bun进程会等待所有子进程退出才结束。启动的是长驻后台进程时,调用proc.unref()解绑,父进程就不会被它拖住。
用 stdio 一次性配置三个流
除了分别写 stdin/stdout/stderr,也可以用 stdio 数组一次配好,顺序是 [stdin, stdout, stderr]。它会覆盖单独设置的那三个字段:
// 三个流全部继承父进程:子进程的输出直接打到你的终端
const proc = Bun.spawn(["npm", "--version"], {
stdio: ["inherit", "inherit", "inherit"],
});
await proc.exited;
调试构建脚本时这一行特别好用——不用再手动转发输出。
交互式程序:terminal(PTY)
有些程序检测到自己不在真实终端里就会关掉彩色输出,或者干脆拒绝交互。给 Bun.spawn 传 terminal 选项能挂一个伪终端(PTY),子进程会认为自己跑在真终端上:
const proc = Bun.spawn(["bash"], {
terminal: {
cols: 80,
rows: 24,
data(terminal, chunk) {
process.stdout.write(chunk);
},
},
});
proc.terminal.write("echo hello\n");
await proc.exited;
proc.terminal.close();
Note挂了
terminal之后,proc.stdin/stdout/stderr全部变成null,所有读写都走proc.terminal。v1.3.14 通过 ConPTY 补齐了 Windows 上的Bun.Terminal支持。
进程间通信(IPC)
两个 bun 进程之间可以用内置 IPC 通道直接传消息:
// parent.ts
const child = Bun.spawn(["bun", "child.ts"], {
ipc(message, childProc) {
// 收到子进程发来的消息
childProc.send("Respond to child");
},
});
child.send("I am your father");
子进程这边用的是和 Node.js child_process.fork() 完全一样的接口:
// child.ts
process.send({ message: "hello from child" });
process.on("message", message => {
console.log(message);
});
Warning默认的序列化方式是 JSC 的
serialize(等价于structuredClone能力),只在两个 bun 进程之间通用。要和一个 Node.js 进程通信,必须显式设置serialization: "json"——两个运行时的 JS 引擎不同,二进制序列化格式对不上。
28.2 Bun.$:类 bash 的 Shell
Bun.$ 是一个模板字符串标签,把 shell 命令写在反引号里,并能把变量/表达式插值进去。它跨平台(Windows/Linux/macOS 均可),并且原生实现了 ls、cd、rm、echo 等常见命令,无需额外安装 rimraf/cross-env。
import { $ } from "bun";
await $`echo "Hello World!"`; // 默认打印到 stdout
await $`echo "Hello World!"`.quiet(); // 静默,不打印
const welcome = await $`echo "Hello World!"`.text(); // 取回字符串
console.log(welcome); // "Hello World!\n"
管道、重定向、命令替换
// 管道
const result = await $`echo "Hello World!" | wc -w`.text(); // "2\n"
// 重定向 stdout 到文件
await $`echo bun! > greeting.txt`;
// 重定向 stderr 到文件
await $`bun run index.ts 2> errors.txt`;
// 把文件作为 stdin
await $`cat < myfile.txt`;
// 命令替换:把另一条命令的输出嵌入当前脚本
await $`echo Hash of current commit: $(git rev-parse HEAD)`;
重定向运算符一览:<(stdin)、>/1>(stdout)、2>(stderr)、&>(两者)、>>/ 2>>/&>>(追加)、1>&2、2>&1。还可以把 JS 对象作为重定向目标:
import { $ } from "bun";
const buffer = Buffer.alloc(100);
await $`echo "Hello World!" > ${buffer}`;
console.log(buffer.toString()); // "Hello World!\n"
读取输出:.text() / .json() / .lines()
// 作为字符串(.text() 会自动静默)
const s = await $`echo "Hello World!"`.text();
// 作为 JSON
const obj = await $`echo '{"foo": "bar"}'`.json(); // { foo: "bar" }
// 逐行读取(适合大输出 / 流式处理)
for await (const line of $`cat list.txt | grep bun`.lines()) {
console.log(line);
}
环境变量与工作目录
import { $ } from "bun";
// 单次命令设置环境变量
await $`FOO=bar bun -e 'console.log(process.env.FOO)'`;
// 全局修改默认环境变量
$.env({ FOO: "bar" });
await $`echo $FOO`; // bar
// 单次覆盖 cwd
await $`pwd`.cwd("/tmp");
// 全局修改默认 cwd
$.cwd("/tmp");
Warning命令替换要用
$(...)语法,不要用反引号嵌套(如await $`echo \`echo hi\“),这与 Bun 对模板字符串raw属性的内部处理冲突。
28.3 错误处理
默认情况下,退出码非 0 会抛出 ShellError,其中包含 exitCode、stdout、stderr:
import { $ } from "bun";
try {
const output = await $`something-that-may-fail`.text();
console.log(output);
} catch (err) {
console.log(`Failed with code ${err.exitCode}`);
console.log(err.stdout.toString());
console.log(err.stderr.toString());
}
用 .nothrow() 关闭抛错、自行判断 exitCode;也可以对 $ 本身调用 $.nothrow() / $.throws(false) 改变全局默认行为。
import { $ } from "bun";
const { exitCode, stderr } = await $`grep nothing ./log.txt`.nothrow();
if (exitCode !== 0) {
console.log("没找到,但这不算错误");
}
grep 没匹配到内容时退出码是 1,diff 发现差异时退出码也是 1。这类「非 0 但不是失败」的命令,就该用 .nothrow(),否则脚本会被一个正常结果中断。
28.4 安全性:默认防注入
Bun.$ 会对插值进命令的字符串默认转义,因此即使用户输入里带有 ; rm -rf ... 也不会被当成命令执行:
import { $ } from "bun";
const foo = "bar123; rm -rf /tmp";
// 下面的 foo 只是作为字符串值传入,分号不会被解释为命令分隔符
await $`FOO=${foo} bun -e 'console.log(process.env.FOO)'`; // bar123; rm -rf /tmp
Note这正是
Bun.$相比直接拼接字符串调用Bun.spawn(["sh","-c","..."])更安全的地方。除非你完全信任输入,否则优先用模板插值而非手动拼 shell 字符串。
28.5 流式、中止与资源统计
用 ReadableStream 作为 stdin
把一段流直接喂给子进程输入,省去先把数据落盘:
const stream = new ReadableStream({
start(controller) {
controller.enqueue("Hello from ");
controller.enqueue("ReadableStream!");
controller.close();
},
});
const proc = Bun.spawn(["cat"], { stdin: stream, stdout: "pipe" });
const output = await proc.stdout.text(); // "Hello from ReadableStream!"
用 AbortSignal 中止
const controller = new AbortController();
const proc = Bun.spawn({ cmd: ["sleep", "100"], signal: controller.signal });
// 之后想中止:
controller.abort();
timeout / killSignal 与 AbortSignal 配合时,killSignal 也决定中止时发送的信号。
进程退出后的资源统计
const proc = Bun.spawn(["bun", "--version"]);
await proc.exited;
const usage = proc.resourceUsage();
console.log(`Max RSS: ${usage.maxRSS} bytes`);
console.log(`CPU user: ${usage.cpuTime.user} µs`);
console.log(`CPU system: ${usage.cpuTime.system} µs`);
Shell 的内置命令
Bun Shell 原生实现了不少常用命令(如 ls、cd、rm、echo、cat、pwd、cp、mv 等),因此在 Windows 上也无需额外安装 GNU 工具链即可使用。遇到平台差异时,优先用这些内置命令而不是依赖系统二进制。
28.6 两种 API 怎么选
| 需求 | 推荐 |
|---|---|
| 精细控制流、信号、IPC、超时、文件描述符 | Bun.spawn / Bun.spawnSync |
| 写构建/部署/运维脚本、管道与重定向 | Bun.$ |
| 需要把 JS 对象当作 stdin/stdout | 两者都支持(Shell 更直观) |
| 需要同步阻塞执行 | Bun.spawnSync |
Tip大多数脚本场景下
Bun.$更省心:跨平台、有.text()/.json()/.lines()解析、默认防注入。只有当你要和非bun子进程做 IPC、或要对文件描述符做底层操作时,才需要落到Bun.spawn。
28.7 常见坑与最佳实践
-
变量要作为参数插值,不要拼整段命令。
Bun.$会对插值内容转义,但如果你把一整条不可信字符串硬塞进反引号模板(而不是把命令与参数分开),仍然容易出错。正确做法:命令与参数分别写,${value}只放数据。const dir = "/tmp/data"; await $`ls ${dir}`; // ✓ 安全:dir 被当作单个参数转义 // 不要写成:await $`ls ${untrustedWholeCommand}`; -
stdout/stderr是一次性流。Bun.spawn返回的proc.stdout默认是ReadableStream,消费一次后就没了。想多次使用,先await proc.stdout.text()缓存,或用Bun.$的.text()/.json()/.lines()直接解析。 -
父进程会等待子进程退出。 没调用
proc.unref()时,bun主进程要等所有子进程结束才能退出;长驻后台进程记得unref()解绑。 -
Windows 上个别命令要走
bun exec。 像yes这类 Windows 不自带的命令,可写成Bun.spawn(["bun", "exec", "yes"]),让 Bun Shell 的内置实现兜底。注意bun x是bunx的别名,用途是执行 npm 包,两者别混。 -
重定向是覆盖,追加用
>>。> greeting.txt会清空重写;要保留原内容用>> greeting.txt(stderr 同理为2>>)。 -
.json()要求输出合法 JSON。 用Bun.$的.json()解析前,先确认命令真的只打印 JSON,否则会抛解析错误;不确定时先用.text()看原始输出。
Warning不要用
Bun.spawn(["sh", "-c", userInput])这类「把用户输入拼进 shell」的写法来图省事——这正是不少命令注入漏洞的源头。需要 shell 语义时优先用Bun.$,它的转义是默认开启的。
28.8 几个高频疑问
问:Bun.spawn 里能直接写 ls -la | grep bun 吗?
不能。Bun.spawn 不经过 shell,整条字符串会被当成一个可执行文件名去找,结果就是「命令不存在」。要么拆成 Bun.spawn(["ls", "-la"]) 再自己接流,要么直接用 await $`ls -la | grep bun`。
问:为什么 await proc.exited 之后读 proc.stdout 是空的?
stdout 默认是 ReadableStream,只能消费一次。如果中途已经读过(比如 await proc.stdout.text()),第二次自然就没有了。需要多处使用就先缓存成变量。
问:Bun.$ 会启动一个真的 bash 吗?
不会。Bun Shell 是 Bun 自己实现的解释器,ls、cd、rm、echo、cat、pwd、cp、mv 这些都是内置命令。所以 Windows 上不装 Git Bash 也能跑同一段脚本,这是它相对 child_process.exec 的最大优势。
问:子进程一直不退出,父进程也卡住了怎么办?
先确认是不是忘了 proc.stdin.end()——像 cat 这种从标准输入读到 EOF 才结束的程序,不关输入流就会永远等下去。其次可以加 timeout 兜底,或者对后台进程调用 proc.unref()。
问:怎么拿到命令的实时输出而不是等它跑完?
用 Bun.$ 的 .lines() 逐行消费,或者对 Bun.spawn 的 proc.stdout 直接 for await 迭代。两者都是流式的,不会先把结果攒在内存里。