插件系统
本教程共 34 篇 · 第 18 篇 · 更新于 2026-08-06
本节目标:
- 理解 Bun 打包器插件的整体结构与生命周期
- 掌握
onResolve/onLoad两个核心回调的用处与配合- 理解 namespace 的概念与
defer()的用法- 通过两三个实战插件,学会自定义文件处理与重定向
内置 loader 覆盖了大部分资源处理需求,但总有够不着的地方。把 Markdown 编译成 HTML、把一组图片按规则重定向到优化版本,这类”任意自定义转换”就得靠插件系统。
Bun 的插件 API 相当简洁:一个插件就是一个对象,在 setup 里注册若干回调,告诉打包器遇到某种情况该怎么处理。
这套 API 是通用的,同一个插件既能用于打包器,也能注册进运行时。
18.1 插件的基本结构
一个最简单的插件是这样定义的:
const myPlugin = {
name: "my-plugin",
setup(build) {
// 在这里用 build.onResolve / build.onLoad 注册规则
},
};
await Bun.build({
entrypoints: ["./index.ts"],
outdir: "./dist",
plugins: [myPlugin],
});
插件对象必须有 name(用于错误提示与日志)和 setup(build) 函数。setup 接收一个 build 参数,你通过它的方法挂接逻辑。把插件放进 Bun.build() 的 plugins 数组即可生效。
Note同一套插件 API 既服务打包器,也服务运行时。放进
Bun.build()的plugins数组,就作用于这次构建;用Bun.plugin(myPlugin)注册、再把注册文件写进bunfig.toml的preload,就能让import在运行时直接生效。本章聚焦打包器一侧。
18.2 生命周期概览
打包器在构建过程中会依次触发若干生命周期钩子,插件可以在不同钩子上”插入”自己的行为:
onStart:构建刚开始、尚未解析任何模块时触发,适合做前置准备;onResolve:每当打包器需要把一条import路径解析成具体模块时触发,你可以在这里拦截并重写路径、指定 namespace;onLoad:当一个模块被解析出来、需要读取其内容并转换成可打包形式时触发,你可以在这里自定义转换(编译、替换内容等);onBeforeParse:更底层的钩子(原生插件常用),在源码被解析前介入;onEnd:整轮构建结束后触发,适合做收尾工作,例如把产物上传到对象存储、生成统计文件。
对绝大多数”自定义文件处理”需求来说,真正高频使用的是 onResolve 和 onLoad 这一对组合拳。
18.3 onResolve:决定”模块在哪里、是什么”
onResolve 的任务是:当遇到一条导入语句时,返回这个模块最终指向哪、属于哪个 namespace。它接收 filter(一个正则,决定哪些路径归你管)和回调。
const envPlugin = {
name: "env-plugin",
setup(build) {
// 拦截 import "env" 这种裸导入
build.onResolve({ filter: /^env$/ }, () => {
return { path: "env", namespace: "env-ns" };
});
},
};
回调里返回的 path 是逻辑路径(不一定真有这个文件),namespace 则把模块归到一个命名空间,方便交给特定 onLoad 处理。默认的 namespace 是 file(对应磁盘文件);bun、node 则是运行时内置的命名空间。通过自定义 namespace,你可以让”虚拟模块”完全脱离文件系统而存在。
18.4 onLoad:决定”模块内容是什么”
onLoad 的任务是:给定一个(带 namespace 的)模块,返回它的内容和处理 loader。它同样用 filter 选择模块,并依赖 onResolve 指定的 namespace 来精确匹配。
回调收到的 args 里有 path(模块路径)、importer(谁引入了它)、namespace、kind(导入种类),还有下文要讲的 defer。
const envPlugin = {
name: "env-plugin",
setup(build) {
build.onResolve({ filter: /^env$/ }, () => {
return { path: "env", namespace: "env-ns" };
});
build.onLoad({ filter: /.*/, namespace: "env-ns" }, () => {
// 把 import "env" 变成一段导出了环境变量的 JS
return {
contents: `export default ${JSON.stringify(process.env)}`,
loader: "js",
};
});
},
};
这样,代码里写 import env from "env" 就能拿到当前进程的环境变量对象。关键点在于:onResolve 把 "env" 指向 env-ns 命名空间,onLoad 只监听 env-ns 命名空间,于是二者精确对接,避免误伤普通文件。
Tip一个稳健的插件套路是:
onResolve负责”认领”特定路径并打上 namespace 标记,onLoad只在该 namespace 下工作。职责分离后,插件不易与其他规则冲突。
18.5 实战一:把 Markdown 编译成 HTML 字符串
假设我们想在代码里 import html from "./doc.md" 直接拿到 HTML 字符串。Bun 的打包器没有内置 Markdown loader,用插件补上就行。
这个场景不需要 onResolve:.md 是磁盘上真实存在的文件,默认解析逻辑已经能找到它,我们只要接管”读出来之后怎么转”这一步。
import { marked } from "marked"; // 实际使用时需先 bun add marked
const markdownPlugin = {
name: "markdown",
setup(build) {
build.onLoad({ filter: /\.md$/ }, async (args) => {
// args.path 是已解析好的绝对路径
const text = await Bun.file(args.path).text();
const html = marked(text);
return {
contents: `export default ${JSON.stringify(html)}`,
loader: "js",
};
});
},
};
随后 import intro from "./intro.md" 得到的就是一个 HTML 字符串,可直接塞进 innerHTML。这就是插件补足内置 loader 盲区的典型用法。
18.6 实战二:重定向图片到优化版本
有时你想把 import "img/logo.png" 重定向到另一张优化过的图(例如不同分辨率)。onResolve 可以在解析阶段直接改写路径:
const imageRedirectPlugin = {
name: "image-redirect",
setup(build) {
build.onResolve({ filter: /^\.\/img\// }, (args) => {
// 把 ./img/xxx 重定向到 ./img/optimized/xxx
return { path: args.path.replace("./img/", "./img/optimized/") };
});
},
};
由于只是改写路径、不改变内容,这里不需要 onLoad——打包器会用默认 loader 去处理重定向后的真实文件。
18.7 defer:等所有其他模块都加载完
onLoad 的回调参数里带着一个 defer 函数,它常被误解成”延迟执行”。实际语义要具体得多:defer() 返回一个 Promise,等到其余所有模块都加载完毕后才 resolve。
用途也因此很明确——当某个模块的内容依赖”全局汇总信息”时,先 await defer() 等大家都过一遍,再生成内容。典型场景是构建产物清单、依赖统计、汇总报告这类文件。
const trackedImports: Record<string, number> = {};
const statsPlugin = {
name: "track imports",
setup(build) {
const transpiler = new Bun.Transpiler();
// 每个 .ts 模块经过这里时,记录它的依赖
build.onLoad({ filter: /\.ts$/ }, async ({ path }) => {
const contents = await Bun.file(path).arrayBuffer();
for (const i of transpiler.scanImports(contents)) {
trackedImports[i.path] = (trackedImports[i.path] || 0) + 1;
}
return undefined; // 不改内容,交回默认处理
});
build.onLoad({ filter: /stats\.json$/ }, async ({ defer }) => {
// 等所有模块都过完上面那个回调,统计才算完整
await defer();
return {
contents: `export default ${JSON.stringify(trackedImports)}`,
loader: "json",
};
});
},
};
注意上面 onLoad 返回 undefined 的用法:表示”我只是路过看一眼,内容交回打包器按默认方式处理”。
Warning
defer()在每个onLoad回调里最多只能调用一次。另外它会阻塞当前模块的加载直到全图就绪,所以别在普通转换插件里顺手加一个——那只会拖慢构建。
18.8 onEnd:构建收尾与产物后处理
当所有模块解析、加载、打包完毕后,onEnd 会被调用。你可以在这里读取 build 收集到的产物信息,做上传、统计、生成 sourcemap 汇总等:
const uploadPlugin = {
name: "upload-to-s3",
setup(build) {
build.onEnd(async (result) => {
if (!result.success) return;
for (const artifact of result.outputs) {
// artifact 实现了 Blob 接口,按需取字节即可
const bytes = await artifact.arrayBuffer();
// 伪代码:把每个产物上传到对象存储
// await s3.put(artifact.path, bytes);
}
});
},
};
result.outputs 是本次构建产出的 BuildArtifact 数组。每个 artifact 都实现了 Blob 接口,除了 path,还带 kind(entry-point / chunk / asset / sourcemap / bytecode)和 hash,做后处理时可以按类型分流。
Note
onEnd接收的result就是BuildOutput,与Bun.build()的返回值同构,包含success、outputs、logs。先用result.success挡一道,能避免对失败构建做无意义的后处理。
18.9 原生插件(进阶提示)
Bun 的打包器本身是原生代码,会用多线程并行加载和解析模块。而 JS 插件只能跑在单线程上,因为 JavaScript 就是单线程的。
原生插件正是为此存在:它们是暴露 C ABI 生命周期函数的 NAPI 模块(官方示例用 Rust 编写),可以多线程运行,还省掉了把字符串在 UTF-8 与 UTF-16 之间来回转换的开销,通过 onBeforeParse 在解析前介入。
代价是需要一套额外的编译工具链。绝大多数场景 JS 插件够用,真撞上构建性能瓶颈、且转换逻辑能独立成原生模块时,再考虑这条路。
18.10 小结
本章我们建立了插件系统的完整认知:
- 插件是一个带
name和setup(build)的对象,通过plugins数组接入构建; - 生命周期包含
onStart/onResolve/onLoad/onBeforeParse/onEnd,最常用的是onResolve与onLoad这一对组合; onResolve负责”认领并定位模块、打 namespace”,onLoad负责”提供内容与 loader”,二者配合实现自定义转换;- 处理磁盘上真实存在的文件时,往往只需要
onLoad;onResolve主要用于虚拟模块与路径重定向; defer()等所有其他模块加载完再 resolve,用于汇总类产物,每回调仅可调用一次;onEnd拿到的outputs是BuildArtifact数组,适合做上传、统计等收尾工作;- 原生插件是多线程的 NAPI 模块,面向构建性能瓶颈。
插件让 Bun 打包器从”能处理常见文件”升级为”能处理任何文件”。下一章我们看看如何在打包的最后阶段做压缩、做独立可执行文件,把产物打磨得既小又好部署。