首页 / Bun 入门教程 / 文件与文件系统

Bun 入门教程

文件与文件系统

本教程共 34 篇 · 第 26 篇 · 更新于 2026-08-06

BunBun.fileBun.write文件 I/OFileSinknode:fsGlob

本节目标:

  • 掌握 Bun.file() 创建惰性文件引用,并以 text()json()bytes()stream() 等多种形式读取内容。
  • 掌握 Bun.write() 这把「写入瑞士军刀」:字符串、二进制、BlobResponse、另一个文件都能作为写入源。
  • 学会用 FileSink 做增量写入,用 Bun.stdin / Bun.stdout / Bun.stderr 打通标准流。
  • 明确 Bun 原生 API 的边界:目录相关操作(readdirmkdirappendFile)交给 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 上的 sendfilecopy_file_range,macOS 上的 clonefilefcopyfile 等)完成搬运。

除了路径字符串,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) 可以是:stringBlob(含 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:fsappendFile

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.fileBun.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:fswatch

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);
});
Note

Bun 的目标是 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:pathjoin / resolve 拼接,不要手写 "a/" + "b"。Windows 下的分隔符差异由 node:path 统一处理,这也是 Bun 官方示例一致采用的写法。

26.7 用 Bun.Glob 批量匹配文件

批量处理文件时,Glob 是内置的、无需安装 globfast-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:pathimport.meta;批量匹配交给 Bun.Glob

几个新手容易踩的点:

  • 以为 Bun.file() 会立刻读盘。它不会。文件不存在时也能创建引用,await file.text() 时才会失败,所以关心存在性就用 await file.exists() 明确检查。
  • Bun.write 追加内容。它是覆盖写,追加请用 node:fsappendFile
  • 忘记 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 的静态文件响应体再次登场。