Farm JS 插件 API 全指南:从 Rollup 风格钩子到可复用的编译期插件开发

原创2026-10-13 18:51:151,440 阅读
文章标签:前端构建构建工具开发工具插件系统前端WebAssembly

Farm JS 插件 API 全指南:从 Rollup 风格钩子到可复用的编译期插件开发

Farm(一个用 Rust 编写的高性能 Vite 兼容构建工具)为 JavaScript 插件提供了一套完整的 Rollup 风格插件系统,让开发者可以像编写 Rollup/Vite/Webpack 插件一样轻松扩展 Farm 的编译流程。本文基于 js-plugin-api.md 官方文档,系统讲解 Farm JS 插件的配置方式、编写范式、全部 20+ 个钩子的类型签名、执行时机与过滤条件,并结合 packages/core 源码与 测试用例 印证底层实现,帮助你从零写出可发布、可缓存、可参与 HMR 的生产级 JS 插件。

插件系统设计理念

Farm Js Plugin 采用了与 Rollup 高度相似的设计风格,这意味着如果你已经熟悉 Rollup/Vite/Webpack 的插件体系,几乎可以零成本地把现有插件或项目迁移到 Farm。从设计上看:

  • 一个 JS 插件就是一个普通的 JavaScript/TypeScript 对象,通过暴露一组名为 hook(钩子)的属性来参与构建流程;
  • 钩子的执行时机与 Rust 插件完全对齐(详见 Rust Plugin Hook Overview),但并非所有 Rust 插件钩子都暴露给 JS 插件,只有本文档列出的钩子才可用;
  • 钩子分为三种类型(与 Rollup 相同),详细说明见 rust-plugin-api.md:
    • first:按顺序串行执行,一旦某个钩子返回非空值(JS 中 null/undefined 视为空),立即返回该结果,后续钩子不再执行;
    • serial:串行执行,每个钩子的返回结果会传给下一个钩子,最后一个钩子的结果作为最终结果;
    • parallel:在线程池中并行执行,钩子之间应相互隔离。

在 packages/core/src/plugin/type.ts 中,JsPlugin 接口完整定义了每个钩子的签名与参数类型,是阅读本文时的最佳参考源码。

配置 JS 插件

在 farm.config.ts 中通过 plugins 选项添加 JS 插件:

import { defineConfig } from "@farmfe/core";
// import a js plugin
import farmPluginFoo from "farm-plugin-foo";

export default defineConfig({
  // configuring it in plugins
  plugins: [farmPluginFoo()],
});

plugins 数组中的每一项可以是一个返回 JsPlugin 对象的插件函数调用(如上例),也可以直接是一个插件对象字面量。从 resolveFarmPlugins 实现 可以看到,Farm 会遍历 config.plugins:

  • 字符串或 [string, options] 形式的项会被解析为 Rust 插件(通过 rustPluginResolver);
  • 对象形式的项会被 convertPlugin 转换为 JS 插件;
  • 嵌套数组会被扁平化处理;
  • 其他类型会抛出 "plugin is not supported" 错误。

编写 JS 插件

Farm Js Plugin 是一个暴露了若干钩子的纯 JavaScript 对象。典型的插件文件结构如下:

// Create a plugin file that exports a plugin function which returns a `JsPlugin` Object:
import type { JsPlugin } from '@farmfe/core';

// Plugin Options
export interface PluginOptions {
  test: boolean;
}
// export a Plugin Function
export default function MyPlugin(options: PluginOptions): JsPlugin {
  // reading options
  const { test } = options;

  // return a object that exposes hook
  return {
    name: 'my-farm-plugin',
    // using load hook to load custom modules
    load: {
      filters: {
        resolvedPaths: ['\\.test$'] // filter files to improve performance
      },
      async executor({ resolvedPath }) {
        if (test && resolvedPath.endsWith('.test')) {
          return {
            content: 'test file',
            sourceMap: null
          }
        }
      }
    }
  }
}

编写范式可归纳为三点:

  1. 导出插件工厂函数:使用闭包读取插件选项(如 options),返回 JsPlugin 对象;
  2. 声明 name:插件必须有一个唯一名称;
  3. 用 filters + executor 定义钩子:filters 是正则数组,用于精准匹配模块从而提升性能;executor 是实际执行逻辑。

:::note Farm 提供了 create-farm-plugin 工具帮助你快速创建和开发 JS 插件,编写插件的更多细节可参考 Writing JS Plugins。官方约定 Farm 专属插件应以 farm-plugin- 前缀命名,并在 package.json 中带上 farm-plugin 关键字。 :::

钩子详解

name

  • 类型:string
  • 必填:true

插件名称,不能为空,用于插件间的标识与去重(resolveConfigHook 中会按 name 去重 Vite 插件)。

export default function MyPlugin() {
  return {
    name: 'my-plugin',
    // ...
  }
}

priority

  • 类型:number
  • 必填:false
  • 默认值:100

控制插件的执行顺序,数值越大越先执行。getSortedPlugins(见 plugin/index.ts)会按 priority 降序排序,且保证优先级大于 100 的插件(pre)、默认插件(normal)和小于 100 的插件(post)按阶段执行。

export default function MyPlugin() {
  return {
    name: 'my-plugin',
    priority: 1000, // make this plugins execute before all other plugins
    // ...
  }
}

:::note Farm 内部插件(如 plugin-script、plugin-resolve)的优先级为 99,所以你的插件默认总会先于内部插件执行。若想让插件在内部插件之后执行,将 priority 设为小于 99 的值即可,例如 98。优先级也支持负数,设为 -9999 可以保证插件永远最后执行。 :::

config

  • 类型:config?: (config: UserConfig) => UserConfig | Promise<UserConfig>;
  • 钩子类型:serial
  • 必填:false

在 config 钩子中修改 Farm 配置。返回(部分)修改后的配置,返回值会深度合并进从 CLI 与配置文件解析出的最终配置;也可以直接原地修改 config 对象。

const resolveConfigPlugin = () => ({
  name: 'return-resolve-config-plugin',
  config: (_config) => ({
    compilation: {
      resolve: {
        alias: {
          foo: 'bar'
        }
      }
    }
  })
});

从源码看,resolveConfigHook 会依次调用每个插件的 config 钩子,并用 merge(conf, res) 深度合并结果(plugin/index.ts)。

:::note config 钩子在所有用户插件解析完成之后才被调用,因此在 config 钩子中向配置新增插件不会生效。 :::

configResolved

  • 类型:configResolved?: (config: ResolvedUserConfig) => void | Promise<void>;
  • 钩子类型:serial
  • 必填:false

在所有插件的 config 钩子被调用完毕后触发,此时配置已解析完成。适用于在插件内部保存最终配置供后续钩子使用。

const myPlugin = () => {
  let farmConfig;

  return {
    name: 'my-plugin',
    configResolved(resolvedConfig) {
      // get resolved config
      resolvedConfig = farmConfig;
    },
    transform: {
      filters: {
        moduleTypes: ['js']
      },
      async executor(param) {
        if (farmConfig.xxx) {
          // ...
        }
      }
    }
  }
}

:::note JS 插件的 config 与 configResolved 钩子都会在 Rust 插件的 config 钩子之前被调用(Rust 侧文档同样注明此顺序,见 rust-plugin-api.md)。 :::

configureDevServer

  • 类型:configureDevServer?: (server: Server) => void | Promise<void>;
  • 钩子类型:serial
  • 必填:false

:::note 该钩子仅在开发模式下运行。 :::

当 Dev Server 就绪后被调用,可以获取 dev server 实例。注意 JsPlugin 接口中对应字段名为 configureServer(见 type.ts)。

const myPlugin = () => {
  let devServer;

  return {
    name: 'my-plugin',
    configureDevServer(server) {
      devServer = server;
    }
  }
}

configureCompiler

  • 类型:configureCompiler?: (compiler: Compiler) => void | Promise<void>;
  • 钩子类型:serial
  • 必填:false

当 Rust Compiler 就绪后被调用,开发与生产模式都会执行,可用于获取 Compiler 实例。

const myPlugin = () => {
  let farmCompiler;

  return {
    name: 'my-plugin',
    configureCompiler(compiler) {
      farmCompiler = compiler;
    }
  }
}

buildStart

  • 类型:buildStart?: { executor: Callback<Record<string, never>, void> };
  • 钩子类型:parallel
  • 必填:false

在编译开始前被调用,适合做一些初始化工作。

const myPlugin = () => {
  // your plugin operations
  let myPluginContext = createMyPluginContext();

  return {
    name: 'my-plugin',
    buildStart: {
      async executor() {
        // set up my plugin before compilation.
        myPluginContext.setup();
      }
    }
  }
}

:::note buildStart 只会在首次编译时调用一次。后续的 Lazy Compilation 与 HMR Update 编译不会触发它。 :::

resolve

  • 必填:false
  • 钩子类型:first
  • 类型:
type ResolveHook = {
  filters: {
    importers: string[];
    sources: string[];
  };
  executor: Callback<PluginResolveHookParam, PluginResolveHookResult>
};

type Callback<P, R> = (
  param: P,
  context?: CompilationContext,
  hookContext?: { caller?: string; meta: Record<string, unknown> }
) => Promise<R | null | undefined>;

/// Parameter of the resolve hook
export interface PluginResolveHookParam {
  /// the start location to resolve `source`, being [None] if resolving a entry or resolving a hmr update.
  /// it's id of the parent module, for example: `src/index.ts` or `src/index.vue?vue&type=xxx`
  importer: string | null;
  /// for example, [ResolveKind::Import] for static import (`import a from './a'`)
  kind: ResolveKind;
  /// source of the import. for example in index.ts (import App from "./App.vue")
  /// source should be './App.vue'
  source: string;
}
/// Resolve result of the resolve hook
export interface PluginResolveHookResult {
  /// resolved path, normally a absolute path. you can also return a virtual path, and use [PluginLoadHookResult] to provide the content of the virtual path
  resolvedPath: string;
  /// whether this module should be external, if true, the module won't present in the final result
  external: boolean;
  /// whether this module has side effects, affects tree shaking
  sideEffects: boolean;
  /// the query parsed from specifier, for example, query should be `{ inline: true }` if specifier is `./a.png?inline`
  /// if you custom plugins, your plugin should be responsible for parsing query
  /// if you just want a normal query parsing like the example above, [crate::utils::parse_query] is for you
  query: [string, string][] | null;
  /// meta data of the module, will be passed to [PluginLoadHookParam] and [PluginTransformHookParam]
  meta: Record<string, string> | null;
}

:::note resolve 钩子中所有 sources 与 importers 过滤器都是正则字符串。 :::

resolve 钩子负责从 importer 解析自定义 source。例如在 a.ts 中 import b from './b?raw':

import b from './b?raw';
// ...

则传入 resolve 钩子的参数为:

const param = {
  source: "./b",
  importer: { relative_path: "a.ts", query_string: "" },
  kind: 'import'
}

默认解析器的解析结果(以 Rust 结构示意)为:

const resolve_result = {
  resolved_path: "/root/b.ts",   // resolved absolute path of the module
  external: false, // this module should be included in the final compiled resources and should not be external
  side_effects: false, // this module may be tree shaken as it does not contains side effects
  query: [["raw", ""]], // query from the source.
  meta: {}
}

递归调用与防死循环:HookContext 用于在钩子递归调用时传递状态。例如在 resolve 钩子中调用 context.resolve:

const myPlugin = () => ({
  name: 'my-plugin',
  resolve: {
    filters: {
      sources: ['^.+foo.+$'],
      importers: ['^src/index.ts$']
    },
    executor: async (param, context, hookContext) => {
      console.log(param);
      if (hookContext.caller === 'my-plugin') {
        return null;
      }
      // replace the original source and resolve new source
      const newSource = param.source.replace('foo', 'bar');
      return context.resolve({
        ...param,
        source: newSource
      }, {
        caller: 'my-plugin',
        meta: {}
      });
    }
  }
});

上述例子中,我们调用 context.resolve 并传入 caller 参数,因此必须加上 if (hookContext.caller === 'my-plugin') 守卫来避免无限循环。

关于 resolve 钩子需要注意:

  • 默认执行顺序:默认情况下,你的 resolve 钩子在 Farm 内部默认解析器之后执行,只有内部解析器无法解析的 source 才会传给插件。这意味着若想覆盖默认解析行为,需要把插件 priority 设为大于 101;
  • 虚拟模块:resolved_path 通常是真实文件的绝对路径,但也可以返回 virtual:my-module 这样的虚拟模块 id,此时你需要实现 load 钩子来提供该虚拟模块的内容。在 Farm 中,resolved_path + query = module_id;
  • ResolveKind 表示导入类型,例如 require(CommonJS require 导入)、cssImport(CSS 的 import 语句导入)等,完整定义见 binding.ts;
  • meta 可跨插件、跨钩子共享,你可以在任何插件的 load、transform、parse 钩子参数中读取 meta。

测试用例 js-plugins.spec.ts 演示了 priority: 1000 的 resolve 插件如何拦截入口解析并附加 query。

load

  • 必填:false
  • 钩子类型:first
  • 类型:
type LoadHook = {
  filters: {
    importers: string[];
    sources: string[];
  };
  executor: Callback<PluginLoadHookParam, PluginLoadHookResult>
};

type Callback<P, R> = (
  param: P,
  context?: CompilationContext,
  hookContext?: { caller?: string; meta: Record<string, unknown> }
) => Promise<R | null | undefined>;

export interface PluginLoadHookParam {
  moduleId: string;
  resolvedPath: string;
  query: [string, string][];
  meta: Record<string, string> | null;
}

export interface PluginLoadHookResult {
  /// the content of the module
  content: string;
  /// the type of the module, for example [ModuleType::Js] stands for a normal javascript file,
  /// usually end with `.js` extension
  moduleType: ModuleType;
  /// source map of the module
  sourceMap?: string | null;
}

load 钩子自定义如何从已解析的模块路径(或模块 id)加载模块内容。例如加载一个虚拟模块:

const myPlugin = () => ({
  name: 'my-plugin',
  load: {
    filters: {
      resolvedPaths: ['^virtual:my-plugin$'],
    },
    executor: async (param, context, hookContext) => {
      if (param.resolvedPath === 'virutal:my-plugin') {
        return {
          content: 'export default "foo"',
          moduleType: 'js'
        };
      }
    }
  }
});

要点:

  • 在 load 钩子中返回模块内容时,moduleType 与 content 是必填的;sourceMap 可选——如果你在 load 钩子中做了转换(不推荐,建议改用 transform 钩子),或者从其他位置读取了原始 source map,可以一并返回;
  • 过滤器语义:load 钩子的 filters.resolvedPath 是 resolvedPath + query,例如 /root/src/index.vue?vue&type=style&lang=css。若想忽略 query 过滤,可用 $ 结尾:src/index\\.vue$;若想按 query 过滤(例如只匹配 lang=css),可用 src/index.vue\\.+\\?vue&.+lang=css。

transform

  • 必填:false
  • 钩子类型:serial
  • 类型:
type TransformHook = {
  filters: {
    importers: string[];
    sources: string[];
  };
  executor: Callback<PluginTransformHookParam, PluginTransformHookResult>
};

type Callback<P, R> = (
  param: P,
  context?: CompilationContext,
  hookContext?: { caller?: string; meta: Record<string, unknown> }
) => Promise<R | null | undefined>;

export interface PluginTransformHookParam {
  moduleId: string;
  /// source content after load or transformed result of previous plugin
  content: string;
  /// module type after load
  moduleType: ModuleType; // Module Type is 'js' | 'jsx' | 'ts' | 'tsx' | 'css' | 'html'...
  resolvedPath: string;
  query: [string, string][];
  meta: Record<string, string> | null;
  sourceMapChain: string[];
}

export interface PluginTransformHookResult {
  /// transformed source content, will be passed to next plugin.
  content: string;
  /// you can change the module type after transform.
  moduleType?: ModuleType;
  /// transformed source map, all plugins' transformed source map will be stored as a source map chain.
  sourceMap?: string | null;
  // ignore previous source map. if true, the source map chain will be cleared. and this result should return a new source map that combines all previous source map.
  ignorePreviousSourceMap?: boolean;
}

transform 钩子基于 模块内容(content) 与 模块类型(moduleType) 做转换。以 sass 转 css 为例:

export default function farmSassPlugin(
  options: SassPluginOptions = {}
): JsPlugin {
  return {
    name: pluginName,
    load: {
      filters: { resolvedPaths: ['\\.(scss|sass)$'] },
      async executor(param) {
        if (param.query.length === 0 && existsSync(param.resolvedPath)) {
          const data = await readFile(param.resolvedPath);
          return {
            content: data,
            moduleType: 'sass'
          };
        }

        return null;
      }
    },
    transform: {
      filters: {
        moduleTypes: ['sass']
      },
      async executor(param, ctx) {
        const { css: compiledCss, map } = compileSass(param.content);
        return {
          content: compiledCss,
          moduleType: 'css' // transformed sass to css,
          sourceMap: JSON.stringify(map)
          ignorePreviousSourceMap: false,
        }
      }
    }
  }
}

编写 transform 钩子的常规步骤:

  1. 基于 moduleType 或 resolvedPath 或 moduleId 添加 if 守卫;
  2. 对 content 执行转换;
  3. 返回转换后的 content、sourceMap 与 moduleType。

关于 ignorePreviousSourceMap:如果你在 transform 钩子中处理了 param.sourceMapChain 并合并(collapsed)了之前插件的 source map,则应设置 ignorePreviousSourceMap: true 以保证 source map 正确;否则应始终设为 false,把 source map chain 交给 Farm 处理。

过滤器规则:

  • 当 resolvedPaths 与 moduleTypes 同时指定时,取并集;
  • filters.resolvedPaths 是 resolvedPath + query(同上),例如 /root/src/index.vue?vue&type=style&lang=css;忽略 query 用 $ 结尾:src/index\\.vue$;按 query 过滤可用 src/index.vue\\.+\\?vue&.+lang=css;
  • filters.moduleTypes 不是正则,必须精确匹配 ModuleType,如 css、js、tsx 等。

:::note transform 钩子是 content to content(内容到内容) 的转换。存在一个类似的 process_module 钩子,它是 ast to ast(AST 到 AST) 的转换。由于性能问题,JS 插件不支持 process_module 钩子;如果你需要 AST 级转换,请改用 Rust Plugin。 :::

buildEnd

  • 类型:buildEnd?: { executor: Callback<Record<string, never>, void> };
  • 钩子类型:parallel
  • 必填:false

在 ModuleGraph 构建完成之后、资源渲染与生成开始之前被调用,适合做状态更新或收尾工作。

const myPlugin = () => {
  // your plugin operations
  let myPluginContext = createMyPluginContext();

  return {
    name: 'my-plugin',
    buildEnd: {
      async executor() {
        // update my plugin status
        myPluginContext.updateStatus('module-graph-built');
      }
    }
  }
}

:::note buildEnd 也只在首次编译时调用一次,后续的 Lazy Compilation 与 HMR Update 不会触发。 :::

renderStart

  • 类型:renderStart?: { executor: Callback<Config['config'], void> };
  • 钩子类型:parallel
  • 必填:false

在资源渲染开始之前被调用。

const myPlugin = () => {
  // your plugin operations
  let myPluginContext = createMyPluginContext();

  return {
    name: 'my-plugin',
    renderStart: {
      async executor() {
        // update my plugin status
        myPluginContext.updateStatus('render-start');
      }
    }
  }
}

:::note renderStart 同样只在首次编译时调用一次。 :::

renderResourcePot

  • 必填:false
  • 钩子类型:serial
  • 类型:
type RenderResourcePotHook = JsPluginHook<
  {
    resourcePotTypes?: ResourcePotType[];
    moduleIds?: string[];
  },
  RenderResourcePotParams,
  RenderResourcePotResult
>;

type Callback<P, R> = (
  param: P,
  context?: CompilationContext,
) => Promise<R | null | undefined>;
type JsPluginHook<F, P, R> = { filters: F; executor: Callback<P, R> };

export interface RenderResourcePotParams {
  content: string;
  sourceMapChain: string[];
  resourcePotInfo: {
    id: string;
    name: string;
    resourcePotType: ResourcePotType;
    map?: string;
    modules: Record<ModuleId, RenderedModule>;
    moduleIds: ModuleId[];
    data: JsResourcePotInfoData;
    custom: Record<string, string>;
  };
}
export interface RenderResourcePotResult {
  content: string;
  sourceMap?: string;
}

Resource Pot(资源池)是最终输出 bundle 文件的抽象表示。你可以返回转换后的 resourcePot content 来修改最终产物。例如渲染 css 时替换占位符:

const myPlugin = () => ({
  name: 'test-render-resource-pot',
  renderResourcePot: {
    filters: {
      moduleIds: ['^index.ts\\?foo=bar$'],
      resourcePotTypes: ['css']
    },
    executor: async (param) => {
      return {
        content: param.content.replace(
          '<--layer-->',
          cssCode
        ),
        sourceMap
      };
    }
  }
})

上述示例把 css 资源池中所有 <--layer--> 替换为真实的 css 代码。

:::note 当 filters.moduleIds 与 filters.resourcePotTypes 同时指定时,取并集。 :::

augmentResourceHash

  • 必填:false
  • 钩子类型:serial
  • 类型:
type AugmentResourceHash = JsPluginHook<
  {
    resourcePotTypes?: ResourcePotType[];
    moduleIds?: string[];
  },
  {
    id: string;
    name: string;
    resourcePotType: ResourcePotType;
    map?: string;
    modules: Record<ModuleId, RenderedModule>;
    moduleIds: ModuleId[];
    data: JsResourcePotInfoData;
    custom: Record<string, string>;
  },
  string
>;

type Callback<P, R> = (
  param: P,
  context?: CompilationContext,
) => Promise<R | null | undefined>;
type JsPluginHook<F, P, R> = { filters: F; executor: Callback<P, R> };

为指定的 Resource Pot 追加资源哈希参数。当你希望在生成资源哈希时加入额外条件时非常有用:

const myPlugin = () => ({
  name: 'test-augment-resource-pot',
  renderResourcePot: {
    filters: {
      moduleIds: ['^index.ts\\?foo=bar$'],
      resourcePotTypes: ['css']
    },
    executor: async (param) => {
      return 'my-hash-args';
    }
  }
})

:::note 当 filters.moduleIds 与 filters.resourcePotTypes 同时指定时,取并集。 :::

finalizeResources

  • 必填:false
  • 钩子类型:serial
  • 类型:
type FinalizeResourcesHook = {
  executor: Callback<
    FinalizeResourcesHookParams,
    FinalizeResourcesHookParams['resourcesMap']
  >;
};

export type FinalizeResourcesHookParams = {
  resourcesMap: Record<string, Resource>;
  config: Config['config'];
};

export interface Resource {
  name: string;
  bytes: number[];
  emitted: boolean;
  resourceType: string;
  origin: { type: 'ResourcePot' | 'Module'; value: string };
  info?: ResourcePotInfo;
}

对所有已生成的资源做最终处理,返回转换后的 resourcesMap。在这个钩子中你可以对最终产物资源进行 add(新增)、remove(删除)、modify(修改)。

注意:

  • bytes 是最终输出的二进制内容,对 js/css/html 代码,可用 Buffer.from(bytes).toString() 还原代码;
  • name 是最终文件名;
  • origin 表示该 Resource 的来源:ResourcePot 表示来自模块打包后的资源池;Module 表示来自单个模块,例如 .png/.jpg 等静态文件。

在 js-plugins.spec.ts 的 full 测试中,finalizeResources 接收 resourcesMap 与 config,用 Buffer.from(v.bytes).toString('utf-8') 读取代码并做快照断言,并返回 params.resourcesMap。

transformHtml

  • 必填:false
  • 钩子类型:serial
  • 类型:
type TransformHtmlHook = {
  order?: 0 | 1 | 2;
  executor: Callback<{ htmlResource: Resource }, Resource>;
};

order 用于配置 transformHtml 钩子的执行阶段:

  • 0:即 pre,在解析与生成资源之前执行,此时可以对原始 HTML 做转换;
  • 1 与 2:即 normal 与 post,在解析与生成资源之后执行,此时所有 <script>、<link> 标签均已注入。

转换最终生成的 HTML(在全部 <script>、<link> 标签注入之后):

const myPlugin = () => ({
  name: 'my-plugin',
  transformHtml: {
    order: 2,
    async executor({ htmlResource }) {
      const htmlCode = Buffer.from(htmlResource).toString();

      const newHtmlCode = htmlCode.replace('my-app-data', data);
      htmlResource.bytes = [...Buffer.from(newHtmlCode)];

      return htmlResource;
    }
  }
});

:::note 你必须修改 htmlResource 的 bytes 字段并返回修改后的 htmlResource;修改任何其他字段不会生效。 :::

测试用例 transform-html.spec.ts 验证了 order: 0(pre)与 order: 2(post)两个阶段钩子的调用顺序,且 HTML 快照被正确写入 resourcesMap['index.html']。

writeResources

  • 必填:false
  • 钩子类型:serial
  • 类型:
type WriteResourcesHook = {
  executor: (param: FinalizeResourcesHookParams) => void | Promise<void>;
};

在所有资源写入磁盘之后被调用。

pluginCacheLoaded

  • 必填:false
  • 钩子类型:serial
  • 类型:
type PluginCacheLoadedHook = {
  executor: Callback<number[], undefined | null | void>;
};

扩展插件的 persistent cache(持久缓存) 加载能力。

当 Persistent Cache 启用时,命中缓存会跳过 load 与 transform 钩子。如果你的插件依赖上一次编译的结果(例如基于已有模块加载虚拟模块),你可能需要实现此钩子来加载插件自身的缓存信息,确保缓存按预期工作:

const myPlugin = () => {
  let cachedData;

  return {
    name: 'my-plugin',
    pluginCacheLoaded: {
      async executor(bytes) {
        const str = Buffer.from(bytes).toString();
        cachedData = JSON.parse(str);
      }
    }
  }
}

:::note 序列化/反序列化方式完全由插件自行决定。最简单的示例:用 [...Buffer.from(JSON.stringify(data))] 反序列化字节数据。 :::

writePluginCache

  • 必填:false
  • 钩子类型:serial
  • 类型:
type WritePluginCacheHook = {
  executor: Callback<undefined, number[]>;
};

扩展 persistent cache 的写入能力。writePluginCache 通常与 pluginCacheLoaded 配合使用,实现插件持久缓存的读写。钩子返回数据的序列化字节:

const myPlugin = () => {
  let cachedData = { foo: 'bar' };

  return {
    name: 'my-plugin',
    writePluginCache: {
      async executor() {
        const bytes = [...Buffer.from(JSON.stringify(data))];
        return bytes;
      }
    }
  }
}

:::note 序列化/反序列化方式由插件自行决定,基础示例为 [...Buffer.from(JSON.stringify(data))]。 :::

finish

  • 类型:finish?: { executor: Callback<Record<string, never>, void> };
  • 钩子类型:parallel
  • 必填:false

在资源渲染开始之前被调用(文档原始描述如此,实际语义为构建收尾阶段,参见下方执行顺序示例):

const myPlugin = () => {
  // your plugin operations
  let myPluginContext = createMyPluginContext();

  return {
    name: 'my-plugin',
    finish: {
      async executor() {
        // update my plugin status
        myPluginContext.updateStatus('finish');
      }
    }
  }
}

:::note finish 只在首次编译时调用一次,后续的 Lazy Compilation 与 HMR Update 不会触发。 :::

在 js-plugins.spec.ts 的完整流程测试中,钩子调用顺序被断言为 buildStart → buildEnd → finalizeResources → finish。

updateModules

  • 必填:false
  • 钩子类型:serial
  • 类型:
type UpdateModulesHook = {
  executor: Callback<
    { paths: [string, string][] },
    string[] | undefined | null | void
  >;
};

当调用 compiler.update(module_paths) 时被触发,适合做清理上一状态、或忽略某些文件的处理(例如 HMR 场景)。

  • paths 是本次更新将要重新编译的路径;
  • 返回新的 paths,后续编译将更新为返回的新路径。

对应源码中的参数类型为 PluginUpdateModulesHookParam { paths: <a href="https://link.gitcode.com/i/2fffde66cb154f9e81afec63e371cc5b" target="_blank">string, UpdateType][] },其中 UpdateType 为 'added' | 'updated' | 'removed'(见 [binding.ts)。测试用例 update-modules.spec.ts 验证了:钩子能收到 [indexPath, 'updated']、['test2', 'added']、['test3', 'removed'] 这样的路径列表;且当一个插件在 updateModules 中返回替换路径时,后续插件收到的 param.paths 会变成替换后的结果。

钩子执行顺序总览

结合文档与 完整测试 的断言,可将 JS 插件的钩子执行流程归纳如下:

  1. 配置阶段:config(可修改配置,deep merge)→ configResolved(拿到最终配置,保存备用)→(JS 插件之后才是 Rust 插件的 config);
  2. 服务器/编译器就绪:configureDevServer(仅开发模式)→ configureCompiler(开发与生产都有);
  3. 构建阶段:buildStart(仅首次)→ resolve / load / transform(模块图构建期核心三钩子)→ buildEnd(仅首次);
  4. 生成阶段:renderStart(仅首次)→ renderResourcePot → augmentResourceHash → finalizeResources(可增删改资源)→ transformHtml(order 0/1/2 分阶段)→ writeResources(落盘后)→ finish(仅首次);
  5. 缓存扩展:pluginCacheLoaded 与 writePluginCache 配合持久缓存读写;
  6. 热更新:updateModules 在 compiler.update() 时触发,可改写待重编译路径。

总结

Farm 的 JS 插件系统完整继承了 Rollup 风格的设计思想:name 标识身份、priority 控制顺序、filters + executor 定义钩子、first/serial/parallel 三种执行模式。通过 resolve → load → transform 三大核心钩子可以参与模块图构建,通过 renderResourcePot → finalizeResources → transformHtml 等钩子可以深度定制最终产物,再配合 pluginCacheLoaded/writePluginCache 持久缓存钩子与 updateModules HMR 钩子,足以支撑从轻量替换到全流程定制的各类需求。

写作插件时建议直接对照源码类型定义(plugin/type.ts、types/binding.ts)以及 js-plugins.spec.ts 测试用例来校验钩子签名与行为,并使用 create-farm-plugin 脚手架快速起步。若需要 AST 级转换等 JS 插件不支持的场景,可参阅 Rust Plugin API 文档。

登录后查看全文
farm