Appearance
Bun 插件系统
Bun 提供了一个通用的插件 API,可用于扩展运行时和打包器的功能。本文将从初学者角度全面介绍 Bun 插件系统,帮助你理解如何使用和创建插件来增强 Bun 的能力。
什么是 Bun 插件?
插件可以拦截导入请求并执行自定义加载逻辑:读取文件、转译代码等。它们可以用来添加对其他文件类型的支持,如 .scss
或 .yaml
。在 Bun 打包器的上下文中,插件可以实现框架级功能,如 CSS 提取、宏和客户端-服务器代码共存。
插件的主要用途
- 支持新的文件类型(SCSS, YAML, SVG 等)
- 自定义代码转换和处理
- 实现框架特性(CSS 提取、代码分割等)
- 扩展打包过程
插件生命周期钩子
插件可以注册回调函数,这些函数会在打包过程的不同阶段被调用:
生命周期钩子概览
钩子名称 | 触发时机 | 主要用途 |
---|---|---|
onStart() | 打包器开始打包时 | 初始化工作,日志记录 |
onResolve() | 模块解析之前 | 自定义模块路径解析 |
onLoad() | 模块加载之前 | 自定义模块内容加载和处理 |
onBeforeParse() | 文件解析之前 | 零拷贝原生插件,在解析前修改内容 |
插件类型定义参考
下面是 Bun 插件相关类型的基本概览(完整定义请参考 Bun 的bun.d.ts
):
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" | "css" | "json" | "toml";
使用插件
定义插件
插件定义为一个简单的 JavaScript 对象,包含name
属性和setup
函数。
ts
import type { BunPlugin } from "bun";
const myPlugin: BunPlugin = {
name: "Custom loader", // 插件名称
setup(build) {
// 在这里实现插件逻辑
},
};
在构建中使用插件
将定义好的插件传入Bun.build
的plugins
数组中:
ts
await Bun.build({
entrypoints: ["./app.ts"], // 入口点
outdir: "./out", // 输出目录
plugins: [myPlugin], // 插件列表
});
理解命名空间(Namespace)
在深入了解插件钩子之前,我们需要先理解"命名空间"这个概念。
每个模块都有一个命名空间。命名空间用于在转译代码中为导入添加前缀。
常见命名空间
命名空间 | 说明 | 示例 |
---|---|---|
"file" | 默认命名空间,表示文件系统中的模块 | import x from "./module.ts" = import x from "file:./module.ts" |
"bun" | Bun 特定模块 | import { test } from "bun:test" |
"node" | Node.js 模块 | import fs from "node:fs" |
TIP
自定义命名空间插件可以创建自己的命名空间。例如,YAML 加载器插件可能使用"yaml:"
命名空间,这样import data from "./config.yaml"
会被转换为import data from "yaml:./config.yaml"
。
详解插件钩子
onStart 钩子
onStart
钩子在打包器开始新的打包过程时运行。
ts
onStart(callback: () => void): Promise<void> | void;
基本示例:
ts
import { plugin } from "bun";
plugin({
name: "onStart example",
setup(build) {
build.onStart(() => {
console.log("打包过程开始了!");
});
},
});
onStart
回调可以返回一个Promise
。在打包过程初始化后,打包器会等待所有onStart()
回调完成后再继续。
异步示例:
ts
const result = await Bun.build({
entrypoints: ["./app.ts"],
outdir: "./dist",
sourcemap: "external",
plugins: [
{
name: "等待10秒",
setup(build) {
build.onStart(async () => {
// 等待10秒
await new Promise((resolve) => setTimeout(resolve, 10000));
console.log("10秒等待完成");
});
},
},
{
name: "记录打包时间",
setup(build) {
build.onStart(async () => {
const now = Date.now();
await Bun.write("bundle-time.txt", String(now));
console.log(`打包时间已记录: ${now}`);
});
},
},
],
});
> `onStart()`回调(与其他生命周期回调一样)无法修改`build.config`对象。如果要修改`build.config`,必须直接在`setup()`函数中进行。
onResolve 钩子
ts
onResolve(
args: { filter: RegExp; namespace?: string },
callback: (args: { path: string; importer: string }) => {
path: string;
namespace?: string;
} | void,
): void;
为了打包项目,Bun 需要遍历项目中所有模块的依赖树。对于每个导入的模块,Bun 必须找到并读取该模块。这个"查找"部分就是"解析"模块。
onResolve()
插件生命周期回调允许你配置模块的解析方式。
工作流程:
示例:重定向所有对images/
的导入到./public/images/
:
ts
import { plugin } from "bun";
plugin({
name: "图片路径重定向插件",
setup(build) {
build.onResolve({ filter: /.*/, namespace: "file" }, (args) => {
// 检查路径是否以images/开头
if (args.path.startsWith("images/")) {
return {
// 返回修改后的路径
path: args.path.replace("images/", "./public/images/"),
};
}
});
},
});
参数解释
- 第一个参数:包含
filter
(正则表达式)和可选的namespace
属性,用于筛选哪些模块导入会触发此回调 - 第二个参数:回调函数,接收导入路径信息,可以返回新的路径
onLoad 钩子
ts
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 读取和解析模块之前修改模块的内容。
工作流程:
示例:创建环境变量导出插件:
ts
import { plugin } from "bun";
const envPlugin = {
name: "环境变量插件",
setup(build) {
build.onLoad({ filter: /env/, namespace: "file" }, (args) => {
// 当导入名为env的模块时,返回包含环境变量的对象
return {
contents: `export default ${JSON.stringify(process.env)}`,
loader: "js", // 使用JS加载器处理这个内容
};
});
},
};
Bun.build({
entrypoints: ["./app.ts"],
outdir: "./dist",
plugins: [envPlugin],
});
// 使用方式:
// import env from "env"
// console.log(env.NODE_ENV) // "production"
defer 函数
onLoad
回调接收一个defer
函数作为参数。此函数返回一个Promise
,该Promise
在所有其他模块加载完成后才会解决。
这允许你延迟执行onLoad
回调,直到所有其他模块都已加载。这对于返回依赖于其他模块的模块内容非常有用。
使用场景示例:跟踪和报告未使用的导出
ts
import { plugin } from "bun";
plugin({
name: "导入跟踪插件",
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; // 不修改内容,继续正常加载
});
// 当导入stats.json时,返回导入统计信息
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 本身是单线程的。
原生插件的优势
原生插件的优点
- 可在多线程上运行,比 JavaScript 插件快得多
- 可以跳过不必要的工作,如传递字符串给 JavaScript 时需要的 UTF-8 到 UTF-16 转换
- 可以直接访问底层系统资源和 API
可用的原生插件生命周期钩子
原生插件可以使用以下生命周期钩子:
onBeforeParse()
:在 Bun 的打包器解析文件之前,在任何线程上调用。
onBeforeParse 钩子
ts
onBeforeParse(
args: { filter: RegExp; namespace?: string },
callback: { napiModule: NapiModule; symbol: string; external?: unknown },
): void;
此生命周期回调在 Bun 的打包器解析文件之前立即运行。
作为输入,它接收文件的内容,并可以选择返回新的源代码。
此回调可以从任何线程调用,因此 napi 模块实现必须是线程安全的。
创建原生插件
原生插件是暴露生命周期钩子作为 C ABI 函数的 NAPI 模块。
要创建原生插件,你必须导出一个 C ABI 函数,该函数与你想实现的原生生命周期钩子的签名匹配。
使用 Rust 创建原生插件
1. 安装必要工具
bash
bun add -g @napi-rs/cli
napi new
2. 添加依赖
bash
cargo add bun-native-plugin
3. 实现原生插件
在lib.rs
文件中,我们将使用bun_native_plugin::bun
过程宏来定义一个实现原生插件的函数:
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(())
}
4. 在 Bun.build()中使用原生插件
typescript
import myNativeAddon from "./my-native-addon";
Bun.build({
entrypoints: ["./app.tsx"],
plugins: [
{
name: "my-plugin",
setup(build) {
build.onBeforeParse(
{
namespace: "file",
filter: "**/*.tsx", // 只处理.tsx文件
},
{
napiModule: myNativeAddon, // 原生模块
symbol: "replace_foo_with_bar", // 要调用的函数符号
// external: myNativeAddon.getSharedState() // 可选的外部状态
},
);
},
},
],
});
实际应用示例
示例 1:创建 YAML 加载器插件
ts
import { plugin } from "bun";
import yaml from "js-yaml";
// 创建一个YAML加载器插件
const yamlPlugin = {
name: "yaml-loader",
setup(build) {
// 监听.yaml和.yml文件的加载
build.onLoad({ filter: /\.(yaml|yml)$/ }, async ({ path }) => {
// 读取YAML文件内容
const text = await Bun.file(path).text();
// 将YAML解析为JavaScript对象
const data = yaml.load(text);
// 返回JavaScript模块内容
return {
contents: `export default ${JSON.stringify(data)}`,
loader: "js",
};
});
},
};
// 使用插件
await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
plugins: [yamlPlugin],
});
示例 2:CSS 模块插件
ts
import { plugin } from "bun";
import * as path from "path";
// CSS模块插件
const cssModulesPlugin = {
name: "css-modules",
setup(build) {
// 处理.module.css文件
build.onLoad({ filter: /\.module\.css$/ }, async ({ path: filePath }) => {
const css = await Bun.file(filePath).text();
// 简化版:这里应该有CSS模块转换逻辑
// 实际实现会解析CSS并创建唯一类名
const className = path.basename(filePath, ".module.css");
const uniqueClassName = `${className}_${Math.random().toString(36).slice(2, 8)}`;
// 创建JS导出和转换后的CSS
const jsContent = `
export const ${className} = "${uniqueClassName}";
// 添加样式到文档
const style = document.createElement("style");
style.textContent = \`.${uniqueClassName} { ${css} }\`;
document.head.appendChild(style);
`;
return {
contents: jsContent,
loader: "js",
};
});
},
};
总结
Bun 的插件系统提供了强大的扩展能力,允许开发者自定义模块解析、加载和转换。通过插件,你可以:
- 添加对新文件类型的支持
- 自定义代码转换和处理流程
- 实现各种框架级功能
- 利用原生插件实现高性能处理
插件系统的设计遵循了清晰的生命周期模型,从onStart
到onResolve
、onLoad
和onBeforeParse
,提供了全面的控制点。
无论是简单的文件类型支持,还是复杂的代码转换,Bun 的插件系统都提供了灵活而高效的解决方案。对于需要极致性能的场景,原生插件提供了多线程处理能力,充分利用了现代硬件资源。
创建插件时,要考虑性能影响。对于简单转换,JavaScript 插件通常足够;但对于重量级处理,考虑使用原生插件以获得更好的性能。