esbuild 插件编写
esbuild 的插件允许我们在 esbuild 打包的过程中插入代码。
插件系统不允许通过命令行中使用,同时只支持在 build 方法中使用,transform 不允许。
定义
esbuild 的插件非常简单,只有 name 、setup 两个字段。
plugin 接口定义:
export interface Plugin {
name: string
setup: (build: PluginBuild) => (void | Promise<void>)
}
插件写法:
esbuild.build({
plugin: [{
name: 'es-plugin',
setup(build) {
// ...
build.onLoad({namespace, filter}, callback)
}
}]
})
setup 函数在执行 build 方法的时候才会执行一次。
build 中的概念
在 esbuild 中运行编写的插件会跟在其他打包器中的插件运行有点区别,可以查看下面的内容:
namespaces
命名空间,用于标识模块。
每个模块都会有一个相关的 namespace,默认使用的是 file,因为这些模块通常来说都是存在于文件系统的。
同时,esbuild 也是可以处理 虚拟模块(不存在于文件系统中的文件)的,通过 stdin 字段提供的一个模块:
esbuild.build({
stdin: {
contents: `console.log('virtual file')`,
loader: 'js'
},
bundle: true,
outfile: "bundle.js",
})
虚拟模块使用其他的命名空间(非 file)来于真实文件进行区分,比如:
{
plugins: [
{
name: 'es-plugin',
setup(build) {
build.onLoad({namespace: 'virtual-module', filter: /.*/}, () => {
})
}
}
]
}
插件可以用来生成虚拟模块,通常虚拟模块的 namespace 都是由创建它的插件定义的。
filter
用于过滤文件,只有与 filter 匹配的路径才会执行对应的回调函数,这些设计都是基于性能考虑的。同时最好使用正则表达式去编写过滤条件,而非 js 代码,这样在 esbuild 内部可以进行计算,不需要再启动 javaScript 环境。
由于 esbuild 是通过 go 编写的,正则表达式的写法与 js 相比会有一些不同,具体写法请看 https://pkg.go.dev/regexp
namespace 也可用于过滤,回调函数必须包含 filter 字段,同时也可以设置 namespace 来更进一步的对过滤出来的文件路径进行限制。
{
plugins: [
{
name: 'es-plugin',
setup(build) {
// 过滤出 html 类型的文件
build.onLoad({filter: /\.(html|vue|svelte|astro|imba)$/}, () => {
})
}
}
]
}
{
plugins: [
{
name: 'es-plugin',
setup(build) {
// 过滤出虚拟模块
build.onLoad({namespace: 'virtual-module', filter: /.*/}, () => {
})
}
}
]
}
方法
多个方法都是同步执行(要注意 filter 如果满足多个回调,那么都会执行)的,如果想要使用公共数据,要特别注意使用同步执行。
onResolve
onResolve 在 ebuild 打包过程中每个模块及模块内的每个 import 中都会判断执行一次。
用于指定 esbuild 怎么处理路径(path),可以将引入的依赖设置成外部引入(external),避免在 load 阶段加载异常或者需要手动设置external字段。
// 将 import 'vue' 设置成external,不打包代码
build.onResolve({ filter: /^vue$/ }, (args) => {
return { path: args.path, external: true };
});
比如:
// index.js
import { Path } from 'env';
console.log(`PATH is ${Path}`);
// bundle.js
esbuild.build({
entryPoints: ['index.js'],
bundle: true,
outfile: 'out.js',
plugins: [
{
name: 'es-plugin',
setup(build) {
// 在这个监听中直接修改了env的path然后传递到 onLoad中,不需要解析出 env 具体的 path,同时添加了一个 namespace 用于过滤区分
build.onResolve({filter: /^env\//}, args => {
return { path: args.path, namespace: 'env-test' };
}),
// 用filter 过滤后在匹配 namespace,之后直接返回内容。
build.onLoad({ filter: /.*/, namespace: 'env-test' }, (args) => {
return { contents: JSON.stringify(process.env), loader: 'json' };
});
}
}
]
});
在上面的例子中,解析入口文件 index.js 会执行一次 onResolve,获取的配置信息如下:
{
path: './index.js',
importer: '',
namespace: 'file',
resolveDir: 'E:\\esbuild\\demo1',
kind: 'entry-point',
pluginData: undefined
}
在 index.js 文件中存在一行 import { Path } from ‘env’; 语句,也会执行一次 onResolve,获取的配置信息如下:
{
path: 'env',
importer: 'E:\\esbuild\\demo1\\index.js',
namespace: 'file',
resolveDir: 'E:\\esbuild\\demo1\\plugin',
kind: 'import-statement',
pluginData: undefined
}
onResolve 方法的第一个参数就是过滤对象(filter、namespaces),第二个参数就是回调函数,该回调函数接受以下字段:
export interface OnResolveArgs {
path: string
importer: string
namespace: string
resolveDir: string
kind: ImportKind
pluginData: any
}
type ImportKind =
| 'entry-point'
// JS
| 'import-statement'
| 'require-call'
| 'dynamic-import'
| 'require-resolve'
// CSS
| 'import-rule'
| 'composes-from'
| 'url-token'
这个回调可以直接 return 出一个对象,如果包含了 path 字段,那么之后的所有 onResolve 回调都会按照返回的这个 path 进行处理,如果没有返回,那么 esbuild 会有自己的处理逻辑。
resolve
当 onResolve 返回了一个对象时,这个对象会替换掉 esbuild 内置的 path 解析结果,这个设计使得插件可以完全控制 path 解析的过程,同时这也意味着如果我们需要返回的对象跟 esbuild 的处理结果一样,那么就需要重新实现一些 esbuild 的内置功能。比如说当我们想要在用户的 node_modules 目录中查找某个包,这个功能 esbuild 已经内置了。
当然处理重新实现内置功能以外,esbuild 也提供了这些内置功能的接口,我们可以直接使用:
下面的例子就是通过过滤出 example 这个导入,返回其他文件路径提供给 onLoad 使用。
import * as esbuild from 'esbuild'
let examplePlugin = {
name: 'example',
setup(build) {
build.onResolve({ filter: /^example$/ }, async () => {
const result = await build.resolve('./foo', {
kind: 'import-statement',
resolveDir: './bar',
})
if (result.errors.length > 0) {
return { errors: result.errors }
}
return { path: result.path, namespace: 'file' }
})
},
}
await esbuild.build({
entryPoints: ['app.js'],
bundle: true,
outfile: 'out.js',
plugins: [examplePlugin],
})
可配置参数
接受两个参数:
- path(String): 路径
- config(Object): 额外配置:
- kind: 路径如何导入的类型,会影响到路径解析功能。‘entry-point’ | ‘import-statement’ | ‘require-call’ | ‘dynamic-import’ | ‘require-resolve’ | ‘import-rule’ | ‘composes-from’ | ‘url-token’
- importer: 在引入当前解析模块的文件路径。
- namespace: 在引入当前解析模块的文件的namespace。
- resolveDir: 文件目录,必须设置。
- pluginData: 传入的数据可以被所有的onResolve 回调使用。
在使用 resolve 方法时需要注意:
- 如果没有传递 resolveDir 参数,那么 esbuild 内部不会尝试任何的路径解析,esbuild 的路径解析逻辑都依赖于 resolveDir 这个字段,包括查找 node_modules 目录里的包。
- 如果文件名的开头不是以 ./ 开头的,那么 esbuild 会把这个输入路径当成一个包路径而非一个相对路径。
- 如果路径解析异常,那么返回对象里会携带 errors 值,解析异常不一定会抛出异常错误,我们最好手动去判断错误。
if (result.errors.length > 0) { return { errors: result.errors } }
result.path 是真实的文件绝对路径。
onLoad
onLoad 在每次遇到新的没有被标记成 external的 path/namespace 对都会执行一次。
在这个阶段可以手动返回 onResolve 中获取到的模块的地址,然后在 load 阶段提前处理对应的文件内容。
onLoad 阶段就是找到 import 引入的文件内容,可以在这个阶段手动填充其他内容替换依赖。
可以在这个阶段去解析读取 esbuild 不支持的文件,然后返回 esbuild 可解析的类型,
处理 txt 文件,将其装换成json:
build.onLoad({ filter: /.txt$/ }, async (args) => {
let text = await fs.readFile(args.path, 'utf-8');
return {
contents: JSON.stringify(text.split(/\s+/)),
loader: 'json',
};
});
如果 onLoad 没有 return 任何对象,那么加载模块的任务就会轮到下一个 onLoad 回调,如果都没有return,那么就会由 esbuild 内部处理。
在 esbuild 中,很多回调都是同时执行的,如果想要顺序一个个执行,就需要使用 async - await 的方式。
// 先执行下面的
build.onLoad({ filter: /.txt$/ }, (args) => {
fs.readFile(args.path, 'utf-8').then((text) => {
console.log(text);
return {
contents: JSON.stringify(text.split(/\s+/)),
loader: 'json',
};
});
});
build.onLoad({ filter: /.txt$/ }, (args) => {
console.log('emit first');
});
// 先执行上面的
build.onLoad({ filter: /.txt$/ }, async (args) => {
let text = await fs.readFile(args.path, 'utf-8');
return {
contents: JSON.stringify(text.split(/\s+/)),
loader: 'json',
};
});
build.onLoad({ filter: /.txt$/ }, async (args) => {
console.log('async 2');
});
回调接收参数
interface OnLoadArgs {
path: string;
namespace: string;
suffix: string;
pluginData: any;
with: Record<string, string>;
}
- path: 完全处理过的路径,如果 namespace 是 file,那么说明是文件系统的路径,否则的话可以是任何形式。
- namespace: 模块路径对应的 namespace,默认是 file。
- suffix: 主要用于处理包含了一些特殊情况的 css 文件。
- pluginData: 插件数据(TODO 如何设置)
- with: TODO
返回数据
interface OnLoadResult {
contents?: string | Uint8Array;
errors?: Message[];
loader?: Loader;
pluginData?: any;
pluginName?: string;
resolveDir?: string;
warnings?: Message[];
watchDirs?: string[];
watchFiles?: string[];
}
interface Message {
text: string;
location: Location | null;
detail: any; // The original error from a JavaScript plugin, if applicable
}
interface Location {
file: string;
namespace: string;
line: number; // 1-based
column: number; // 0-based, in bytes
length: number; // in bytes
lineText: string;
}
- contents: 模块内容,如果设置了,那么其他onLoad 方法不会在执行。如果所有的onLoad回调都没有返回,那么 esbuild 会使用默认的内容加载功能去处理(只针对namespace 为 file的)。TODO 如果 return 的对象里不包含 contents 字段会怎么样,
- loader:告知esbuild 怎么去解析内容, TODO 支持的解析器类型
- resolveDir:模块对应的文件系统目录,如果namespace是file,那么这个值为模块所在目录,否则可以为空或者是插件提供。
- errors、warnings:在 esbuild 路径解析的时候出现的警告或者错误。
- watchFiles、watchDirs:
- pluginName: 插件重命名
- pluginData: 用于在插件之间共享数据。
onStart
在 esbuild 开始执行 build 方法的时候触发,不单只是首次的执行,还在 rebuild、watch 模式、serve 模式中可以使用。
onStart(callback: () =>
(OnStartResult | null | void | Promise<OnStartResult | null | void>)): void
不应该在 onStart方法中执行任何初始化操作,因为它可以会多次调用,直接在 setup 方法里初始化即可。
onEnd
在 esbuild 执行完 build 方法的时候触发,不单只是首次的执行,还在 rebuild、watch 模式、serve 模式中可以使用。
onDispose
当插件不在使用时就会执行 onDispose 方法,在每次 build 结束后都会执行,不管结果如何。
build options
通过 build.initialOptions 可以获取到启动 build 时的所有配置,同时也可以修改这些配置。
let examplePlugin = {
name: 'auto-node-env',
setup(build) {
const options = build.initialOptions
options.define = options.define || {}
options.define['process.env.NODE_ENV'] =
options.minify ? '"production"' : '"development"'
},
}
如果是在 build start 阶段之后修改的配置,那么不会生效,rebuild 等方法也不会更新这些配置、
路径解析
前面提到如果 onResolve 阶段返回了一个对象(path 不为空),那么这个对象会替换掉 esbuild 内置的路径解析功能。
// 没有携带 path 字段,会使用 esbuild 的内置路径解析功能
return { namespace: 'file' }
// path 为空,会使用 esbuild 的内置路径解析功能
return { path: '', namespace: 'file' }
插件缓存
esbuild 内部不处理插件缓存,如果在写插件的时候如果耗时较长,最薅写一个缓存逻辑。
使用一个map来保存转换函数,key通常是需要转换的输入,而value就是输出,最好使用 LRU 算法来清除最常访问的数据,防止map越来越大。
有几种缓存方式
- 保存在内存中(可以直接使用 rebuild 方法)
- 保存在磁盘里(可以在不同 build 方法里使用同一份数据)
- 保存在服务器里(可以在不同服务中使用)