Bun Shell:在 JavaScript 中编写 Bash 脚本的新范式

Bun Shell 是 Bun 内置的跨平台 Shell,允许你用 JavaScript 模板字符串编写 Shell 脚本。本文详解 Bun Shell 的语法、管道、环境变量与自动转义机制,并与 Bash、zx、execa 对比,分析适用场景、常见误区和迁移落地方法。

写脚本这件事,很多团队都经历过同一个循环:最开始用 bash 图省事,写到一半发现要在 macOS、Linux、Windows 三种环境里保持一致行为;然后换成 Node.js 写,用 child_process 手工拼命令;再后来发现 zx 这类库也只是把字符串丢给系统 shell,Windows 上照样要装 Git Bash 才能跑通。

AI technology illustration

Bun Shell 提供了另一种解法。它不是一个包装 child_process 的 npm 库,而是 Bun 运行时里真正嵌入的一个跨平台 Shell。命令语法、管道、重定向、参数转义,全部由 Bun 自己解析和处理,而不是依赖系统 shell 的行为。这篇文章会从它的设计思路讲起,结合具体用法和边界,聊聊它和 Bash、zx 这类方案的本质区别,以及工程上什么时候值得切换。

脚本跨平台之痛是怎么来的

跨平台问题不是“换个命令”就能解决的。同一个 rm -rf dist && mkdir dist,在 Linux 的 CI 上没有任何问题,到了 Windows 的 PowerShell 里就不是这个语法了。很多团队的应对方式是强制所有人装 Git Bash 或者 WSL,但这意味着每个新同事入职都要先配环境,CI 和本地环境偶尔还会出现版本差异,维护成本一直存在。

于是 Node 生态出现了 shelljs、execa、zx 这一批工具。它们的共同点是:用 JavaScript 组织逻辑,把命令交给系统 shell 去执行。zx 本身很好用,但它的跨平台能力仍然被系统 shell 限制——你在 zx 里写的 ls *.ts,在 Windows 上最终执行时依然要面对 cmd 或 PowerShell 的差异。

真正麻烦的地方在于:JS 负责逻辑,shell 负责解析,中间隔着一层字符串。所有转义、引号、通配符展开的问题,都发生在这层字符串上。Bun Shell 把“解析”这件事从系统 shell 手里拿了过来,用自己的解析器处理命令语法,从而让同一段脚本在不同平台上有可预期的行为。这就是它被称为“新范式”的原因——不只是换了个 API,而是把执行层重写了。

Bun Shell 到底是什么

Bun Shell 的入口就是 JavaScript 的模板字符串:

import { $ } from "bun";

await $`echo hello`;

这行代码看起来只是把命令包了一层,但 Bun 在内部要做的事比想象中多:解析命令、识别参数、展开 glob、处理管道和重定向,然后基于当前平台选择正确的执行方式。你不需要关心命令应该交给 bash 还是 cmd,Bun Shell 自己完成了这层抽象。

因为它内嵌在 Bun 运行时里,脚本天然拥有 JavaScript 和 TypeScript 的全部能力。shell 脚本里最难写的部分——条件判断、循环、字符串处理、数据结构——都可以交给 JS,shell 只负责它擅长的事:调用外部命令。

把几种常见方案放在一起比较,差别会更清楚:

方案 跨平台能力 变量转义 依赖 适用场景
Bash 脚本 差,Windows 上基本要重写 手动转义,容易出错 系统自带 Unix 环境运维与 CI
Node.js child_process 中等,命令解析仍需自己处理 字符串拼接,风险高 Node.js 已有 Node 服务的胶水逻辑
zx 依赖系统 Shell 提供辅助函数,仍需注意 Node.js + bash 快速编写复杂 JS 逻辑脚本
Bun Shell 好,语法层统一解析 模板变量自动转义 Bun 运行时 跨平台开发脚本、CI、工具链

需要注意,这里的“跨平台”不是说 Bun Shell 内置了 rm、ls 这些命令,而是命令的解析和参数传递是一套统一逻辑。具体命令本身(比如 git、node)还是来自系统安装,但至少你不再需要为了语法差异维护两套脚本。

从最简单的用法开始

核心 API 就是 $ 这个模板函数。它返回一个 ShellPromise,可以直接 await,也可以调用 .text()、.json()、.lines() 等方法来拿结构化输出:

const output = await $`git log --oneline -5`.text();
console.log(output);

命令的输出默认会打印到终端;如果你想捕获而不是打印,就在后面接 .text() 这类方法。这一点和 bash 的命令替换有点像,但返回值是 JavaScript 对象,后续的逻辑直接用 JS 处理,不用再写 grep、awk 去切字段。

另一个和 bash 不同的地方是错误处理。Bun Shell 在执行命令时,如果退出码不为 0,会直接抛异常,异常对象里带有 stderr 和 exitCode。这个行为在脚本里非常友好——你可以在捕获异常时拿到完整的失败信息。如果你希望容忍某个命令失败,可以在管道末尾加 .nothrow(),这是写检测类脚本时很实用的能力:

const result = await $`git diff --exit-code`.nothrow();
if (result.exitCode !== 0) {
  console.log("有代码变更");
}

需要切换工作目录时,还可以通过 .cwd() 直接指定,比如让 npm 命令在子目录执行:

await $`npm run build`.cwd('./frontend');

环境变量:新手最容易踩的坑

Bun Shell 有一个和直觉不太一样的设计:默认情况下,它不会把父进程的环境变量传给子进程。也就是说,直接在脚本里写 echo $HOME 得到的可能是空值。第一次用的时候很容易怀疑是自己的脚本写错了,其实这是刻意设计,目的是避免脚本在不知情的情况下把密钥、token 传给外部命令。

需要环境变量时,显式传入即可:

await $`echo $DEPLOY_ENV`
  .env({ ...process.env, DEPLOY_ENV: 'production' });

这个设计在团队协作里其实是加分项:每个子进程能拿到哪些变量,代码里写得明明白白,而不是依赖运行环境“恰好有”某个变量。习惯了之后,你会觉得这种显式反而更安全。

模板变量:自动转义,告别拼接地狱

在 bash 里把动态内容拼进命令,是一件非常头疼的事。文件名里带空格、路径里有特殊字符,都可能让整条命令突然爆炸。而 Bun Shell 的模板变量注入是带自动转义的:

const message = 'hello "world" && rm -rf /';
await $`echo ${message}`;

上面这段代码会安全地输出原始字符串,不会执行 rm 命令。Bun Shell 在把 JavaScript 值插入命令时,会按照当前平台的规则做正确转义。你可以放心地把用户输入、文件路径、分支名直接传进命令,而不需要手工包引号,也不用再担心引号嵌套导致转义出错。

这是 Bun Shell 相比“字符串拼接命令”最本质的进步,也是它作为脚本范式的核心价值:数据是数据,命令是命令,中间由运行时负责翻译。

它和完整 Bash 之间的边界

Bun Shell 覆盖了日常脚本最常见的能力:管道、重定向、glob、环境变量,以及命令链式调用,但它不是完整 bash。bash 的数组、进程替换、复杂正则匹配、trap 信号处理这类高级特性,Bun Shell 并不会完整实现。

这意味着两件事。第一,不要把现有几十上百行的 bash 脚本原封不动搬进来,迁移过程必然涉及改写。第二,也不要用 bash 的思路写 Bun Shell——在 shell 语法里绕来绕去做复杂逻辑,不如直接用 JS 的 if/for 和数据结构。

Bun Shell 的正确用法是“薄薄一层 shell,厚厚一层 JS”:外部命令调用交给 $,其余逻辑全部交给 JavaScript。

什么时候该换,什么时候别换

从工程实践看,Bun Shell 最有价值的地方是团队内部开发脚本:package.json 里的 scripts、CI 里的集成步骤、开发工具链里的胶水脚本。这类脚本最大的痛点就是跨平台差异,而 Bun Shell 恰好提供了统一语法、自动转义和完整 JS 生态,能明显减少“在 Windows 上跑不起来”这类问题。

不太适合的场景也有这么几类:

  • 已经稳定运行且没有跨平台需求的纯 bash 脚本,没必要为迁移付出额外风险。
  • 重度依赖 bash 高级特性的脚本,比如大量使用数组、进程替换、复杂正则的运维脚本。
  • 对运行环境体积敏感的场景。Bun Shell 需要 Bun 运行时,在 CI 里通常不是问题,但要在用户机器上运行就需要额外安装。

从 zx 迁移到 Bun Shell 的路径倒是比较顺。因为 zx 本身就是在 JS 里组织 shell 命令,代码结构接近,很多场景只需要把 $ 的实现换掉,然后处理环境变量显式传入的问题。不过迁移前要先确认团队愿意把 Bun 作为新的运行时依赖,这个决策影响的不只是脚本。

常见误区

  • 误区一:Bun Shell 是一个可以单独安装的 npm 包。不是,它依赖 Bun 运行时本身,脚本需要用 bun run 执行。
  • 误区二:Bun Shell 是“JavaScript 版的 bash”。它更像一个跨平台的命令执行层,覆盖的是 bash 的子集,不是完整 bash。所有 bash 语法直接可用的预期一定会碰壁。
  • 误区三:环境变量可以直接使用。默认不继承 process.env,必须通过 .env() 显式传入。这既是安全设计,也是新人踩坑重灾区。
  • 误区四:有了 Bun Shell 就不再需要 bash。交互式终端、系统初始化、服务器运维这些场景,bash 的地位不会被动摇。Bun Shell 解决的问题域更小,也更聚焦。

落地第一步

如果你想在项目里引入 Bun Shell,建议从最痛苦的脚本开始替换,通常是那些在 Windows 上跑不了的 dev script,比如清理构建产物、启动本地服务、跑多步测试、解析工具输出并做后续处理。

import { $ } from "bun";

// 1. 拉取分支列表
const branches = (await $`git branch --list`.text())
  .split("\n")
  .map((item) => item.trim())
  .filter(Boolean);

// 2. 逐个检查最近提交,容忍单个分支失败
for (const branch of branches) {
  const result = await $`git log -1 --oneline ${branch}`.nothrow();
  if (result.exitCode === 0) {
    console.log(`${branch}: ${result.stdout.trim()}`);
  }
}

这个例子展示了三件关键的事:命令输出怎么变成 JS 数据、模板变量怎么安全传参、失败命令怎么容忍。把这些机制跑通之后,你会发现很多原本要 grep、sed、awk 组合完成的工作,在 Bun Shell 里直接用 JS 处理更直观,也更可维护。

最后说回标题里的“新范式”。Bun Shell 没有选择继续在 bash 语法上打补丁,而是把脚本的语法层和执行层重新拆开,放进了 JavaScript 生态。它不完美,也不是 bash 的终结者,但在跨平台开发脚本这个具体的战场上,它确实提供了一套更符合现代工程习惯的解法。如果你正在为 Windows 同事跑不起来的脚本头疼,不妨给它一个机会。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/616/

(0)
上一篇 5小时前
下一篇 5小时前

相关推荐