# 插件开发

插件开发其实就是基于原有的体系结构中进行扩展自定义。 Esbuild 插件也不例外,通过 Esbuild 插件我们可以扩展 Esbuild 原有的路径解析、模块加载等方面的能力,并在 Esbuild 的构建过程中执行一系列自定义的逻辑。

esbuild 插件是一个带有namesetup函数的对象,当然很多时候,我们都会以函数的的方式返回这个对象。其中,name 的值是一个字符串,它表示你的插件名称 。 setup 的值是一个函数,它会被传入一个参数 build(对象)。

export interface Plugin {
  name: string
  setup: (build: PluginBuild) => (void | Promise<void>)
}
成功
1
2
3
4

build 对象上会暴露5个钩子函数:onStartonResolveonLoadonEndonDispose

# 插件运行机制

build对象上的5个钩子函数,其实就是esbuild构建过程中的几个阶段我们需要去扩展执行的内容

image-20230815145208844

# onStartonEndonDispose

其中onStartonEndonDispose的使用都很简单。onStartonEnd两个钩子无非就是用来在构建开启和结束时执行一些自定义的逻辑,onDispose无非就是不再使用插件时执行清理。这三个钩子函数都有一个回调函数作为参数。其中onEnd的回调函数可以获取esbuild执行之后的返回值。

/** Documentation: https://esbuild.github.io/plugins/#on-start */
onStart(callback: () =>
  (OnStartResult | null | void | Promise<OnStartResult | null | void>)): void
/** Documentation: https://esbuild.github.io/plugins/#on-end */
onEnd(callback: (result: BuildResult) =>
  (OnEndResult | null | void | Promise<OnEndResult | null | void>)): void
/** Documentation: https://esbuild.github.io/plugins/#on-dispose */
onDispose(callback: () => void): void
成功
1
2
3
4
5
6
7
8

我们可以先来看一下这几个函数,然后就可以马上就能做出一些很简单的插件

let testPlugin = () => ({
  name: "test-plugin",
  setup(build) {
    console.log(build.initialOptions)
    build.onStart(() => {
      console.log("===> onStart <===");
    });
    build.onEnd((result) => {
      console.log(result)
      console.log(`===> onEnd  <===`);
    });
    build.onDispose(() => {
      console.log("===> onDispose <===");
    });
  }
});
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

可以注意build.initialOptionsbuild.onEnd的回调函数参数打印的内容:

{
  absWorkingDir: '/Users/yingside/work/demo/esbuild-demo',
  platform: 'browser',
  format: 'iife',
  assetNames: 'assets/[name]-[hash]',
  treeShaking: true,
  tsconfig: './tsconfig.json',
  logLevel: 'info',
  publicPath: '/',
  entryPoints: [ 'src/app.tsx', 'src/index.html' ],
  bundle: true,
  minify: false,
  sourcemap: true,
  target: [ 'es2020', 'chrome58', 'firefox57', 'safari11' ],
  metafile: true,
  outdir: './dist/',
  loader: { '.html': 'copy', '.svg': 'dataurl', '.png': 'file' },
  plugins: [ { name: 'test-plugin', setup: [Function: setup] } ]
}
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
  errors: [],
  warnings: [],
  outputFiles: undefined,
  metafile: {
    inputs: {
      'node_modules/react/cjs/react.development.js': [Object],
      'node_modules/react/index.js': [Object],
      'node_modules/scheduler/cjs/scheduler.development.js': [Object],
      'node_modules/scheduler/index.js': [Object],
      'node_modules/react-dom/cjs/react-dom.development.js': [Object],
      'node_modules/react-dom/index.js': [Object],
      'node_modules/react-dom/client.js': [Object],
      'src/components/comp.css': [Object],
      'node_modules/react/cjs/react-jsx-runtime.development.js': [Object],
      'node_modules/react/jsx-runtime.js': [Object],
      'src/components/Comp1.tsx': [Object],
      'src/assets/react.svg': [Object],
      'src/components/comps.module.css': [Object],
      'src/components/Comp2.tsx': [Object],
      'src/style.css': [Object],
      'src/assets/logo192.png': [Object],
      'src/app.tsx': [Object],
      'src/index.html': [Object]
    },
    outputs: {
      'dist/assets/logo192-3BFQN3OB.png': [Object],
      'dist/app.js.map': [Object],
      'dist/app.js': [Object],
      'dist/app.css.map': [Object],
      'dist/app.css': [Object],
      'dist/index.html': [Object]
    }
  },
  mangleCache: undefined
}
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

# 计算构建时间

let time = () => ({
  name: "time",
  setup(build) {
    let time;
    build.onStart(() => {
      time = Date.now();
      console.log("===> Build Start <===");
    });
    build.onEnd((result) => {
      // console.log(result)
      if (result.errors.length > 0) { 
        return;
      }
      console.log(`===> Build ended:${Date.now() - time}ms  <===`);
    });
    build.onDispose(() => {
      console.log("===> Build Disposed <===");
    });
  }
});
export default time;
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

这个插件的功能很简单,就是计算构建时间,无非也就是在onStart开始设置一个时间,然后onEnd计算时间差

# 清除打包内容

import { existsSync } from "fs";
import { rimraf } from "rimraf"

const clear = () => {
  return {
    name: "Clear",
    setup: (build) => {
      build.onStart(() => {
        const { outdir, outfile } = build.initialOptions;
        if (outdir && existsSync(outdir)) {
          rimraf.sync(outdir)
        }
        if (outfile && existsSync(outfile)) {
          rimraf.sync(outfile)
        }
      });
    },
  };
};
export default clear
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这里删除文件直接使用了第三方包rimraf,可以安装一下这个第三方包直接使用

npm i rimraf
成功
1

# 实现 HTML 构建

我们之前简单的使用loader的copy功能构建了html页面,但是却很死板,必须在开始就定义好css,js的文件名,如果css,js要实现hash定义的话,就很麻烦了,我们可以通过自定义HTML构建来实现这个效果。

import fs from "fs/promises";
import path from "path";

const development = "development" === process.argv[2];

const htmlPlugin = () => ({
  name: "html-plugin",
  setup(build) {
    build.onEnd(async (result) => {
      if (result.errors.length) {
        return;
      }
      // 获取 metafile
      const { metafile } = result;

      // 存放 script 和 link 标签
      const scripts = [];
      const links = [];

      if (metafile) {
        // 获取所有的输出文件,注意outputs是一个对象
        const { outputs } = metafile;
        const assets = Object.keys(outputs);

        assets.forEach((asset) => {
          //获取文件名
          asset = asset.substring(asset.lastIndexOf('/') + 1);
          if (asset.endsWith(".js")) {
            scripts.push(createScript(asset));
          } else if (asset.endsWith(".css")) {
            links.push(createLink(asset));
          }
        });
      }
      // 拼接 HTML 内容
      const templateContent = generateHTML(scripts, links);
      // HTML 写入
      const basePath = build.initialOptions.outdir ? build.initialOptions.outdir : process.cwd();
      const templatePath = path.join(basePath, "index.html");
      await fs.writeFile(templatePath, templateContent);
    });
  }
});

export default htmlPlugin;

const createScript = (src) => `<script type="module" src="${src}"></script>`;
const createLink = (src) => `<link rel="stylesheet" href="${src}"></link>`;
const generateHTML = (scripts, links) => `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  ${links.join("\n")}
</head>

<body>
  <div id="root"></div>
  ${scripts.join("\n")}
  ${development ? 
  `<script type="module">
    new EventSource('/esbuild').addEventListener('change', e => location.reload())
  </script>` : ""}
</body>

</html>
`;
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

# onResolveonLoad

onResolveonLoad是两个非常重要的钩子函数,分别控制路径解析模块内容加载的过程。它们都需要传入 Options(选项)和 CallBack(回调)等 2 个参数。

/** Documentation: https://esbuild.github.io/plugins/#on-resolve */
onResolve(options: OnResolveOptions, callback: (args: OnResolveArgs) =>
  (OnResolveResult | null | undefined | Promise<OnResolveResult | null | undefined>)): void

/** Documentation: https://esbuild.github.io/plugins/#on-load */
onLoad(options: OnLoadOptions, callback: (args: OnLoadArgs) =>
  (OnLoadResult | null | undefined | Promise<OnLoadResult | null | undefined>)): void
成功
1
2
3
4
5
6
7

# onResolve钩子函数

onResolve将在 esbuild 构建的每个模块中的每个导入路径上运行。该回调可以自定义 esbuild 如何进行路径解析

其中,Options 是一个对象,它包含 filter(必须)和 namespace 等 2 个属性:

/** Documentation: https://esbuild.github.io/plugins/#on-resolve-options */
export interface OnResolveOptions {
  filter: RegExp
  namespace?: string
}
成功
1
2
3
4
5

filter 为必传参数,是一个正则表达式,它决定了要过滤出的特征文件。

filter 正则是使用 Go 原生正则实现的,为了不使性能过于劣化,规则应该尽可能严格。同时它本身和 JS 的正则也有所区别,不支持前瞻(?<=)、后顾(?=)和反向引用(\1)这三种规则。

namespace 为选填参数,默认为file,一般在 onResolve 钩子中的回调参数返回namespace属性作为标识,我们可以在onLoad钩子中通过 namespace 将模块过滤出来。

CallBack 是一个回调函数。里面的参数args: onResolveArgs很重要,我们一般如果要操作onResolve钩子函数,一般都是在这个参数上做文章

const myPlugin = () => {
  return {
    name: 'my-plugin',
    setup(build) {
      build.onResolve({ filter: /.*/ }, args => {
        console.log('---onResolve---')
        console.log(args);
      })
    },
  }
}
成功
1
2
3
4
5
6
7
8
9
10
11

其实正则表达式{ filter: /.*/ }表示所有路径文件,因此这里会打印一堆内容,主要格式如下:

{
  // 模块路径
  path: 'react-dom/client',
  // 父模块路径
  importer: '/xxx/work/demo/esbuild-demo/src/app.tsx',
  // namespace 标识,默认为file
  namespace: 'file',
  // 基准路径
  resolveDir: '/xxx/work/demo/esbuild-demo/src',
  // 导入方式,如 import-statement、require-call、entry-point
  kind: 'import-statement',
  // 额外绑定的插件数据
  pluginData: undefined
}
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14

当然onResolve函数也可以有返回值:

return {
    // 错误信息
    errors: [],
    // 将其设置为true将模块标记为external,这意味着它将不会包含在捆绑包中,而是会在运行时导入
    external: false;
    // namespace 标识
    namespace: 'xxx';
    // 模块路径,如果要设置path,如果要和onLoad结合使用,要处理为绝对路径
    path: args.path,
    // 额外绑定的插件数据
    pluginData: null,
    // 插件名称
    pluginName: 'xxx',
    // 设置为 false,如果模块没有被用到,模块代码将会在产物中会删除。否则不会这么做
    sideEffects: false,
    // 添加一些路径后缀,如`?xxx`
    suffix: '?xxx',
    // 警告信息
    warnings: [],
    // 仅仅在 Esbuild 开启 watch 模式下生效
    // 告诉 Esbuild 需要额外监听哪些文件/目录的变化
    watchDirs: [],
    watchFiles: []
}
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

如果仅仅就只是对路径做一些处理,我们也可以直接开发出一些比较有用的插件,比如,有时候某些依赖的模块使用了 nodejs 的原生模块,但是以 node: 开头的的写法,这会导致 esbuild 无法识别。我们可以通过插件修改一下这些路径,比如:

# 改用external引入外部CDN

比如现在已经引入了lodash的库,在打包的时候当然会打包lodash的内容,但是现在希望去掉打包内容改用外部external引入CDN,我们可以通过插件来实现这一步

// 界面调用lodash
import _ from "lodash";

_.debounce(() => console.log("hello world"), 1000)();
成功
1
2
3
4
//esbuild-plugin-lodashExternal

const lodashExternal = () => {
  return {
    name: 'plugin-lodashExternal',
    setup(build) {
      build.onResolve({ filter: /(^lodash)/ }, (args) => ({
        path: 'https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm',
        external: true
      }))
    },
  }
}
export default lodashExternal;
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14

引入插件后,就将原来的lodash打包,转变为了CDN引入。

你可以通过插件打包metafile详情看到build打包信息

const result = await esbuild.build(config);
const text = await esbuild.analyzeMetafile(result.metafile, {
  verbose:true
})
console.log(text);
成功
1
2
3
4
5

其实,在esbuild配置中,我们可以直接配置external属性,写明哪些第三方包我们需要作为external引入

const config = {
  //入口列表
  entryPoints: ['src/App.tsx'],
  //输出目录
  outdir: './dist',
  //外部引入
  external: ['lodash'],
  //...其他省略
};
成功
1
2
3
4
5
6
7
8
9

然后删除界面的import语句,在html文件中添加script引入

<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
成功
1

# onLoad钩子函数

onLoad函数的工作是返回模块的内容并告诉 esbuild 如何解释它

注意这里的关键点:回模块的内容,并告诉esbuild如何解释他。

onResolve一样,,Options 是一个对象,它包含 filter(必须)和 namespace 2 个属性:

/** Documentation: https://esbuild.github.io/plugins/#on-load-options */
export interface OnLoadOptions {
  filter: RegExp
  namespace?: string
}
成功
1
2
3
4
5

其中,这里的namespace主要是和onResolve函数定义的namespace进行对应的,当然也可以单独运行。

args参数:

/** Documentation: https://esbuild.github.io/plugins/#on-load-arguments */
export interface OnLoadArgs {
  path: string //正则之后获取的路径,一般是绝对路径
  namespace: string //命名空间
  suffix: string  // 后缀信息
  pluginData: any // 额外的插件数据
}
成功
1
2
3
4
5
6
7

返回类型

/** Documentation: https://esbuild.github.io/plugins/#on-load-results */
export interface OnLoadResult {
  // 插件名称
  pluginName?: string
  
	// 错误信息
  errors?: PartialMessage[]
  // 警告信息
  warnings?: PartialMessage[]
  
	// 模块具体内容
  contents?: string | Uint8Array
  // 指定 loader,如`js`、`ts`、`jsx`、`tsx`、`json`等等
  loader?: Loader
  
  // 基准路径
  resolveDir?: string
  // 额外的插件数据
  pluginData?: any

  // 仅仅在 Esbuild 开启 watch 模式下生效
  // 告诉 Esbuild 需要额外监听哪些文件/目录的变化
  watchFiles?: string[]
  watchDirs?: string[]
}
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 以json格式读取txt文件内容

txt文件中的内容,以键值对的形式排列,我们希望将文件中的内容取到,并以json对象的格式在代码中输出

// assets/word.txt
name=jack
age=19
score=100
成功
1
2
3
4

希望可以在代码中像下面的方式获取到txt文件中的内容:

import word from "../assets/word.txt"

console.log(word.name,word.age,word.score)
成功
1
2
3

我们可以通过插件处理这种需求

import fs from "fs/promises";
import path from "path";

const txtPlugin = () => ({
  name: 'txt-plugin',
  setup(build) {
    build.onLoad({ filter: /\.txt$/ }, async (args) => {
      console.log(args)
      let text = await fs.readFile(args.path, 'utf8')
      const arr = text.split(/\s+/);
      const obj = arr.reduce((result, item) => {
        const [key, value] = item.split("=");
        result[key] = value;
        return result;
      }, {});

      return {
        contents: JSON.stringify(obj),
        loader: 'json',
      }
    })
  }
});

export default txtPlugin;
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

当使用 reduce() 方法时,你需要传递一个回调函数和一个初始值作为参数。回调函数将在每次迭代中被调用,并接收四个参数:累积值(也称为结果值)、当前元素、当前索引和原始数组。

array.reduce(callback, initialValue);
成功
1
  • callback:回调函数,用于对每个元素执行操作。它接收四个参数:
    • accumulator:累积值,即上一次回调函数的返回值或初始值。
    • currentValue:当前元素。
    • currentIndex(可选):当前元素的索引。
    • array(可选):原始数组。 回调函数应返回一个值,该值将作为下一次迭代的累积值。
  • initialValue:可选参数,作为第一次调用回调函数时的累积值(即第一个 accumulator 的值)。

当然我们其实可以onResovleonLoad结合使用

import fs from "fs/promises";
import path from "path";

const txtPlugin = () => ({
  name: 'txt-plugin',
 
  setup(build) {
    build.onResolve({ filter: /\.txt$/ }, (args) => {
      const basePath = path.join(args.resolveDir, args.path)
      console.log(basePath)
      return {
        path: basePath,
        namespace: 'txt-ns',
      }
    })
    build.onLoad({ filter: /.*/, namespace: 'txt-ns' }, async (args) => {
      let text = await fs.readFile(args.path, 'utf8')
      console.log(text);
      const arr = text.split(/\s+/);
      const contents = arr.reduce((result, item) => {
        const [key, value] = item.split("=");
        result[key] = value;
        return result;
      }, {});
      console.log(contents);
      return {
        contents: JSON.stringify(contents),
        loader: 'json',
      }
    })
  }
  
});

export default txtPlugin;
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

# 读取markdown文件内容

我们知道markdown文件格式的内容,可以轻松的转换成HTML内容

我们需要先引入markdown的转换插件库,marked (opens new window)

npm install marked
npm install @types/marked //如果需要TS支持的话
成功
1
2
import path from "path";
import fs from "fs/promises";
import {marked} from "marked";

const markdownPlugin = () => ({ 
  name: 'markdown-plugin',
  setup(build) { 
    build.onResolve({ filter: /\.md$/ }, (args) => {
      if (args.resolveDir === "") return;

      return {
        path: path.isAbsolute(args.path)
          ? args.path
          : path.join(args.resolveDir, args.path),
        namespace: "markdown"
      };
    });
    build.onLoad({ filter: /.*/, namespace: "markdown" }, async (args) => {
      const markdownContent = await fs.readFile(args.path, "utf8")

      const markdownHTML = marked.parse(markdownContent);

      return {
        contents: JSON.stringify({
          html: markdownHTML,
          raw: markdownContent,
          filename: path.basename(args.path)
        }),
        loader: "json"
      };
    });
  }
})

export default markdownPlugin
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

我们可以直接在tsx的界面中使用

import markdown from "../assets/readme.md";

// jsx
<div dangerouslySetInnerHTML={{ __html: markdown.html }}></div>
成功
1
2
3
4

ts声明

declare module '*.md' {
  const content: {
    html: string,
    raw: string,
    filename: string
  }
  export default content
}
成功
1
2
3
4
5
6
7
8