Preface

esbuild is a general purpose code compiler and build tool that uses golang builds, it is very fast and 1~2 orders of magnitude higher in performance than the existing js toolchain. It is not yet an out-of-the-box build tool, but with its plugin system we can already do many things.

esbuild

Automatically exclude all dependencies

When building libs, we usually don’t want to bundle dependent modules and want to exclude all dependencies by default, and this plugin is designed to do that. It will set all imported modules that do not start with . to avoid bundling into the final build.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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,
        };
      });
    },
  };
}

We could use it this way, for example import esbuild, but it would not be bundled in.

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

will be compiled as:

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

Using Environment Variables

Sometimes we will need to use environment variables for different environments to differentiate them, and it is easy to do this using plugins.

 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
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());
      }
    },
  };
}

After using the plugin, we can use environment variables in our code.

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

The result of the compilation.

1
export const NodeEnv = "test";

Output logs at build time

Sometimes we want to build something in watch mode, but esbuild won’t output a message after the build, so we simply implement one.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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`);
      });
    },
  };
}

We can test that it works.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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);

Automatically exclude node: prefix dependencies

Sometimes some dependencies use nodejs native modules, but are written with node: prefixes, which will not be recognized by esbuild, we use the following plugin to handle it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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,
      }));
    },
  };
}

The native modules starting with node: in our code below will be excluded.

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

The result of the compilation.

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

Binding text files via ?raw

If you have used vite, you may be impressed by its ?* feature, which provides a variety of functions to import files in different ways, and in esbuild, we sometimes want to statically bundle certain content, such as readme files.

1
2
3
import { Plugin } from "esbuild";
import { readFile } from "fs-extra";
import * as path from "path";
 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
/**
 * 通过 ?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",
          };
        }
      );
    },
  };
}

Verify by the following.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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();

Rewriting some modules

Sometimes we want to rewrite some modules, such as changing the imported lodash to lodash-es for tree shaking, and we can do this with the following plugin.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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]);
      });
    },
  };
}

We can replace lodash with lodash-es using the following configuration.

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

Source Code.

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

The result of the compilation.

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

Force specified modules to have no side effects

When we use a third-party package, it is possible that the package depends on some other module. If the module does not declare sideEffect, then even if it has no side effects and exports the esm package, it will bundle in the dependent module, but we can use the plugin api to force the specified module to have no side effects.

 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
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;
      });
    },
  };
}

We use it in the following way.

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

At this point, even though some code in lib-a depends on lib-b, as long as your code does not depend on a specific method, then it will be tree shaking correctly.

For example, the following code.

1
2
3
4
5
6
7
8
// 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}`;
}

The result of the compilation.

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

Summary

Many plugins for esbuild have been implemented, but it is still a bit weak as a basic build tool for building applications, and it is currently only recommended for building pure JavaScript/TypeScript code. If you need to build a complete web application, then vite is probably the most mature build tool based on esbuild.