首页 / Bun 入门教程 / 子进程与 Shell

Bun 入门教程

子进程与 Shell

本教程共 34 篇 · 第 28 篇 · 更新于 2026-08-06

BunBun.spawnBun.spawnSyncBun.$子进程Shell

本节目标:

  • 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()TypedArrayResponseRequestReadableStreamBlob、或文件描述符数字。

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.spawnterminal 选项能挂一个伪终端(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 均可),并且原生实现了 lscdrmecho 等常见命令,无需额外安装 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>&22>&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,其中包含 exitCodestdoutstderr

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 / killSignalAbortSignal 配合时,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 原生实现了不少常用命令(如 lscdrmechocatpwdcpmv 等),因此在 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 execyes 这类 Windows 不自带的命令,可写成 Bun.spawn(["bun", "exec", "yes"]),让 Bun Shell 的内置实现兜底。注意 bun xbunx 的别名,用途是执行 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 自己实现的解释器,lscdrmechocatpwdcpmv 这些都是内置命令。所以 Windows 上不装 Git Bash 也能跑同一段脚本,这是它相对 child_process.exec 的最大优势。

问:子进程一直不退出,父进程也卡住了怎么办? 先确认是不是忘了 proc.stdin.end()——像 cat 这种从标准输入读到 EOF 才结束的程序,不关输入流就会永远等下去。其次可以加 timeout 兜底,或者对后台进程调用 proc.unref()

问:怎么拿到命令的实时输出而不是等它跑完?Bun.$.lines() 逐行消费,或者对 Bun.spawnproc.stdout 直接 for await 迭代。两者都是流式的,不会先把结果攒在内存里。