Bun 提供了一个通用插件 API,可用于扩展运行时和打包器。
插件拦截导入并执行自定义加载逻辑:读取文件、转译代码等。它们可以用于增加对额外文件类型的支持,比如 .scss 或 .yaml。在 Bun 打包器的上下文中,插件可以用于实现框架级功能,如 CSS 提取、宏和客户端-服务器代码共置。
生命周期钩子
插件可以注册回调,在打包生命周期的各个阶段执行:
onStart():打包器开始构建时运行一次
onResolve():模块解析之前运行
onLoad():模块加载之前运行
onBeforeParse():在文件被解析之前,在解析线程中运行零拷贝的本地插件
类型的粗略概览(完整类型定义请参阅 Bun 的 bun.d.ts):

bun.d.ts
type PluginBuilder = {
onStart(callback: () => void): void;
onResolve: (
args: { filter: RegExp; namespace?: string },
callback: (args: { path: string; importer: string }) => {
path: string;
namespace?: string;
} | void,
) => void;
onLoad: (
args: { filter: RegExp; namespace?: string },
defer: () => Promise<void>,
callback: (args: { path: string }) => {
loader?: Loader;
contents?: string;
exports?: Record<string, any>;
},
) => void;
config: BuildConfig;
};
type Loader =
| "js"
| "jsx"
| "ts"
| "tsx"
| "json"
| "jsonc"
| "toml"
| "yaml"
| "file"
| "napi"
| "wasm"
| "text"
| "css"
| "html";
插件定义为一个包含 name 属性和 setup 函数的简单 JavaScript 对象。

myPlugin.ts
import type { BunPlugin } from "bun";
const myPlugin: BunPlugin = {
name: "Custom loader",
setup(build) {
// 实现逻辑
},
};
该插件可以作为 plugins 数组传递给 Bun.build。

index.ts
await Bun.build({
entrypoints: ["./app.ts"],
outdir: "./out",
plugins: [myPlugin],
});
插件生命周期
命名空间
onLoad 和 onResolve 接收一个可选的 namespace 字符串。什么是命名空间?
每个模块都有一个命名空间。命名空间用于在转译代码中为导入路径添加前缀;例如,一个拥有 filter: /\.yaml$/ 和 namespace: "yaml:" 的加载器会将导入 ./myfile.yaml 转为 yaml:./myfile.yaml。
默认命名空间是 "file",通常无需显式指定,比如:import myModule from "./my-module.ts" 等同于 import myModule from "file:./my-module.ts"。
其他常见命名空间:
"bun":Bun 专属模块(如 "bun:test"、"bun:sqlite")
"node":Node.js 模块(如 "node:fs"、"node:path")
onStart
onStart(callback: () => void): Promise<void> | void;
注册一个回调,在打包器开始新一轮构建时执行。

index.ts
import { plugin } from "bun";
plugin({
name: "onStart example",
setup(build) {
build.onStart(() => {
console.log("Bundle started!");
});
},
});
回调可以返回 Promise。打包流程初始化后,打包器会等待所有 onStart() 回调完成才继续。
例如:

index.ts
const result = await Bun.build({
entrypoints: ["./app.ts"],
outdir: "./dist",
sourcemap: "external",
plugins: [
{
name: "Sleep for 10 seconds",
setup(build) {
build.onStart(async () => {
await Bun.sleep(10_000);
});
},
},
{
name: "Log bundle time to a file",
setup(build) {
build.onStart(async () => {
const now = Date.now();
await Bun.$`echo ${now} > bundle-time.txt`;
});
},
},
],
});
上述示例中,Bun 会等待第一个 onStart()(睡眠 10 秒)和第二个 onStart()(将打包时间写入文件)都完成后才继续。
onStart() 回调(以及所有生命周期回调)不能修改 build.config 对象。如果要修改 build.config,必须直接在 setup() 函数中完成。
onResolve
onResolve(
args: { filter: RegExp; namespace?: string },
callback: (args: { path: string; importer: string }) => {
path: string;
namespace?: string;
} | void,
): void;
为打包项目,Bun 会遍历所有模块的依赖树。对于每个导入的模块,Bun 需要找到并读取该模块。这种“找到”模块的过程称为“解析”。
onResolve() 插件生命周期回调允许你自定义模块解析行为。
onResolve() 的第一个参数是带有 filter 和可选 namespace 属性的对象。filter 是用于匹配导入字符串的正则表达式,实际用途是过滤哪些模块使用自定义解析逻辑。
第二个参数是回调函数,对每个匹配第一个参数中 filter 和 namespace 的导入模块都会调用。
回调接收匹配模块路径作为输入,可以返回该模块的新路径。Bun 将读取新路径内容并将其解析为模块。
例如,将所有导入 images/ 的模块重定向到 ./public/images/:

index.ts
import { plugin } from "bun";
plugin({
name: "onResolve example",
setup(build) {
build.onResolve({ filter: /.*/, namespace: "file" }, args => {
if (args.path.startsWith("images/")) {
return {
path: args.path.replace("images/", "./public/images/"),
};
}
});
},
});
onLoad
onLoad(
args: { filter: RegExp; namespace?: string },
defer: () => Promise<void>,
callback: (args: { path: string, importer: string, namespace: string, kind: ImportKind }) => {
loader?: Loader;
contents?: string;
exports?: Record<string, any>;
},
): void;
在 Bun 打包器完成模块解析后,需要读取模块内容并进行解析。
onLoad() 插件生命周期回调允许你在 Bun 读取解析模块内容之前修改该内容。
与 onResolve() 类似,onLoad() 的第一个参数用于筛选该次调用适用的模块。
第二个参数是回调,对每个匹配模块加载内容前调用。
回调接收匹配模块的路径,模块的导入者,模块命名空间以及模块类型。
回调可以返回该模块新的 contents 字符串和新的 loader。
例如:

index.ts
import { plugin } from "bun";
const envPlugin: BunPlugin = {
name: "env plugin",
setup(build) {
build.onLoad({ filter: /env/, namespace: "file" }, args => {
return {
contents: `export default ${JSON.stringify(process.env)}`,
loader: "js",
};
});
},
});
Bun.build({
entrypoints: ["./app.ts"],
outdir: "./dist",
plugins: [envPlugin],
});
// import env from "env"
// env.FOO === "bar"
该插件会将所有形式为 import env from "env" 的导入转换成导出当前环境变量的 JavaScript 模块。
.defer()
传给 onLoad 回调的参数之一是 defer 函数。该函数返回一个 Promise,只有等所有其他模块都加载完成时该 Promise 才会 resolve。
这允许推迟 onLoad 回调的执行,直到所有其他模块加载完成。
在需要返回依赖其他模块内容的模块内容时非常有用。

index.ts
import { plugin } from "bun";
plugin({
name: "track imports",
setup(build) {
const transpiler = new Bun.Transpiler();
let trackedImports: Record<string, number> = {};
// 每个经过这个 onLoad 回调的模块
// 都会将其导入记录到 trackedImports 中
build.onLoad({ filter: /\.ts/ }, async ({ path }) => {
const contents = await Bun.file(path).arrayBuffer();
const imports = transpiler.scanImports(contents);
for (const i of imports) {
trackedImports[i.path] = (trackedImports[i.path] || 0) + 1;
}
return undefined;
});
build.onLoad({ filter: /stats\.json/ }, async ({ defer }) => {
// 等待所有文件加载完成,确保
// 上面的 onLoad 回调对每个文件都执行且导入被跟踪
await defer();
// 输出包含每个导入统计信息的 JSON
return {
contents: `export default ${JSON.stringify(trackedImports)}`,
loader: "json",
};
});
},
});
.defer() 函数当前限制为每个 onLoad 回调只能调用一次。
原生插件
Bun 打包器速度极快的一个原因是其采用原生代码实现,并通过多线程并行加载和解析模块。
然而,用 JavaScript 编写的插件有一个限制是 JavaScript 本身是单线程的。
原生插件是以 NAPI 模块形式编写的,可以在多个线程上运行。这样原生插件的运行速度远超 JavaScript 插件。
此外,原生插件还可以跳过一些不必要的工作,比如将 UTF-8 转换为 UTF-16(传递到 JavaScript 字符串时需要进行的转换)。
以下生命周期钩子可用于原生插件:
onBeforeParse():在任何线程上,文件被 Bun 打包器解析前调用。
原生插件是暴露生命周期钩子作为 C ABI 函数的 NAPI 模块。
要创建原生插件,必须导出一个符合所实现生命周期钩子签名的 C ABI 函数。
用 Rust 创建原生插件
原生插件是暴露生命周期钩子作为 C ABI 函数的 NAPI 模块。
要创建原生插件,必须导出一个符合所实现生命周期钩子签名的 C ABI 函数。
bun add -g @napi-rs/cli
napi new
然后安装该 crate:
cargo add bun-native-plugin
接下来,在 lib.rs 文件中,我们使用 bun_native_plugin::bun 过程宏定义一个函数来实现我们的原生插件。
下面是一个实现 onBeforeParse 钩子的示例:

lib.rs
use bun_native_plugin::{define_bun_plugin, OnBeforeParse, bun, Result, anyhow, BunLoader};
use napi_derive::napi;
/// 定义插件及其名称
define_bun_plugin!("replace-foo-with-bar");
/// 实现 `onBeforeParse`,将所有出现的
/// `foo` 替换为 `bar`。
///
/// 我们用 #[bun] 宏生成部分样板代码。
///
/// 函数参数(`handle: &mut OnBeforeParse`)告诉宏该函数实现了 `onBeforeParse` 钩子。
#[bun]
pub fn replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
// 获取输入源代码
let input_source_code = handle.input_source_code()?;
// 获取该文件的 Loader 类型
let loader = handle.output_loader();
let output_source_code = input_source_code.replace("foo", "bar");
handle.set_output_source_code(output_source_code, BunLoader::BUN_LOADER_JSX);
Ok(())
}
在 Bun.build() 中使用该插件示例:

index.ts
import myNativeAddon from "./my-native-addon";
Bun.build({
entrypoints: ["./app.tsx"],
plugins: [
{
name: "my-plugin",
setup(build) {
build.onBeforeParse(
{
namespace: "file",
filter: "**/*.tsx",
},
{
napiModule: myNativeAddon,
symbol: "replace_foo_with_bar",
// external: myNativeAddon.getSharedState()
},
);
},
},
],
});
onBeforeParse
onBeforeParse(
args: { filter: RegExp; namespace?: string },
callback: { napiModule: NapiModule; symbol: string; external?: unknown },
): void;
该生命周期钩子在 Bun 打包器解析文件之前立即运行。
作为输入,它接收文件内容,并可选择返回新的源码。
此回调可以在任意线程调用,因此 napi 模块实现必须是线程安全的。