文件与文件系统
本教程共 34 篇 · 第 26 篇 · 更新于 2026-08-06
本节目标:
- 掌握
Bun.file()创建惰性文件引用,并以text()、json()、bytes()、stream()等多种形式读取内容。- 掌握
Bun.write()这把「写入瑞士军刀」:字符串、二进制、Blob、Response、另一个文件都能作为写入源。- 学会用
FileSink做增量写入,用Bun.stdin/Bun.stdout/Bun.stderr打通标准流。- 明确 Bun 原生 API 的边界:目录相关操作(
readdir、mkdir、appendFile)交给node:fs。- 熟悉路径处理与
Bun.Glob文件匹配的常用写法。
从本章开始,我们进入「内置 API 精选」篇。前面 25 章讲的是 Bun 作为运行时、包管理器、打包器、测试运行器的四大能力,而这一篇要讲的是另一件同样重要的事:Bun 在标准库层面提供了一批经过深度优化的原生 API,让很多过去要装第三方包才能干的事,现在一行内置调用就够了。文件 I/O 是其中最基础、也最能体现 Bun 设计取向的一块。
26.1 Bun.file():惰性的文件引用
在 Node.js 里读文件的习惯动作是 fs.readFile(path) —— 调用即读盘。Bun 的思路不同:Bun.file(path) 返回一个 BunFile 对象,它只是一个「指向磁盘某个位置的引用」,创建它并不会真的去读文件内容。
const foo = Bun.file("foo.txt"); // 相对于当前工作目录
foo.size; // 字节数
foo.type; // MIME 类型
BunFile 实现了 Web 标准的 Blob 接口,所以你可以用一组熟悉的方法把它「兑现」成具体格式:
const foo = Bun.file("foo.txt");
await foo.text(); // 字符串
await foo.json(); // 解析后的 JSON 对象
foo.stream(); // ReadableStream
await foo.arrayBuffer(); // ArrayBuffer
await foo.bytes(); // Uint8Array
这套设计的价值在于延迟:你可以把一个 BunFile 直接交给 Bun.serve 当作响应体、交给 Bun.spawn 当作子进程的 stdin、交给 Bun.write 当作写入源,全程不需要先把内容读进 JavaScript 堆内存。Bun 会在底层挑选最合适的系统调用(Linux 上的 sendfile、copy_file_range,macOS 上的 clonefile、fcopyfile 等)完成搬运。
除了路径字符串,Bun.file() 还接受文件描述符和 file:// URL:
Bun.file(1234); // 文件描述符
Bun.file(new URL(import.meta.url)); // 指向当前文件自身
BunFile 允许指向一个不存在的位置,这一点和 fs.readFile 直接抛错的行为不同。是否存在要用 exists() 主动询问:
const notreal = Bun.file("notreal.txt");
notreal.size; // 0
notreal.type; // "text/plain;charset=utf-8"
const exists = await notreal.exists(); // false
默认 MIME 类型是 text/plain;charset=utf-8,可以通过第二个参数覆盖:
const notreal = Bun.file("notreal.json", { type: "application/json" });
notreal.type; // => "application/json;charset=utf-8"
删除文件用 .delete():
await Bun.file("logs.json").delete();
Note
Bun.file()是惰性的,size属性来自一次轻量的元信息查询,不代表内容已经加载。真正触发读盘的是text()、bytes()、arrayBuffer()、json()这些兑现方法,以及对stream()的消费。
26.2 Bun.write():一个函数覆盖绝大多数写入场景
Bun.write(destination, data) 返回写入的字节数。它的强大之处在于两个参数都接受多种类型。
目标(destination) 可以是:路径字符串、file:// URL、BunFile 引用。
数据(data) 可以是:string、Blob(含 BunFile)、ArrayBuffer / SharedArrayBuffer、各类 TypedArray、以及 Response。
写字符串:
const data = `It was the best of times, it was the worst of times.`;
await Bun.write("output.txt", data);
复制文件——注意这里两个参数都是 BunFile,Bun 会走零拷贝的系统调用,而不是「读进内存再写出去」:
const input = Bun.file("input.txt");
const output = Bun.file("output.txt"); // 此时还不存在
await Bun.write(output, input);
写二进制数据:
const encoder = new TextEncoder();
const data = encoder.encode("datadatadata"); // Uint8Array
await Bun.write("output.txt", data);
把一个 HTTP 响应体直接落盘,这是下载文件最短的写法:
const response = await fetch("https://bun.com");
await Bun.write("index.html", response);
Tip
Bun.write不做「追加」。要追加内容,用node:fs的appendFile:import { appendFile } from "node:fs/promises"; await appendFile("message.txt", "data to append");
26.3 增量写入:FileSink
一次性写入适合小文件。如果要持续产出日志、边计算边落盘,就需要增量写入接口 FileSink。从 BunFile 上调用 .writer() 拿到它:
const file = Bun.file("output.txt");
const writer = file.writer();
writer.write("it was the best of times\n");
writer.write("it was the worst of times\n");
这些数据先进内部缓冲区。手动落盘用 .flush(),它返回本次刷出的字节数:
writer.flush(); // 把缓冲区写到磁盘
缓冲区达到「高水位线」时会自动刷新,这个阈值可以配置:
const file = Bun.file("output.txt");
const writer = file.writer({ highWaterMark: 1024 * 1024 }); // 1MB
写完后 .end() 会刷新缓冲区并关闭文件:
writer.end();
Warning默认情况下,只要
FileSink没有显式.end(),bun进程就不会退出。如果你不希望它牵制进程生命周期,调用writer.unref()解除引用,需要时再writer.ref()恢复。
26.4 标准流:Bun.stdin / Bun.stdout / Bun.stderr
Bun 把三个标准流也暴露成 BunFile 实例:
Bun.stdin; // 只读
Bun.stdout;
Bun.stderr;
这意味着前面所有 Bun.write 的技巧对标准输出同样成立。官方文档里那个著名的三行 cat 实现就是这么来的:
// 用法:bun ./cat.ts ./path-to-file
import { resolve } from "node:path";
const path = resolve(process.argv.at(-1)!);
await Bun.write(Bun.stdout, Bun.file(path));
因为 Bun.write(BunFile → 终端) 在 Linux 上直接映射到 sendfile,官方给出的数据是:处理大文件时比 GNU cat 快约 2 倍。
读取管道输入用 Bun.stdin.stream(),它按块产出 Uint8Array,块边界不保证是整行:
for await (const chunk of Bun.stdin.stream()) {
const chunkText = Buffer.from(chunk).toString();
console.log(`Chunk: ${chunkText}`);
}
echo "hello" | bun run stdin.ts
如果你要的是按行交互式读取,Bun 还把 console 对象本身做成了 AsyncIterable,逐行产出 stdin:
const prompt = "Type something: ";
process.stdout.write(prompt);
for await (const line of console) {
console.log(`You typed: ${line}`);
process.stdout.write(prompt);
}
26.5 目录操作:明确交给 node:fs
这是本章最需要记住的边界:Bun.file 和 Bun.write 只管文件本身,不管目录。创建目录、列出目录、重命名、追加、监听变更这些操作,官方推荐直接使用 Bun 实现的 node:fs 模块——它同样是原生实现,性能不打折。
递归读目录:
import { readdir } from "node:fs/promises";
// 读取当前目录下所有文件
const files = await readdir(import.meta.dir);
// 递归读取
const all = await readdir("../", { recursive: true });
递归建目录:
import { mkdir } from "node:fs/promises";
await mkdir("path/to/dir", { recursive: true });
监听文件变更用 node:fs 的 watch:
import { watch } from "node:fs";
const watcher = watch(import.meta.dir, (event, filename) => {
console.log(`${event}: ${filename}`);
});
process.on("SIGINT", () => {
watcher.close();
process.exit(0);
});
NoteBun 的目标是 100% 兼容 Node.js,
node:fs是其中完成度较高的模块之一,但「目标」不等于「已全部达成」。遇到冷门 API 时建议先跑一个最小片段验证,再决定是否依赖。v1.3.14 重写了 Linux 与 macOS 上的fs.watch()实现,若你之前遇到过监听丢事件的问题,升级到基线版本再试一次。
26.6 路径处理
Bun 没有另造一套路径库,node:path 就是标准答案:
import { resolve, join, dirname, extname } from "node:path";
resolve("./data", "users.json");
join(import.meta.dir, "fixtures", "sample.txt");
更常用的是 import.meta 上的几个属性,它们让「相对于当前源文件」的定位变得非常直接:
import.meta.dir; // 当前文件所在目录的绝对路径
import.meta.path; // 当前文件的绝对路径
import.meta.url; // 当前文件的 file:// URL
在 URL 与路径之间转换,Bun 提供了两个工具函数:
const path = Bun.fileURLToPath(new URL("file:///foo/bar.txt"));
// => "/foo/bar.txt"
const url = Bun.pathToFileURL("/foo/bar.txt");
// => "file:///foo/bar.txt"
如果你想复用 Bun 自己的模块解析算法(含 node_modules 查找、tsconfig paths),可以用 Bun.resolveSync:
Bun.resolveSync("./foo.ts", import.meta.dir);
Bun.resolveSync("zod", process.cwd());
Tip跨平台写路径时,永远用
node:path的join/resolve拼接,不要手写"a/" + "b"。Windows 下的分隔符差异由node:path统一处理,这也是 Bun 官方示例一致采用的写法。
26.7 用 Bun.Glob 批量匹配文件
批量处理文件时,Glob 是内置的、无需安装 glob 或 fast-glob 的方案:
import { Glob } from "bun";
const glob = new Glob("**/*.ts");
// 递归扫描当前目录及其子目录
for await (const file of glob.scan(".")) {
console.log(file); // => "index.ts"
}
也可以只做字符串匹配,不碰磁盘:
import { Glob } from "bun";
const glob = new Glob("*.ts");
glob.match("index.ts"); // => true
glob.match("index.js"); // => false
scan() 接受一个选项对象,常用的有 cwd(起始目录)、dot(是否匹配以 . 开头的条目,默认 false)、absolute(是否返回绝对路径,默认 false)、onlyFiles(是否只返回文件,默认 true)。支持的通配符包括 ?、*、**、[ab] 字符类、{a,b,c} 分支和开头的 ! 取反。此外 Bun 也实现了 Node.js 的 fs.glob() 系列函数,并额外支持传入模式数组与 exclude 排除项。
26.8 小结与常见误区
回顾一下这套 API 的分工:读文件用 Bun.file() 拿引用再兑现;写文件用 Bun.write(),源类型足够宽泛以至于大多数场景不需要中间变量;持续写入用 FileSink;标准流是三个特殊的 BunFile;目录、追加、监听交给 node:fs;路径交给 node:path 与 import.meta;批量匹配交给 Bun.Glob。
几个新手容易踩的点:
- 以为
Bun.file()会立刻读盘。它不会。文件不存在时也能创建引用,await file.text()时才会失败,所以关心存在性就用await file.exists()明确检查。 - 用
Bun.write追加内容。它是覆盖写,追加请用node:fs的appendFile。 - 忘记
writer.end()。FileSink未关闭会让进程挂着不退出,脚本里尤其明显。 - 为了「读文件再写文件」而先
await file.text()。直接await Bun.write(dest, Bun.file(src))更快,因为 Bun 会走零拷贝路径,内容根本不进 JavaScript 堆。 - 去找
Bun.mkdir/Bun.readdir。它们不存在,这是有意的设计取舍:Bun 只在能明显做得更快的地方提供原生 API,其余交给已经足够好的node:fs。
下一章我们把 Bun.file 用到实处——它会作为 Bun.serve 的静态文件响应体再次登场。