用Bun Shell构建TypeScript自动化脚本 — 从安装到实战模式

用Bun Shell构建TypeScript自动化脚本 — 从安装到实战模式

基于Bun 1.3.14实际实验的Bun Shell完整指南。涵盖$模板字面量、.nothrow()错误处理、Promise.all并行化及macOS echo陷阱,附真实执行日志。还包含与zx的实质差异及生产环境部署的注意事项。

写shell脚本的时候,我总有个小烦恼。用bash写虽然熟悉但在Windows上会出问题。Node.js的child_process写起来回调满天飞。用zx又需要额外安装包。就在这时我试了试Bun Shell,起初以为不过是个zx的翻版,真正跑起来之后,想法有些改变了。

这篇文章基于我在Bun 1.3.14上实际实验的结果。文档里写的和实际运行的有出入的地方,我如实记录了下来。

Bun Shell是什么,为什么现在值得关注

Bun是JavaScript运行时,同时也是包管理器、打包工具和测试运行器。整个项目的目标是把碎片化的生态系统整合成一个工具。就像Python的uv整合了pip、pyenv和poetry一样,Bun把npm/yarn/pnpm加测试运行器加打包工具合并成了一个。

Bun Shell是这种整合哲学在shell脚本领域的延伸。安装bun之后,不需要额外配置,就可以在TypeScript内用$模板字面量直接执行shell命令。

和zx的区别

说实话,API表面上很像。两者都用$`command`语法。核心区别只有一个:Bun Shell不依赖bash。

zx在内部调用系统的bash(或sh)。Windows上没有bash的话,就需要WSL或Git Bash。Bun Shell内置了用Rust实现的自有shell,不需要bash也能运行。lsrmechocdmkdir等常用命令在Windows、macOS、Linux上的运行结果完全一致。

如果团队里有Windows开发者,这个区别就很重要了。

安装方法

安装Bun只需一行命令:

curl -fsSL https://bun.sh/install | bash

安装完成后,PATH会自动添加到shell配置文件(~/.zshrc~/.bashrc)中。在当前会话中生效:

export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
bun --version  # 1.3.14

初始化新项目:

mkdir my-scripts && cd my-scripts
bun init -y

bun init会自动生成package.jsontsconfig.jsonindex.ts。TypeScript无需额外配置就能直接运行,这点很方便。

基本模式: 用$模板字面量执行命令

最基本的用法:从内置bun模块导入$

import { $ } from "bun";

// 执行命令
await $`echo "Hello from Bun Shell"`;

// 捕获输出
const files = await $`ls -la`.text();
console.log(files);

// JavaScript变量插值(自动转义!)
const filename = "my file.txt";  // 含空格
await $`echo "${filename}" > output.txt`;
// → output.txt中保存"my file.txt"(空格被正确转义)

变量插值的自动转义确实有效。我测试了含空格的文件名,无需额外处理就能正确识别。这减少了bash脚本中因忘记给"${var}"加引号而出错的情况。

输出格式方法

// 以字符串返回
const text = await $`ls`.text();

// 以行数组返回(Bun特有的便利方法)
const lines = await $`ls`.lines();
// → ["file1.ts", "file2.ts", ...]

// 以Blob返回
const blob = await $`cat file.txt`.blob();

.lines()是把输出按行解析成数组的便利方法,比手写text().split('\n')更简洁。

错误处理、环境变量和管道

两种错误处理模式

Bun Shell中命令失败(exit code != 0)时,默认会抛出异常。

// 模式1:try/catch
try {
  await $`ls /nonexistent-dir`;
} catch (e) {
  console.log("错误:", e.message); // "Failed with exit code 1"
}

// 模式2:.nothrow() — 用exitCode代替抛出异常
const result = await $`ls /nonexistent-dir`.nothrow();
console.log(result.exitCode); // 1
console.log(result.stderr.toString()); // 错误信息

实际工作中我更常用.nothrow()。检查文件是否存在、命令是否安装等场景比try/catch更简洁:

const nodeResult = await $`node --version`.nothrow();
if (nodeResult.exitCode === 0) {
  console.log("Node.js:", nodeResult.stdout.toString().trim());
} else {
  console.log("Node.js未安装");
}

这个模式在我的实验中验证可以正常使用。

设置环境变量

// 设置全局默认值
$.env({ API_KEY: "secret123", PATH: process.env.PATH! });

// 仅对单个命令生效
const result = await $`echo $LOCAL_VAR`
  .env({ LOCAL_VAR: "only this command", PATH: process.env.PATH! })
  .text();

注意:.env()传入的对象会完全替换现有环境变量,而不是合并。如果忘了带PATH,后续所有命令都找不到可执行文件。

管道

// Bun Shell内置管道
const sorted = await $`printf "banana\napple\ncherry\n" | sort`.text();
// → apple, banana, cherry

// 去重+排序(使用文件重定向)
await Bun.write("input.txt", "banana\napple\ncherry\napple\n");
const unique = await $`sort < input.txt | uniq`.text();

这里有个陷阱。在macOS上,echo "banana\napple"中的\n不会被解释为换行符。与Linux bash的echo -e不同,macOS默认的echo不处理转义序列。需要用printf代替。

Bun Shell虽然不依赖bash运行,但内置命令的行为仍然遵循所在操作系统的规范,这一点要牢记。

并行执行:Promise.all是关键

在Bun Shell中并行执行多个命令需要使用Promise.all。顺序写的命令会顺序执行。

// 顺序执行(~200ms)
await $`sleep 0.1`;
await $`sleep 0.1`;

// 并行执行(~100ms)
await Promise.all([
  $`sleep 0.1`,
  $`sleep 0.1`,
]);

我直接测量了一下,顺序执行约471ms,并行执行约263ms。overhead比预期大,这是因为macOS进程创建本身有成本。不过对于IO密集型任务,并行化效果还是明显的。

实用构建脚本示例

Bun Shell在构建脚本中最能体现价值。可以将shell操作与TypeScript逻辑混合在同一个文件中:

import { $ } from "bun";

const DIST = "./dist";
const SRC = "./src";

async function build() {
  // 清理构建目录
  await $`rm -rf ${DIST} && mkdir -p ${DIST}`;

  // 获取TypeScript文件列表
  const tsFiles = await $`ls ${SRC}/*.ts`.text();
  const files = tsFiles.trim().split("\n");

  console.log(`构建目标:${files.length}个文件`);

  // 并行处理
  await Promise.all(
    files.map(async (f) => {
      const name = f.split("/").pop()!.replace(".ts", ".js");
      await $`bun build ${f} --outfile ${DIST}/${name}`;
    })
  );

  // 检验结果
  const built = await $`ls ${DIST}/`.text();
  console.log("构建完成:", built.trim().replace(/\n/g, ", "));
}

build().catch(console.error);

将这个脚本保存为scripts/build.ts,用bun run scripts/build.ts执行。不需要Node.js或ts-node,体感上轻松很多。将这个构建脚本接入GitHub Actions CI/CD流水线是本地自动化跑通后自然的下一步。

实验中发现的陷阱

诚实地说。

陷阱1:.stdin() API在1.3.14中不可用

你可能见过$`command`.stdin("text")这样的写法。在Bun 1.3.14中,这个API并不存在,运行时会报stdin is not a function错误。

替代方案:

// ❌ 1.3.14中不可用
await $`sort | uniq`.stdin("banana\napple\ncherry");

// ✅ 替代方案1:使用文件
await Bun.write("/tmp/input.txt", "banana\napple\ncherry\n");
await $`sort < /tmp/input.txt | uniq`;

// ✅ 替代方案2:用printf构建管道
await $`printf "banana\napple\ncherry\n" | sort | uniq`;

这是我发现的最意外的地方。部分文档里有这个API的示例,但当前稳定版本里根本没有,使用前要确认所用版本。

陷阱2:$.env()是替换而非合并

// ❌ 危险:PATH会消失
$.env({ MY_VAR: "value" });
await $`ls`;  // 可能报错

// ✅ 安全:明确包含PATH
$.env({ MY_VAR: "value", PATH: process.env.PATH! });

陷阱3:macOS的echo不解释\n

前面已经说过,但值得再强调一遍:Bun Shell使用系统原生的echo。macOS上echo "a\nb"输出的是字面量a\nb,而不是两行。需要换行的管道输入请用printf

// ❌ macOS上不如预期
await $`echo "apple\nbanana\ncherry" | sort`;
// → 输出一行 "apple\nbanana\ncherry"

// ✅ 各平台通用
await $`printf "apple\nbanana\ncherry\n" | sort`;

什么时候用Bun Shell,什么时候不用

我的结论:项目已经基于Bun,就有足够理由用Bun Shell;否则从zx开始更现实。

适合用Bun Shell的场景

  • 项目已经用Bun作为包管理器:无需额外依赖就能用shell脚本。
  • 团队有Windows开发者:需要不依赖bash的跨平台shell。
  • 希望把构建/部署脚本统一成TypeScript:配置代码和shell操作在同一个文件里处理。

不必用Bun Shell的场景

  • 项目基于Node.js + npm,没有迁移计划。
  • 已有复杂bash脚本,Bun Shell的兼容性不确定。
  • zx已经运行良好,团队也很熟悉。

我不认同”Bun Shell比zx更好”这种说法。从生态成熟度和下载量来看,zx更占优。Bun Shell是”用Bun的人的自然选择”,而不是”所有项目都应该弃用zx”。

还有一点,.stdin() API尚不稳定让我觉得遗憾。一旦稳定下来,基于stdin的管道处理会简洁很多,现在还需要绕路。

现在到底值不值得用

实际安装运行之后,Bun Shell的开发体验比我想象的好。变量自动转义、.nothrow()模式、.lines()这样的便利方法,这些细节设计在zx里也见不到。

不过目前仍是1.x版本,部分API还不稳定。在生产CI/CD脚本中使用之前,建议在实际环境中充分验证。与Claude Code hooks等自动化流水线集成时也同样如此。

Bun在快速发展,Shell API也会逐渐稳定。现在没有迫切需要放弃zx的理由,但新的Bun项目不妨先试试内置shell。


实验环境

  • Bun:1.3.14(macOS arm64)
  • 沙箱:/tmp/bun-lab-final/
  • 实验日期:2026-05-25

常见问题

Bun Shell和zx的核心区别是什么?
API语法几乎相同,但Bun Shell不依赖bash。zx在内部调用系统的bash或sh,因此Windows上需要WSL或Git Bash。Bun Shell内置了用Rust实现的自有shell,让ls、rm、echo等命令在Windows、macOS、Linux上的运行结果完全一致。
在Bun 1.3.14中可以用.stdin()直接传字符串吗?
不可以。在1.3.14中直接传字符串会报stdin is not a function错误。最稳定的替代方案是用Bun.write写入文件再重定向,或者用printf构建管道。
使用$.env()时为什么必须手动加上PATH?
因为传给$.env()的对象会完全替换现有环境变量,而不是合并。如果漏掉PATH,后续所有命令都会找不到ls等可执行文件,所以必须显式包含process.env.PATH。
现在应该用Bun Shell替代zx吗?
如果项目已经基于Bun,或团队里有Windows开发者,那么它无需额外依赖即可使用,是个自然的选择。但如果项目基于Node.js加npm且没有迁移计划,或者zx已经运行良好,那么继续用zx更现实。

阅读其他语言版本

这篇文章有帮助吗?

您的支持能帮助我创作更好的内容。请我喝杯咖啡吧。

关于作者

jw

Kim Jangwook

AI/LLM专业全栈开发者

凭借10年以上的Web开发经验,构建AI代理系统、LLM应用程序和自动化解决方案。分享Claude Code、MCP和RAG系统的实践经验。

返回博客列表