从 Node.js API 迁移到 Bun 原生 API

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

把 package manager 换成 Bun 很简单,真正费判断的是源码。看到 node:fsprocess.argvperformance,是不是都应该寻找一个 Bun.* 替代品?

我在迁移博客时确实换掉了不少 Node API,但最后没有追求“零 Node import”。Bun 原生 API 在文件读写、glob 和脚本执行上很顺手;路径处理、临时目录和 Next.js 环境变量继续使用兼容 API 反而更清楚。

文件读取:Bun.file()

原来的内容加载是同步读取:

const source = fs.readFileSync(filePath, 'utf8');

迁移后:

const sourceFile = Bun.file(filePath);
if (!(await sourceFile.exists())) return null;

const source = await sourceFile.text();

Bun 文件 I/O 文档说明,Bun.file() 返回惰性加载的 BunFile,创建对象时不会立刻读磁盘。它还实现了 Blob 接口,可以直接放进标准 Response

const file = Bun.file(filePath);

return new Response(file, {
  headers: {
    'Content-Type': file.type || 'application/octet-stream',
  },
});

这正好满足附件接口,不需要先把整张图片读进 Buffer

文件写入:Bun.write()

URL 推送脚本要更新一份 JSON 记录。过去通常写成:

await fs.writeFile(filePath, JSON.stringify(data, null, 2));

Bun 可以直接写字符串、Blob、ArrayBuffer 或 Response:

await Bun.write(filePath, `${JSON.stringify(data, null, 2)}\n`);

读取 JSON 也不需要先拿文本:

const data = await Bun.file(filePath).json();

这些替换减少的是胶水代码,并没有改变业务逻辑。

目录扫描:Bun.Glob

同步递归目录通常要处理 readdirSync()Dirent 和子目录:

for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
  // 判断目录,再递归
}

迁移后用一个 glob 扫描内容根目录:

const glob = new Bun.Glob('**/*');

for await (const relativePath of glob.scan({
  cwd: contentDir,
  onlyFiles: true,
})) {
  const publicPath = relativePath.split(path.sep).join('/');

  if (
    /^posts\/.+\.md$/i.test(publicPath) ||
    /^pages\/[^/]+\.md$/i.test(publicPath)
  ) {
    markdownFiles.push(relativePath);
  }
}

Bun.Glob 文档列出了 cwdonlyFiles、符号链接和隐藏文件等选项。这里还有一个额外收益:一次扫描同时得到文章和附件集合,Markdown 重写本地图片时只查 Set,不用对每个链接再次执行 existsSync()statSync()

命令行参数:Bun.argv

Bun 直接运行 TypeScript,所以 .mjs 脚本可以通过 git mv 改成 .ts,不需要再安装 tsx

参数读取由:

const args = process.argv.slice(2);

变为:

const args = Bun.argv.slice(2);

脚本命令也更直接:

{
  "scripts": {
    "load-test": "bun scripts/load-test.ts",
    "baidu:push": "bun --env-file=.env scripts/push-baidu-urls.ts"
  }
}

这类脚本是 Bun 独占入口,使用 Bun.argvBun.env 没有兼容成本。

高精度计时:Bun.nanoseconds()

服务器访问日志原来使用:

const startedAt = process.hrtime.bigint();
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;

现在使用:

const startedAt = Bun.nanoseconds();
const durationMs = (Bun.nanoseconds() - startedAt) / 1_000_000;

Bun API ReferenceBun.nanoseconds() 定义为从进程启动开始计算的高精度单调时钟。它适合测持续时间,不受系统时间调整影响。

浏览器组件中的 performance.now() 没有改。那本来就是标准 Web API,也不存在 Node 兼容问题。迁移时只看函数名,很容易把已经正确的代码也改坏。

路径与 URL:只换有明确收益的部分

next.config.ts 原来为了得到 monorepo 根目录使用 node:pathprocess.cwd()

outputFileTracingRoot: path.resolve(process.cwd(), '../..');

配置文件自身位置更适合用 URL 表达:

outputFileTracingRoot: Bun.fileURLToPath(
  new URL('../..', import.meta.url),
);

但正文扫描仍保留 node:path

path.resolve()
path.relative()
path.dirname()
path.basename()
path.extname()

Bun 没有完整原生 path API。自己重写 Windows 盘符、分隔符和越界判断,既不是迁移收益,也很难测试完整。

import.meta.dir 只代表当前模块位置

在普通 Bun workspace 中,这段代码没问题:

export const contentDirectory = import.meta.dir;

我曾让 Next.js 直接导入这个 workspace 包,希望生产环境也能得到仓库里的 content 目录。Turbopack 打包后,模块位置发生变化,原始源码目录的假设不再成立,生产构建因此失败。

最后我让 content workspace 保留 import.meta.dir,只供自己的本地脚本使用;Blog 不再导入它,而是根据 standalone 的工作目录和 tracing 配置找到内容。

这不是 import.meta.dir 的 bug。问题是我把“模块当前目录”误当成“打包前 workspace 永久目录”。

测试中的 node:fs 不必强行删除

测试需要下面这组操作:

  • 创建唯一临时目录。
  • 递归创建 posts
  • 写入一批 Markdown。
  • 测试结束后递归删除目录。

Bun 原生文件 API 对读写单个文件很方便,但 Bun 文档仍建议用 node:fs 完成 mkdirreaddir 等未覆盖操作。因此测试继续使用:

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

测试由 bun test 执行,Node 兼容模块由 Bun 提供。换成 shell 命令或自制临时目录工具不会让测试更“原生”,只会更绕。

process.envprocess.exitCode 也要看语义

普通 Bun 脚本可以读 Bun.env,但 Next.js 约定的 NODE_ENVNEXT_RUNTIMENEXT_PUBLIC_* 继续使用 process.env。这是框架接口。

错误处理同样不能机械替换:

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

process.exitCode = 1 允许当前任务完成后以失败状态退出。Bun.exit(1) 会立即终止进程,可能截断尚未刷新的日志或清理逻辑。两者不是同一个 API 的两种拼写。

我保留下来的判断规则

适合优先迁移:

  • 文件内容读取和写入。
  • glob 扫描。
  • Bun 专用脚本的参数与环境变量。
  • 服务器端高精度持续时间。
  • 测试运行器。

适合继续使用兼容 API:

  • 跨平台路径运算。
  • 临时目录和递归目录管理。
  • Next.js 明确规定的 process.env
  • Next 内部 HTTP Server 类型与事件。
  • 只提供类型、不进入生产包的 @types/node

完整项目迁移过程见 Next.js 16 博客完整迁移到 Bun 1.4。如果仍然觉得保留 node: 很别扭,可以继续看 Next.js 的 nodejs runtime 为什么仍能由 Bun 执行