DEV Community

rxliuli
rxliuli

Posted on

Using the ESBuild plug-in mechanism to achieve the desired functionality

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's not an out-of-the-box build tool yet, but with its plugin system we can already do a lot of things.

1653052756694

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.

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,
        }
      })
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

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

import { build } from 'esbuild'
console.log(build)
Enter fullscreen mode Exit fullscreen mode

will be compiled into

import { build } from 'esbuild'
console.log(build)
Enter fullscreen mode Exit fullscreen mode

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.

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())
      }
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

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

export const NodeEnv = import.meta.env.NODE_ENV
Enter fullscreen mode Exit fullscreen mode

Compile the result

export const NodeEnv = 'test'
Enter fullscreen mode Exit fullscreen mode

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.

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`)
      })
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

We can test that it works

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)
Enter fullscreen mode Exit fullscreen mode

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

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,
      }))
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

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

import { path } from 'node:path'
console.log(path.resolve(__dirname))
Enter fullscreen mode Exit fullscreen mode

Compilation results

// <stdin>
import { path } from 'path'
console.log(path.resolve(__dirname))
Enter fullscreen mode Exit fullscreen mode

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.

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',
          }
        },
      )
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

Verify by

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()
Enter fullscreen mode Exit fullscreen mode

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

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])
      })
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

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

build({
  plugins: [resolve([['lodash', 'lodash-es']])],
})
Enter fullscreen mode Exit fullscreen mode

Source code

import { uniq } from 'lodash'
console.log(uniq([1, 2, 1]))
Enter fullscreen mode Exit fullscreen mode

Compile the result

import { uniq } from 'lodash-es'
console.log(uniq([1, 2, 1]))
Enter fullscreen mode Exit fullscreen mode

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.

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
      })
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

We use it in the following way

build({
  plugins: [sideEffects(['lib'])],
})
Enter fullscreen mode Exit fullscreen mode

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

// main.ts
import { hello } from 'lib-a'
console.log(hello('lili'))
Enter fullscreen mode Exit fullscreen mode
// lib-a/src/index.ts
export * from 'lib-b'
export function hello(name: string) {
  return `hello ${name}`
}
Enter fullscreen mode Exit fullscreen mode

Compile the result

// dist/main.js
function hello(name: string) {
  return `hello ${name}`
}
console.log(hello('liuli'))
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)