用esbuild实现前端构建速度飞跃的实战经验分享
先看效果,再看代码
我最近接手了一个老项目,构建时间从原来的十几秒飙到了快一分钟。Webpack 那套配置已经复杂到没人敢动,每次改点东西都得祈祷别出问题。直到我试了 esbuild —— 你猜怎么着?构建时间直接干到了 800ms。不是优化后的 Webpack,就是原生 esbuild,啥插件都没加。当时我就一个念头:早用这玩意儿能少熬多少夜。
下面这个是最基础的打包命令,我建议你直接复制跑一遍感受下速度:
npx esbuild src/index.js --bundle --format=esm --outdir=dist
就这么一行,搞定打包、模块转换、输出目录。不需要写配置文件,不用搞什么 webpack.config.js 写一页纸。esbuild 的 CLI 就是这么暴力直接。
如果你习惯写配置文件,也可以整一个 build.js:
const esbuild = require('esbuild');
esbuild.build({
entryPoints: ['src/index.js'],
bundle: true,
format: 'esm',
outdir: 'dist',
sourcemap: true,
minify: true,
}).catch(() => process.exit(1));
运行方式也很简单:
node build.js
亲测有效,而且比 CLI 更容易加逻辑。比如你想根据不同环境输出不同格式,直接在 JS 里判断就行,比写一堆 npm scripts 干净多了。
这个场景最好用
说实话,esbuild 最让我爽的地方不是打包应用,而是处理那些“小而碎”的构建需求。比如我现在有个需求:把一个工具函数单独打包成 UMD 格式,供别人通过 script 标签引入。
这种事用 Webpack 太重了,还得配 output.library、libraryTarget,一通操作下来半小时过去了。但 esbuild 几行代码搞定:
esbuild.build({
entryPoints: ['src/utils.js'],
bundle: true,
format: 'umd',
globalName: 'MyUtils',
outfile: 'dist/utils.js',
minify: true,
}).catch(() => process.exit(1));
打完之后生成的文件可以直接扔到 HTML 里用:
<script src="dist/utils.js"></script>
<script>
console.log(MyUtils.formatDate(new Date()));
</script>
注意这里的 globalName,它会决定你在全局挂载的对象名。我之前没设这个,结果默认挂到 window.exports 上去了,调用的时候一脸懵,折腾了半天才发现是配置漏了。
处理 CSS 也能行,但别指望太多
esbuild 原生支持导入 .css 文件,但它不会帮你做 PostCSS、autoprefixer 这些事情。比如你写了:
import './styles/main.css';
esbuild 会把这个文件当成字符串塞进 bundle,然后在运行时动态插入 style 标签。听起来不错,对吧?但问题来了 —— 它不支持 CSS Modules,也不支持 Sass/Less。
所以我的做法是:只让 esbuild 处理 JS/TS,CSS 单独用别的工具走一遍。如果非要用 esbuild 处理样式,可以试试社区插件,比如 esbuild-plugin-less,但稳定性一般,小项目可以玩,生产环境慎用。
如果你只是想简单合并几个 CSS 文件,可以用下面这种方式:
esbuild.build({
entryPoints: ['src/main.css'],
outfile: 'dist/bundle.css',
loader: { '.css': 'copy' },
});
不过 loader 的 copy 功能其实是实验性的,官方文档都没正式写进去。我踩过一次坑:路径别名 alias 在 copy 模式下不生效,最后只能手动 resolve 路径,挺麻烦的。
踩坑提醒:这三点一定注意
- Node.js 内置模块兼容问题:你在代码里用了
process.env.NODE_ENV或者Buffer,esbuild 默认不会给你 polyfill。打包出来在浏览器跑直接报错。解决方案有两个:一是自己定义 define:define: { 'process.env.NODE_ENV': '"production"', 'global': 'window', 'Buffer': 'buffer' }二是配合 external 把 node 内建模块排除,再手动引入 shim。我个人建议前者,简单粗暴。
- 动态 import() 的 chunk 名控制不了:你用
import(这种动态导入时,esbuild 会自动生成 chunk 文件名,比如./modules/${name}.js)chunk.BXK23OAE.js。没法像 Webpack 那样用魔法注释指定名字。这不是大问题,但如果要做资源预加载或者监控上报,解析文件名就有点难受。目前没有解法,只能接受。 - alias 别名必须写全,不然会找不到文件:比如你配置了:
alias: { '@': './src' }那你引用的时候必须写
@/utils,不能写@utils,连掉斜杠都不行。更坑的是,它不会报错,而是直接当成外部依赖处理,最后包里少了这个模块,运行时报 undefined。我在这里卡了将近两小时,以为是缓存问题,清了三四遍才意识到是 alias 写错了。
高级玩法:写个自己的构建脚本
我一直觉得,构建工具最怕的就是“配置即代码”。esbuild 允许你在 JS 里完全控制构建流程,这点特别适合写一些自动化任务。比如我现在每天要给内部系统打一个带版本号的包,我写了个简单的 build-with-version.js:
const esbuild = require('esbuild');
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const version = pkg.version;
esbuild.build({
entryPoints: ['src/index.js'],
bundle: true,
format: 'iife',
outfile: 'dist/bundle.js',
minify: true,
define: {
'__APP_VERSION__': "${version}",
'process.env.NODE_ENV': '"production"'
},
plugins: [{
name: 'version-inject',
setup(build) {
build.onLoad({ filter: /.js$/ }, async (args) => {
const contents = await fs.promises.readFile(args.path, 'utf8');
return {
contents: console.log('v${version}');n${contents},
loader: 'js'
};
});
}
}]
}).catch(() => process.exit(1));
你看这个 plugin,我在每个入口文件前面自动插入一行版本打印。虽然简单,但在排查线上问题时特别有用。以前都是手动改,现在一键搞定。而且这种脚本还能接 CI,push 一下自动打个带 commit hash 的包,省心得很。
插件机制虽然不如 Webpack 强大,但对于大多数轻量需求完全够用。尤其是 onLoad 和 onResolve 这两个钩子,能让你拦截文件读取和路径解析,做一些很骚的操作,比如 mock 接口数据:
plugins: [{
name: 'mock-api',
setup(build) {
build.onLoad({ filter: //api// }, () => ({
contents: export default { data: 'mocked', status: 200 },
loader: 'js'
}));
}
}]
这样所有请求 https://jztheme.com/api/user 的 import 都会被替换成 mock 数据,本地开发调试神器。当然,上线前记得关掉。
总结一下
esbuild 不是万能的,它不适合那种超复杂的前端工程,比如有几十个微前端、各种定制 loader 的项目。但如果你是中小型项目,或者想快速搭个 demo、工具库、内部系统,那它真的香到爆。
我现在的新项目基本都用 esbuild 打底,搭配一些轻量插件解决特定问题。复杂功能交给后端或服务端渲染,前端就专注快和稳。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流 —— 尤其是怎么优雅处理 CSS 的,我现在这方案还是太糙了。

暂无评论