Skip to content

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.buildplugins数组中:

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 本身是单线程的。

原生插件的优势

原生插件的优点

  1. 可在多线程上运行,比 JavaScript 插件快得多
  2. 可以跳过不必要的工作,如传递字符串给 JavaScript 时需要的 UTF-8 到 UTF-16 转换
  3. 可以直接访问底层系统资源和 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 的插件系统提供了强大的扩展能力,允许开发者自定义模块解析、加载和转换。通过插件,你可以:

  1. 添加对新文件类型的支持
  2. 自定义代码转换和处理流程
  3. 实现各种框架级功能
  4. 利用原生插件实现高性能处理

插件系统的设计遵循了清晰的生命周期模型,从onStartonResolveonLoadonBeforeParse,提供了全面的控制点。

无论是简单的文件类型支持,还是复杂的代码转换,Bun 的插件系统都提供了灵活而高效的解决方案。对于需要极致性能的场景,原生插件提供了多线程处理能力,充分利用了现代硬件资源。

创建插件时,要考虑性能影响。对于简单转换,JavaScript 插件通常足够;但对于重量级处理,考虑使用原生插件以获得更好的性能。