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

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/