试 Bun 之后我更关心的不是跑分,而是反馈链路有没有变短

Bun 刚开始被频繁讨论时,我也看了很多跑分。

安装依赖快、启动快、测试快、脚本执行快。数字确实漂亮,但让我对它感兴趣的不是某一次 benchmark,是日常开发里那条反馈链路。

前端开发有很多高频小动作:装依赖、跑脚本、跑一组测试、启动本地服务、执行内容校验。每次等几秒看起来不算什么,但一天里反复发生,就会影响写代码的节奏。

所以我后来试 Bun,不是想证明它能替代谁,而是想看它能不能让这些小循环更短。

我没有从主应用开始迁移

成熟项目里,工具链通常牵一发动全身。

一个 Next.js 或 Vite 项目里,可能混着框架插件、CSS 处理、环境变量、测试环境、CI 缓存、monorepo workspace、部署脚本。你只看本地能不能跑通,远远不够。

所以我更愿意从低风险脚本开始。

比如内容检查、数据生成、独立工具脚本:

1{
2  "scripts": {
3    "check:content": "node scripts/check-content.js",
4    "generate:feed": "node scripts/generate-feed.js"
5  }
6}

可以先在分支里尝试:

1{
2  "scripts": {
3    "check:content": "bun scripts/check-content.js",
4    "generate:feed": "bun scripts/generate-feed.js"
5  }
6}

这类迁移影响范围清楚。脚本输入输出稳定,失败影响范围小,对比也容易。能跑通就继续观察,遇到兼容问题也容易回退。

我第一个搬过去的是站点的 RSS/sitemap 生成脚本。改完命令之后第一反应是快了不少,原来 node scripts/generate-feed.js 冷启动要等小一秒,换 bun 之后基本是按下回车就出结果——省的不是 CPU 时间,是那种“等一下”的心理停顿。但真正让我留意的是一个细节:脚本里我用了 import { readFileSync } from 'node:fs',Bun 跑得很干净,可有一段老代码写的是 require('fs') 混着 ESM 的 import,在 Node 里靠 .mjspackage.jsontype 勉强相安无事,到 Bun 里反而把这种含糊地带照出来了。这不算坑,反而是好事,逼我把模块系统理清楚。

还有一处值得记一笔。我那个脚本里用 new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) 格式化发布时间,本地 macOS 上 Node 和 Bun 输出一致,但 CI 的 Linux 镜像里 Bun 早期版本的 ICU 数据和 Node 不完全一样,生成出来的日期字符串差了个空格。要不是我做了文件 diff,这种东西根本发现不了。所以低风险脚本的“低风险”也是相对的——影响范围小,但语义差异该验证还得验证。

这比一上来把包管理、测试、构建、部署全换掉要稳很多。

Bun 的吸引力在“一体化”

Bun 不只是一个运行时。

它同时覆盖了:

  • JavaScript 运行时。
  • 包管理。
  • 测试运行器。
  • 打包器。
  • TypeScript 和 JSX 支持。

这也是它和很多单点工具不一样的地方。过去前端项目常见组合是 Node 负责运行,pnpm/npm/yarn 负责安装,Vite/Webpack 负责构建,Jest/Vitest 负责测试,再加各种转译和配置。

每个工具都专业,但组合起来确实复杂。

Bun 的方向是把这些能力收束到一个入口里,减少工具之间的摩擦。对新项目、工具脚本、小型服务来说,这种体验很直接。

这种一体化在小脚本里体感最明显的,是它对 TypeScript 的态度。我写了个临时的内容统计脚本,直接 bun run stats.ts,不用配 ts-node、不用 tsx、不用 --loader,也不用专门起一个 tsconfig。Bun 把 .ts 当一等公民,开箱就转译。这在过去是要折腾半天的:装 ts-node 会和 ESM 打架,换 tsx 又要对齐 Node 版本,遇到 path alias 还得再叠一层 tsconfig-paths。Bun 把这一摞中间层直接抹掉了,对“写完即跑”的小工具特别友好。

它内置的几个 API 也很顺手。比如读文件我可以直接:

1const file = Bun.file("./posts/index.json");
2const data = await file.json();

或者起一个极简的本地 mock 服务,连 express 都不用装:

1Bun.serve({
2  port: 3001,
3  fetch(req) {
4    const url = new URL(req.url);
5    if (url.pathname === "/api/posts") {
6      return Response.json([{ id: 1, title: "hello" }]);
7    }
8    return new Response("not found", { status: 404 });
9  },
10});

这种“标准库自带电池”的感觉,是我愿意在边缘场景先用它的主要原因。

但一体化也意味着你要接受它的约束。这些好用的 Bun.* API 一旦写进脚本,就是绑定关系,哪天想退回 Node 得手动还原。项目越成熟,既有工具链越稳定,迁移收益就越需要算清楚。

快不快要放到具体链路里看

我现在不太喜欢只讨论“Bun 比某某快多少”。

更实际的问题是:它快的地方是不是你项目真正痛的地方?

如果团队每天痛的是依赖安装慢、脚本启动慢、测试反馈慢,那 Bun 可能很值得试。

如果真正慢的是接口、构建产物过大、类型检查太重、E2E 环境启动慢,那换 Bun 未必能直接解决。

我会把反馈链路拆开看:

1安装依赖 -> 启动开发服务 -> 修改代码 -> 跑测试 -> 构建 -> 部署

然后问:哪一段最耗时间?哪一段最影响开发节奏?Bun 能不能安全替换这一段?

拿我自己的项目举例。我真量过一次,用最朴素的办法——删掉 node_modules 和 lockfile,分别跑安装:

1rm -rf node_modules package-lock.json
2time npm install
3# 然后清干净再来一遍
4rm -rf node_modules bun.lock
5time bun install

冷装(没有任何缓存)的差距没有跑分页那么夸张,但 bun install 确实快出一截,热装(有全局缓存、只是重建 node_modules)差距更明显,因为 Bun 用的是 hardlink/clone 那套,几乎不重复拷贝文件。对一个一天要切好几个分支、动不动 install 一次的项目,这一段省下来的时间是实打实的。

但同样这套测下来,我发现自己项目真正的瓶颈根本不在安装,而在类型检查和 Next 的构建。tsc --noEmit 慢、next build 慢,这俩跟你用不用 Bun 装依赖没半点关系。所以那次量完我反而冷静了:Bun 能治的是我的“快病”,治不了我的“慢病”。

这样比泛泛追跑分更有意义。

兼容性要实际验证

Bun 发展很快,但工具链迁移不能只看宣传页。

我会重点验证几类问题:

  • Node API 行为是否一致。
  • 依赖是否使用原生扩展。
  • lockfile 和包管理策略是否统一。
  • CI 环境是否稳定。
  • 测试里的 mock、fake timer、snapshot 是否一致。
  • 构建产物和原流程是否等价。

最容易被忽略的是“能跑”和“语义一致”不是一回事。

比如测试迁移到 bun test,跑得快当然好,但要确认断言行为、mock 机制、环境变量、DOM 环境都符合预期。速度提升不能以测试含义变化为代价。

我在测试这一层踩过最实在的一个坑:bun test 不是 Jest 的 drop-in 替代。它确实兼容了很大一部分 expect 语法,但具体到 jest.mock() 的提升行为、jest.useFakeTimers() 的实现、还有自定义 matcher,差异是存在的。我有一组依赖 vi.mock/jest.mock 自动 hoisting 的测试,搬过去之后 mock 没生效,排查半天才反应过来不是我代码的问题,是运行器语义不一样。更别说要测 React 组件,bun test 默认没有 jsdom,得自己配 happy-dom 或者 --preload 注入环境:

1// happydom.ts
2import { GlobalRegistrator } from "@happy-dom/global-registrator";
3GlobalRegistrator.register();
1# bunfig.toml
2[test]
3preload = ["./happydom.ts"]

这事我的结论是:纯逻辑的、不碰 DOM、不重度用 mock 的单测,搬去 bun test 收益明显且风险低;一旦测试里堆了大量 Jest 生态特有的东西,迁移就不是“换个命令”那么简单了,老实留在 Vitest/Jest 反而省心。

工具脚本也是一样。生成出来的文件要和原来对比,而不是只看命令退出码。我的土办法就是迁移前后各跑一遍,把产物 git diff 一下,一个字节都不差才算过——前面那个日期空格的问题,就是这么逮到的。

我会用 checklist 推进

如果一个成熟项目要试 Bun,我会按这种顺序来:

11. 单独开分支,不影响主流程
22. 先迁移一个低风险 script
33. 对比迁移前后的输出文件
44. 在 CI 里并行验证,不立刻替换主流程
55. 连续几轮无差异,再扩大到更多脚本
66. 最后才考虑测试、包管理或构建

这个流程看起来慢,但工具链最怕的是“本地快了,线上炸了”。

第 4 步在 CI 里并行验证,我具体是这么干的:不动原来的 job,新加一个只跑 Bun 的影子 job,让两边跑同样的脚本,结果都上传成 artifact,再比对。大概长这样:

1jobs:
2  build-node:
3    runs-on: ubuntu-latest
4    steps:
5      - uses: actions/checkout@v4
6      - uses: actions/setup-node@v4
7        with: { node-version: 20 }
8      - run: npm ci
9      - run: npm run generate:feed
10      - uses: actions/upload-artifact@v4
11        with: { name: feed-node, path: public/feed.xml }
12
13  build-bun:
14    runs-on: ubuntu-latest
15    steps:
16      - uses: actions/checkout@v4
17      - uses: oven-sh/setup-bun@v2
18      - run: bun install --frozen-lockfile
19      - run: bun run generate:feed
20      - uses: actions/upload-artifact@v4
21        with: { name: feed-bun, path: public/feed.xml }

两个 artifact 拉下来 diff 一下,连着几次提交都零差异,我才敢把主 job 换掉。这里要提醒一句:Bun 1.2 起默认 lockfile 是文本格式的 bun.lock,和 package-lock.json/pnpm-lock.yaml 一样能直接 git diff 看出改动,可读性好很多(更早版本用的是二进制的 bun.lockb,那种格式没法直接 review)。就算换成了文本格式,它和 package-lock.json/pnpm-lock.yaml 也不是一回事,过渡期容易出现两份 lockfile 并存、版本各算各的情况。CI 上一定要带 --frozen-lockfile,否则它可能在 CI 里偷偷解析出和本地不同的依赖版本,这种偏差最难查。

工程化不是追求最短迁移路径,而是追求可验证、可回退、可解释。

它适合先用在哪些地方

我觉得 Bun 很适合从这些地方试:

  • 独立内容生成脚本。
  • 本地 mock 服务。
  • 小型内部工具。
  • 低风险测试包。
  • 新项目原型。
  • monorepo 里的边缘工具包。

不太建议第一步就动这些地方:

  • 主应用构建链路。
  • 复杂 CI/CD。
  • 依赖很多原生模块的服务。
  • 已经非常稳定的 pnpm workspace。
  • 强依赖特定测试框架语义的项目。

这不是说不能迁,而是先后顺序要讲成本。工具链迁移如果没有明确收益,很容易变成“为了新而新”。

我对工具链趋势的感受

这两年前端工具链有一个明显方向:反馈要更快,默认配置要更少,工具之间的分工在重新收敛。

Bun 是这个方向里很有代表性的一个选择。它提醒我,开发体验不是小事。一次脚本快几秒,单看不起眼,但高频反馈链路变短,团队写代码、跑验证、改问题的节奏都会变好。

但工具选型最后看的不是兴奋感,而是净收益。

如果一个团队已经有稳定的 Node + pnpm + Vitest + Vite 流程,Bun 要带来的不仅是速度,还必须是不增加维护负担的速度。

这里有个容易被低估的隐性成本:团队认知。我在脚本里用了 Bun.fileBun.serve 之后,新来的同事如果只熟 Node,看到这些 API 会先愣一下。工具越“魔法”,门槛迁移得越隐蔽——不是装不上,是看不懂、改不动、出了问题不知道去哪查文档。所以哪怕只在边缘脚本里用,我也会在 README 里写清楚“这个脚本依赖 Bun,原因是 X,回退方案是 Y”。把绑定关系写在明处,比让它在某次半夜的构建里突然冒出来强。

另一个我现在的折中做法是:能用 Web 标准 API 就尽量用 Web 标准 API,少用 Bun.* 专属语法。比如 fetchURLResponse 这些 Node 18+ 和 Bun 都支持,写出来的脚本两边都能跑,迁移和回退的成本都低。把“用了 Bun 的速度”和“被 Bun 锁死”这两件事分开看,是我这段时间最大的体会。

我现在看 Bun,不会只看它能不能“替代 Node”。

对我来说更实际的问题是:它能不能让某一段开发反馈更短,并且迁移成本、兼容风险、团队理解成本都可控。

如果答案是肯定的,就从小脚本开始试。如果答案还不清楚,就继续观察。工具链更新很快,但项目稳定性更贵。