Using the ESBuild plug-in mechanism to achieve the desired functionality
Tags: javascript, esbuild
Published: 2022-05-20 15:04:27

## Preface

[esbuild](https://esbuild.github.io/) 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's not an out-of-the-box build tool yet, but with its plugin system we can already do a lot of things.

![1653052756694](https://blog.rxliuli.com/resource/006b4700843a4237a22056b3bd29a742.png)

## Automatically exclude all dependencies

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

```ts
import { Plugin } from 'esbuild'

/**
 * Automatically exclude all dependencies
 * Some regular expression syntax of js is not supported by golang, see 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.

```ts
import { build } from 'esbuild'
console.log(build)
```

will be compiled into

```js
import { build } from 'esbuild'
console.log(build)
```

## Using environment variables

Sometimes we need to use environment variables for different environments to differentiate between them, and it's easy to do that with plugins.

```ts
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

```ts
export const NodeEnv = import.meta.env.NODE_ENV
```

Compile the result

```js
export const NodeEnv = 'test'
```

## Exporting 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.

```ts
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

```ts
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 dependencies starting with node:

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

```ts
import { Plugin } from 'esbuild'

/**
 * Exclude and replace node built-in modules
 */
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 following code will be excluded

```ts
import { path } from 'node:path'
console.log(path.resolve(__dirname))
```

Compilation results

```js
// <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.

```ts
import { Plugin } from 'esbuild'
import { readFile } from 'fs-extra'
import * as path from 'path'

/**
 * Package resources as strings via ?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

```ts
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

```ts
import { build, Plugin } from 'esbuild'
import path from 'path'

/**
 * Rewrite the specified import as another
 * @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

```ts
build({
  plugins: [resolve([['lodash', 'lodash-es']])],
})
```

Source code

```ts
import { uniq } from 'lodash'
console.log(uniq([1, 2, 1]))
```

Compile the result

```js
import { uniq } from 'lodash-es'
console.log(uniq([1, 2, 1]))
```

## Forcing a module to be specified has 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.

```ts
import { Plugin } from 'esbuild'

/**
 * Set the specified module to be a package with no side effects, and since the webpack/esbuild configuration is not compatible, use the plugin to do this first
 * @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

```ts
build({
  plugins: [sideEffects(['lib'])],
})
```

In this case, even if 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

```ts
// main.ts
import { hello } from 'lib-a'
console.log(hello('lili'))
```

```ts
// lib-a/src/index.ts
export * from 'lib-b'
export function hello(name: string) {
  return `hello ${name}`
}
```

Compile the result

```js
// 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 to build some 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.