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

本文最后更新于:2022年5月20日 下午

前言

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

1653052756694

自动排除所有依赖项

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

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

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

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

会被编译成

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

使用环境变量

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

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

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

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

编译结果

1
export const NodeEnv = 'test'

在构建时输出日志

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

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

我们可以测试它是有效的

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)

自动排除 node: 开头的依赖

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

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

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

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

编译结果

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

通过 ?raw 捆绑文本文件

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

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

通过以下方式验证

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()

重写一些模块

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

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

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

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

源代码

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

编译结果

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

强制指定模块没有副作用

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

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

我们以如下方式使用它

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

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

例如下面的代码

1
2
3
// main.ts
import { hello } from 'lib-a'
console.log(hello('liuli'))
1
2
3
4
5
// lib-a/src/index.ts
export * from 'lib-b'
export function hello(name: string) {
return `hello ${name}`
}

编译结果

1
2
3
4
5
// 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日
许可协议