使用 ESBuild 插件机制实现需要的功能

本文最后更新于:2022年5月20日 晚上

前言

esbuild 是一个通用的代码编译器和构建工具,使用 golang 构建,它非常快,性能上比现有的 js 工具链高 1~2 个数量级。它目前还不是一个开箱即用的构建工具,但通过它的插件系统,我们已经可以做到许多事情。

1653052756694

自动排除所有依赖项

在构建 lib 时,我们通常都不希望捆绑依赖的模块,希望能够默认排除掉所有的依赖项,这个插件就是用来实现这个功能的。它会将所有不是以 . 开头的导入模块设置为外部的,避免捆绑到最终构建产物中。

import { Plugin } from "esbuild";

/**
 * 自动排除所有依赖项
 * golang 不支持 js 的一些正则表达式语法,参考 https://github.com/evanw/esbuild/issues/1634
 */
export function autoExternal(): Plugin {
  return {
    name: "autoExternal",
    setup(build) {
      build.onResolve({ filter: /.*/ }, (args) => {
        if (/^\.{1,2}\//.test(args.path)) {
          return;
        }
        return {
          path: args.path,
          external: true,
        };
      });
    },
  };
}

我们可以这样使用,例如 import esbuild,但它不会被捆绑进来。

import { build } from "esbuild";
console.log(build);

会被编译成

import { build } from "esbuild";
console.log(build);

使用环境变量

有时候我们会需要针对不同的环境使用环境变量来区分,而使用插件也可以很简单的做到这一点。

import { Plugin } from "esbuild";

/**
 * @param {string} str
 */
function isValidId(str: string) {
  try {
    new Function(`var ${str};`);
  } catch (err) {
    return false;
  }
  return true;
}

/**
 * Create a map of replacements for environment variables.
 * @return A map of variables.
 */
export function defineProcessEnv() {
  /**
   * @type {{ [key: string]: string }}
   */
  const definitions: Record<string, string> = {};
  definitions["process.env.NODE_ENV"] = JSON.stringify(
    process.env.NODE_ENV || "development"
  );
  Object.keys(process.env).forEach((key) => {
    if (isValidId(key)) {
      definitions[`process.env.${key}`] = JSON.stringify(process.env[key]);
    }
  });
  definitions["process.env"] = "{}";

  return definitions;
}

export function defineImportEnv() {
  const definitions: Record<string, string> = {};
  Object.keys(process.env).forEach((key) => {
    if (isValidId(key)) {
      definitions[`import.meta.env.${key}`] = JSON.stringify(process.env[key]);
    }
  });
  definitions["import.meta.env"] = "{}";
  return definitions;
}

/**
 * Pass environment variables to esbuild.
 * @return An esbuild plugin.
 */
export function env(options: { process?: boolean; import?: boolean }): Plugin {
  return {
    name: "env",
    setup(build) {
      const { platform, define = {} } = build.initialOptions;
      if (platform === "node") {
        return;
      }
      build.initialOptions.define = define;
      if (options.import) {
        Object.assign(build.initialOptions.define, defineImportEnv());
      }
      if (options.process) {
        Object.assign(build.initialOptions.define, defineProcessEnv());
      }
    },
  };
}

使用插件之后,我们就可以在代码中使用环境变量了

export const NodeEnv = import.meta.env.NODE_ENV;

编译结果

export const NodeEnv = "test";

在构建时输出日志

有时候我们希望以监视模式构建某些东西,但 esbuild 不会在构建后输出消息,我们简单实现一个。

import { Plugin, PluginBuild } from "esbuild";

export function log(): Plugin {
  return {
    name: "log",
    setup(builder: PluginBuild) {
      let start: number;
      builder.onStart(() => {
        start = Date.now();
      });
      builder.onEnd((result) => {
        if (result.errors.length !== 0) {
          console.error("build failed", result.errors);
          return;
        }
        console.log(`build complete, time ${Date.now() - start}ms`);
      });
    },
  };
}

我们可以测试它是有效的

const mockLog = jest.fn();
jest.spyOn(global.console, "log").mockImplementation(mockLog);
await build({
  stdin: {
    contents: `export const name = 'liuli'`,
  },
  plugins: [log()],
  write: false,
});
expect(mockLog.mock.calls.length).toBe(1);

自动排除 node: 开头的依赖

有时候某些依赖的模块使用了 nodejs 的原生模块,但是以 node: 开头的的写法,这会导致 esbuild 无法识别,我们使用以下插件处理它

import { Plugin } from "esbuild";

/**
 * 排除和替换 node 内置模块
 */
export function nodeExternal(): Plugin {
  return {
    name: "nodeExternals",
    setup(build) {
      build.onResolve({ filter: /(^node:)/ }, (args) => ({
        path: args.path.slice(5),
        external: true,
      }));
    },
  };
}

我们的以下代码中的 node: 开头的原生模块会被排除

import { path } from "node:path";
console.log(path.resolve(__dirname));

编译结果

// <stdin>
import { path } from "path";
console.log(path.resolve(__dirname));

通过 ?raw 捆绑文本文件

如果使用过 vite 的话,可能会对它的 ?* 功能印象深刻,它提供了各种各样的功能来以不同的方式导入文件,而在 esbuild 中,我们有时候也希望静态捆绑某些内容,例如 readme 文件。

import { Plugin } from "esbuild";
import { readFile } from "fs-extra";
import * as path from "path";

/**
 * 通过 ?raw 将资源作为字符串打包进来
 * @returns
 */
export function raw(): Plugin {
  return {
    name: "raw",
    setup(build) {
      build.onResolve({ filter: /\?raw$/ }, (args) => {
        return {
          path: path.isAbsolute(args.path)
            ? args.path
            : path.join(args.resolveDir, args.path),
          namespace: "raw-loader",
        };
      });
      build.onLoad(
        { filter: /\?raw$/, namespace: "raw-loader" },
        async (args) => {
          return {
            contents: await readFile(args.path.replace(/\?raw$/, "")),
            loader: "text",
          };
        }
      );
    },
  };
}

通过以下方式验证

const res = await build({
  stdin: {
    contents: `
        import readme from '../../README.md?raw'
        console.log(readme)
      `,
    resolveDir: __dirname,
  },
  plugins: [raw()],
  bundle: true,
  write: false,
});
console.log(res.outputFiles[0].text);
expect(
  res.outputFiles[0].text.includes("@liuli-util/esbuild-plugins")
).toBeTruthy();

重写一些模块

有时候我们希望重写一些模块,例如将导入的 lodash 更改为 lodash-es 以实现 tree shaking,这时候我们可以通过以下插件办到这件事

import { build, Plugin } from "esbuild";
import path from "path";

/**
 * 将指定的 import 重写为另一个
 * @param entries
 * @returns
 */
export function resolve(entries: [from: string, to: string][]): Plugin {
  return {
    name: "resolve",
    setup(build) {
      build.onResolve({ filter: /.*/ }, async (args) => {
        const findEntries = entries.find((item) => item[0] === args.path);
        if (!findEntries) {
          return;
        }
        return await build.resolve(findEntries[1]);
      });
    },
  };
}

我们可以使用以下配置将 lodash 替换为 lodash-es

build({
  plugins: [resolve([["lodash", "lodash-es"]])],
});

源代码

import { uniq } from "lodash";
console.log(uniq([1, 2, 1]));

编译结果

import { uniq } from "lodash-es";
console.log(uniq([1, 2, 1]));

强制指定模块没有副作用

当我们的使用一个第三方的包时,有可能这个包依赖了一些其他的模块,如果这个模块没有声明 sideEffect,那么即便它没有副作用并且导出了 esm 的包,也会将依赖的模块 bundle 进来,但我们可以使用插件 api 强制指定模块没有副作用。

import { Plugin } from "esbuild";

/**
 * 设置指定模块为没有副作用的包,由于 webpack/esbuild 的配置不兼容,所以先使用插件来完成这件事
 * @param packages
 * @returns
 */
export function sideEffects(packages: string[]): Plugin {
  return {
    name: "sideEffects",
    setup(build) {
      build.onResolve({ filter: /.*/ }, async (args) => {
        if (
          args.pluginData || // Ignore this if we called ourselves
          !packages.includes(args.path)
        ) {
          return;
        }

        const { path, ...rest } = args;
        rest.pluginData = true; // Avoid infinite recursion
        const result = await build.resolve(path, rest);

        result.sideEffects = false;
        return result;
      });
    },
  };
}

我们以如下方式使用它

build({
  plugins: [sideEffects(["lib"])],
});

这时候,即便 lib-a 中某些代码依赖了 lib-b,但只要你的代码没有依赖到特定方法,那么它就会被正确的 tree shaking

例如下面的代码

// main.ts
import { hello } from "lib-a";
console.log(hello("liuli"));
// lib-a/src/index.ts
export * from "lib-b";
export function hello(name: string) {
  return `hello ${name}`;
}

编译结果

// dist/main.js
function hello(name: string) {
  return `hello ${name}`;
}
console.log(hello("liuli"));

总结

目前已经实现了 esbuild 的许多插件,但它作为构建应用的基础构建工具仍然稍显薄弱,目前仅推荐使用它构建一些纯 JavaScript/TypeScript 的代码。如果需要构建完整的 web 应用,那么 vite 可能是目前基于 esbuild 的最成熟的构建工具。


使用 ESBuild 插件机制实现需要的功能
https://blog.rxliuli.com/p/ba9b341a8792405fb86d8fe02a18adfc/
作者
rxliuli
发布于
2022年5月20日
许可协议