Rspack和Farm都是以性能为卖点的打包工具,前者基于Rust重写了webpack的核心能力,兼容大部分webpack插件生态;后者则是从零设计的编译框架,插件体系借鉴了swc的处理思路。两者在编写插件时都提供了钩子(Hook)机制,让开发者可以在编译的各个阶段插入自定义逻辑。不过很多团队在接入时图省事,直接把插件参数声明成any,结果编译期检查形同虚设。这篇文章就来聊聊如何用TypeScript为这两款工具的钩子编写严谨的类型定义。

一、理解Rspack的钩子类型体系
Rspack为了兼容webpack生态,内部实现了一套与Tapable高度兼容的钩子系统。这意味着你在webpack插件里熟悉的compiler.hooks和compilation.hooks在Rspack中同样存在,而且官方提供了@rspack/core包,其中导出了RspackPluginInstance、RspackCompiler、RspackCompilation等一系列类型。写类型的第一步,就是不要自己凭空造类型,而是复用这些官方导出。
一个典型的Rspack插件结构如下,注意apply方法的参数类型直接引用官方类型,而不是any:
import type { RspackPluginInstance, Compiler, Compilation } from '@rspack/core';
import type { Sources } from 'rspack';
class MyPlugin implements RspackPluginInstance {
apply(compiler: Compiler) {
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
// 这里的compilation已经被正确推导为Compilation类型
compilation.hooks.processAssets.tap(
{
name: 'MyPlugin',
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
},
(assets) => {
for (const [name, source] of Object.entries(assets)) {
const content = source.source().toString();
console.log(`资源 ${name} 大小: ${content.length}`);
}
}
);
});
}
}
这段代码的关键在于tap的回调参数类型是由Compiler类型自动推导出来的,不需要手动标注。如果编辑器里没有出现提示,多半是tsconfig.json的moduleResolution没有配置成bundler或者node16,导致@rspack/core的exports字段解析失败。另外要注意,processAssets的stage常量挂在Compilation命名空间上,用import type引入类型后,还需要单独用import引入这个命名空间的运行时值,否则打包时会报找不到常量的错误。
二、为复杂钩子参数编写精确类型
官方类型有时覆盖不到所有细节。比如processAssets的assets对象,其value类型是Sources.Source的联合类型,直接调用source()方法时TypeScript可能提示方法在联合类型上不存在。这时可以用类型守卫做收窄:
function isRawSource(
source: Sources.Source
): source is Sources.RawSource {
return (source as Sources.RawSource).isRawSource === true;
}
compilation.hooks.processAssets.tap({ name: 'MyPlugin' }, (assets) => {
for (const [file, source] of Object.entries(assets)) {
if (isRawSource(source)) {
// 这里source被收窄为RawSource,可以直接访问buffer
const buf = source.buffer();
console.log(`${file} 是原始资源,字节数 ${buf.length}`);
}
}
});
除了类型守卫,还可以借助工具类型简化。假设你的插件需要暴露可配置选项,推荐用satisfies操作符配合泛型对象,既能保留字面量类型,又能校验配置结构:
interface MyPluginOptions {
exclude?: RegExp[];
prefix?: string;
}
class MyPlugin implements RspackPluginInstance {
private options: Required<MyPluginOptions>;
constructor(options: MyPluginOptions = {}) {
this.options = {
exclude: [/\.map$/],
prefix: 'APP_',
...options,
};
}
apply(compiler: Compiler) {
// 插件逻辑
}
}
这种写法的好处是,Required<MyPluginOptions>保证了实例内部的配置永远是完整的,而构造函数接受可选字段,对外接口保持友好。相比在每个使用点重复判空,把默认值集中在构造函数里处理要干净得多。
三、Farm插件钩子的类型声明方式
Farm的插件体系与Rspack差异较大。Farm插件不是基于类和apply方法,而是导出一个插件对象数组,每个对象实现PluginHook接口中的部分钩子。Farm官方通过@farmfe/core导出Plugin类型,它是一个庞大的可选属性集合,你只需实现自己关心的钩子,TypeScript会自动帮你校验钩子名称和参数签名是否正确。
import type { Plugin } from '@farmfe/core';
export function myFarmPlugin(): Plugin {
return {
name: 'my-farm-plugin',
transform: {
filters: {
moduleTypes: ['ts'],
},
executor(param) {
// param类型由Plugin接口推导,包含content、moduleType、resolvePath等字段
const transformed = param.content.replace(
/process\.env\.APP_NAME/g,
JSON.stringify('my-app')
);
return { content: transformed, moduleType: 'ts' };
},
},
generate: {
filters: { moduleTypes: ['css'] },
executor(param) {
console.log(`正在处理CSS模块,产物名 ${param.name}`);
return undefined;
},
},
};
}
这里有三个容易被忽视的类型细节。第一,Farm的transform和generate等钩子支持filters过滤配置,正确声明filters可以让钩子只在你关心的模块类型上触发,减少不必要的性能开销。第二,executor的返回值是可选的联合类型,返回undefined表示放弃修改,类型系统会强制你处理这条分支。第三,如果你的插件需要在钩子之间共享状态,不要把状态塞进插件对象里,而是用闭包封装,并通过watchModes等钩子在模式切换时重置,避免热更新时状态残留。
四、跨工具复用类型定义的实践建议
如果同一个插件逻辑需要同时支持Rspack和Farm,直接把两套类型混在一个文件里会非常混乱。推荐的做法是分层:核心处理逻辑用无依赖的纯函数实现,输入输出都定义成中立的自定义类型;然后再写两个薄适配层,分别对接Rspack的钩子签名和Farm的钩子签名。这样核心逻辑可以被单测覆盖,适配层的类型校验也能各自独立。
具体来说,可以定义一个TransformContext接口描述统一的处理上下文,Rspack适配层从compilation中提取所需字段填入上下文,Farm适配层则直接从executor的param构造。两端共享同一个transformSource纯函数,任何行为变更只需改一处。此外,建议在项目根目录放置一个plugins/types.d.ts声明文件,集中管理自定义类型并导出,配合tsconfig.json的paths映射,避免各处相对路径引用带来的维护成本。
最后提一句工程层面的配置:务必开启strict模式并启用noImplicitOverride,同时在package.json的types字段中正确指向类型入口。类型定义写好之后,插件的使用方在配置文件里也能获得完整的智能提示,这对维护团队内的共享插件库尤其有价值。
TypeScriptRspack插件Farm钩子类型修改时间:2026-09-06 05:32:35