把 package manager 换成 Bun 很简单,真正费判断的是源码。看到 node:fs、process.argv 或 performance,是不是都应该寻找一个 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 文档列出了 cwd、onlyFiles、符号链接和隐藏文件等选项。这里还有一个额外收益:一次扫描同时得到文章和附件集合,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.argv 和 Bun.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 Reference把 Bun.nanoseconds() 定义为从进程启动开始计算的高精度单调时钟。它适合测持续时间,不受系统时间调整影响。
浏览器组件中的 performance.now() 没有改。那本来就是标准 Web API,也不存在 Node 兼容问题。迁移时只看函数名,很容易把已经正确的代码也改坏。
路径与 URL:只换有明确收益的部分
next.config.ts 原来为了得到 monorepo 根目录使用 node:path 和 process.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 完成 mkdir、readdir 等未覆盖操作。因此测试继续使用:
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';测试由 bun test 执行,Node 兼容模块由 Bun 提供。换成 shell 命令或自制临时目录工具不会让测试更“原生”,只会更绕。
process.env 和 process.exitCode 也要看语义
普通 Bun 脚本可以读 Bun.env,但 Next.js 约定的 NODE_ENV、NEXT_RUNTIME 和 NEXT_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 执行。