本文最后更新于:2022年5月20日 下午
前言
esbuild 是一个通用的代码编译器和构建工具,使用 golang 构建,它非常快,性能上比现有的 js 工具链高 1~2 个数量级。它目前还不是一个开箱即用的构建工具,但通过它的插件系统,我们已经可以做到许多事情。
自动排除所有依赖项
在构建 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'
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'
function isValidId(str: string) { try { new Function(`var ${str};`) } catch (err) { return false } return true }
export function defineProcessEnv() {
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 }
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'
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
| 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'
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'
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'
export function sideEffects(packages: string[]): Plugin { return { name: 'sideEffects', setup(build) { build.onResolve({ filter: /.*/ }, async (args) => { if ( args.pluginData || !packages.includes(args.path) ) { return }
const { path, ...rest } = args rest.pluginData = true 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
| import { hello } from 'lib-a' console.log(hello('liuli'))
|
1 2 3 4 5
| export * from 'lib-b' export function hello(name: string) { return `hello ${name}` }
|
编译结果
1 2 3 4 5
| function hello(name: string) { return `hello ${name}` } console.log(hello('liuli'))
|
总结
目前已经实现了 esbuild 的许多插件,但它作为构建应用的基础构建工具仍然稍显薄弱,目前仅推荐使用它构建一些纯 JavaScript/TypeScript 的代码。如果需要构建完整的 web 应用,那么 vite 可能是目前基于 esbuild 的最成熟的构建工具。