Using the ESBuild plug-in mechanism to achieve the desired functionality
rxliuli
Posted on May 20, 2022
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.
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,
}
})
},
}
}
We could use it this way, for example import esbuild, but it would not be bundled in.
import { build } from 'esbuild'
console.log(build)
will be compiled into
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.
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
export const NodeEnv = import.meta.env.NODE_ENV
Compile the result
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.
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
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
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
import { path } from 'node:path'
console.log(path.resolve(__dirname))
Compilation results
// <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.
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
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
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
build({
plugins: [resolve([['lodash', 'lodash-es']])],
})
Source code
import { uniq } from 'lodash'
console.log(uniq([1, 2, 1]))
Compile the result
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.
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
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
// main.ts
import { hello } from 'lib-a'
console.log(hello('lili'))
// lib-a/src/index.ts
export * from 'lib-b'
export function hello(name: string) {
return `hello ${name}`
}
Compile the result
// dist/main.js
function hello(name: string) {
return hello </span><span class="p">${</span><span class="nx">name</span><span class="p">}</span><span class="s2">
}
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.
Posted on May 20, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 15, 2022