esbuild 插件编写

发布于:2024-03-01 ⋅ 阅读:(56) ⋅ 点赞:(0)

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],
})
可配置参数

接受两个参数:

  1. path(String): 路径
  2. 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 方法时需要注意:

  1. 如果没有传递 resolveDir 参数,那么 esbuild 内部不会尝试任何的路径解析,esbuild 的路径解析逻辑都依赖于 resolveDir 这个字段,包括查找 node_modules 目录里的包。
  2. 如果文件名的开头不是以 ./ 开头的,那么 esbuild 会把这个输入路径当成一个包路径而非一个相对路径。
  3. 如果路径解析异常,那么返回对象里会携带 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越来越大。

有几种缓存方式

  1. 保存在内存中(可以直接使用 rebuild 方法)
  2. 保存在磁盘里(可以在不同 build 方法里使用同一份数据)
  3. 保存在服务器里(可以在不同服务中使用)
本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到