esbuild 是 Evan Wallace(Figma 的联合创始人)用 Go 语言写的 JavaScript/TypeScript 打包器。它的速度快到什么程度?官方给的数据是比 Webpack 快 10-100 倍。
这个数字不是吹的。在一个中等规模的项目里,Webpack 需要 30 秒完成的构建,esbuild 可能 300 毫秒就搞定了。Vite 的开发服务器之所以启动飞快,底层就是用 esbuild 做依赖预构建。
为什么这么快#
esbuild 快的原因不是什么黑魔法,而是做对了几件事:
用 Go 而不是 JavaScript#
JavaScript 是单线程的,虽然有 Worker,但进程间通信开销大。Go 天生支持并发,可以充分利用多核 CPU。esbuild 的解析、转换、代码生成都是并行执行的。
从零开始写,没有历史包袱#
Webpack 的架构是十年前设计的,为了兼容各种场景,代码路径非常复杂。esbuild 没有这些负担,可以针对现代 JavaScript 生态做极致优化。
尽量少做 AST 遍历#
传统打包器的流程是:解析代码生成 AST → 各种插件遍历 AST 做转换 → 生成代码。每个插件都要遍历一遍,效率很低。
esbuild 把常用的转换(TypeScript、JSX、压缩等)全部内置,一次遍历搞定所有事情。
内存布局优化#
esbuild 对内存的使用非常克制,数据结构紧凑,GC 压力小。这在处理大型项目时效果明显。
安装与基本使用#
# 安装npm install esbuild --save-dev
# 或者全局安装npm install -g esbuild最简单的用法,打包一个文件:
# 打包 app.js,输出到 out.jsesbuild app.js --bundle --outfile=out.js常用的命令行参数:
# 开启压缩esbuild app.js --bundle --minify --outfile=out.js
# 生成 source mapesbuild app.js --bundle --sourcemap --outfile=out.js
# 指定目标环境esbuild app.js --bundle --target=es2020 --outfile=out.js
# 指定输出格式(esm/cjs/iife)esbuild app.js --bundle --format=esm --outfile=out.js
# 处理 JSXesbuild app.jsx --bundle --loader:.jsx=jsx --outfile=out.jsJavaScript API#
命令行适合简单场景,复杂项目还是用 API 更灵活。
同步构建#
const esbuild = require('esbuild')
const result = esbuild.buildSync({ entryPoints: ['src/index.js'], bundle: true, minify: true, sourcemap: true, target: ['es2020'], outfile: 'dist/bundle.js',})
console.log('构建完成')异步构建#
const esbuild = require('esbuild')
async function build() { const result = await esbuild.build({ entryPoints: ['src/index.js'], bundle: true, minify: true, sourcemap: true, outdir: 'dist', splitting: true, // 代码分割,需要 format: 'esm' format: 'esm', })
console.log('构建完成', result)}
build()监听模式#
const esbuild = require('esbuild')
async function watch() { const ctx = await esbuild.context({ entryPoints: ['src/index.js'], bundle: true, outdir: 'dist', })
// 开启监听 await ctx.watch() console.log('监听文件变化中...')
// 需要停止时调用 // await ctx.dispose()}
watch()开发服务器#
esbuild 内置了一个简单的开发服务器:
const esbuild = require('esbuild')
async function serve() { const ctx = await esbuild.context({ entryPoints: ['src/index.js'], bundle: true, outdir: 'public', })
const { host, port } = await ctx.serve({ servedir: 'public', port: 3000, })
console.log(`服务器运行在 http://${host}:${port}`)}
serve()这个服务器比较简陋,没有 HMR,只有 Live Reload。生产项目建议用 Vite。
常用配置项详解#
entryPoints - 入口文件#
// 单入口entryPoints: ['src/index.js']
// 多入口entryPoints: ['src/index.js', 'src/admin.js']
// 指定输出名称entryPoints: { main: 'src/index.js', admin: 'src/admin.js',}loader - 文件类型处理#
esbuild 通过 loader 来处理不同类型的文件:
{ loader: { '.js': 'jsx', // 把 .js 文件当作 JSX 处理 '.png': 'file', // 图片输出为文件 '.svg': 'text', // SVG 作为文本导入 '.json': 'json', // JSON 文件 '.css': 'css', // CSS 文件 }}内置的 loader 类型:
| Loader | 说明 |
|---|---|
| js | JavaScript |
| jsx | JSX |
| ts | TypeScript |
| tsx | TypeScript + JSX |
| json | JSON 文件 |
| css | CSS 文件 |
| text | 作为字符串导入 |
| file | 输出为文件,返回 URL |
| dataurl | 转为 Data URL |
| binary | 作为 Uint8Array 导入 |
| base64 | 作为 Base64 字符串导入 |
| copy | 复制文件,不做处理 |
define - 全局常量替换#
{ define: { 'process.env.NODE_ENV': '"production"', '__DEV__': 'false', 'API_URL': '"https://api.example.com"', }}注意值需要是 JavaScript 表达式的字符串形式,所以字符串要用引号包裹。
external - 排除依赖#
{ external: [ 'react', 'react-dom', // 排除所有 node_modules './node_modules/*', ]}被排除的依赖不会被打包,而是保留 import/require 语句。
alias - 路径别名#
{ alias: { '@': './src', '@components': './src/components', '@utils': './src/utils', }}target - 目标环境#
// 指定 ES 版本target: ['es2020']
// 指定浏览器版本target: ['chrome80', 'firefox78', 'safari14']
// 指定 Node 版本target: ['node16']esbuild 会根据目标环境自动转换语法,比如把箭头函数转成普通函数。
代码分割#
{ entryPoints: ['src/index.js', 'src/admin.js'], bundle: true, splitting: true, // 开启代码分割 format: 'esm', // 必须是 esm 格式 outdir: 'dist', // 必须用 outdir,不能用 outfile chunkNames: 'chunks/[name]-[hash]', // 分割出的 chunk 命名规则}插件开发#
esbuild 的插件 API 相对简单,主要是两个钩子:onResolve 和 onLoad。
基本结构#
const myPlugin = { name: 'my-plugin', setup(build) { // 解析模块路径时触发 build.onResolve({ filter: /.*/ }, (args) => { console.log('解析:', args.path) return null // 返回 null 表示不处理 })
// 加载模块内容时触发 build.onLoad({ filter: /.*/ }, (args) => { console.log('加载:', args.path) return null }) },}
// 使用插件esbuild.build({ entryPoints: ['src/index.js'], bundle: true, plugins: [myPlugin], outfile: 'dist/bundle.js',})实战:处理 YAML 文件#
const yaml = require('js-yaml')const fs = require('fs')
const yamlPlugin = { name: 'yaml', setup(build) { build.onLoad({ filter: /\.ya?ml$/ }, async (args) => { const content = await fs.promises.readFile(args.path, 'utf8') const data = yaml.load(content)
return { contents: `export default ${JSON.stringify(data)}`, loader: 'js', } }) },}实战:环境变量注入#
const envPlugin = { name: 'env', setup(build) { build.onResolve({ filter: /^env$/ }, (args) => ({ path: args.path, namespace: 'env-ns', }))
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({ contents: JSON.stringify(process.env), loader: 'json', })) },}
// 使用// import env from 'env'// console.log(env.NODE_ENV)实战:Vue SFC 简易处理#
const vuePlugin = { name: 'vue', setup(build) { build.onLoad({ filter: /\.vue$/ }, async (args) => { const source = await fs.promises.readFile(args.path, 'utf8')
// 简单解析,实际需要用 @vue/compiler-sfc const templateMatch = source.match(/<template>([\s\S]*)<\/template>/) const scriptMatch = source.match(/<script>([\s\S]*)<\/script>/)
const template = templateMatch ? templateMatch[1] : '' const script = scriptMatch ? scriptMatch[1] : 'export default {}'
// 这只是示例,实际要复杂得多 const code = ` ${script.replace('export default', 'const __component__ =')} __component__.template = \`${template}\` export default __component__ `
return { contents: code, loader: 'js' } }) },}与 TypeScript 配合#
esbuild 原生支持 TypeScript,但有些细节要注意。
tsconfig.json 支持#
esbuild 只读取部分 tsconfig 选项:
{ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "jsx": "react-jsx", "jsxImportSource": "react", "baseUrl": ".", "paths": { "@/*": ["src/*"] } }}esbuild 会识别 jsx、jsxFactory、jsxFragmentFactory、useDefineForClassFields、importsNotUsedAsValues 等选项。
但 paths 别名需要通过 esbuild 的 alias 配置,或者用 esbuild-plugin-alias 插件。
类型检查#
esbuild 不做类型检查,它只是把 TypeScript 语法剥掉。类型检查需要单独跑 tsc --noEmit:
{ "scripts": { "build": "npm run typecheck && esbuild src/index.ts --bundle --outfile=dist/bundle.js", "typecheck": "tsc --noEmit" }}装饰器#
esbuild 不支持旧版装饰器语法(TypeScript 的 experimentalDecorators)。如果项目用了装饰器(比如 NestJS),需要用 SWC 或 Babel 先转换一遍。
与 React 配合#
JSX 配置#
{ loader: { '.js': 'jsx', '.ts': 'tsx' }, jsx: 'automatic', // 使用新版 JSX 转换,不需要手动 import React jsxDev: process.env.NODE_ENV === 'development', // 开发模式添加调试信息}如果用的是老项目:
{ jsx: 'transform', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment',}完整的 React 项目配置#
const esbuild = require('esbuild')const { copy } = require('esbuild-plugin-copy')
const isDev = process.env.NODE_ENV === 'development'
async function build() { const ctx = await esbuild.context({ entryPoints: ['src/index.tsx'], bundle: true, minify: !isDev, sourcemap: isDev, target: ['es2020'], format: 'esm', splitting: true, outdir: 'dist', jsx: 'automatic', define: { 'process.env.NODE_ENV': JSON.stringify( process.env.NODE_ENV || 'development' ), }, plugins: [ copy({ assets: [{ from: './public/*', to: './' }], }), ], })
if (isDev) { await ctx.watch() const { port } = await ctx.serve({ servedir: 'dist', port: 3000 }) console.log(`开发服务器: http://localhost:${port}`) } else { await ctx.rebuild() await ctx.dispose() console.log('构建完成') }}
build()性能优化技巧#
减少入口点#
每个入口点都会触发一次完整的依赖分析。如果可能,把多个入口合并:
// 不太好entryPoints: ['page1.js', 'page2.js', 'page3.js', ...]
// 如果它们共享很多依赖,考虑用动态导入entryPoints: ['main.js']// main.js 里动态导入各个页面排除大型依赖#
像 lodash、moment 这种体积大的库,可以用 CDN 引入:
{ external: ['lodash', 'moment'], // 然后在 HTML 里用 script 标签引入 CDN}使用增量构建#
开发时用 context API 和 watch,esbuild 会缓存中间结果:
const ctx = await esbuild.context(options)await ctx.watch()// 后续的重新构建会很快并行处理多个构建#
如果有多个独立的构建任务,可以并行执行:
await Promise.all([ esbuild.build({ entryPoints: ['client.js'], outfile: 'dist/client.js' }), esbuild.build({ entryPoints: ['server.js'], outfile: 'dist/server.js', platform: 'node', }), esbuild.build({ entryPoints: ['worker.js'], outfile: 'dist/worker.js' }),])当前限制#
esbuild 追求速度,牺牲了一些功能:
不支持的特性#
- HMR(热模块替换):只有 Live Reload,没有状态保持的 HMR
- 旧版装饰器:不支持 TypeScript 的 experimentalDecorators
- CSS Modules 的
:部分语法不支持 - 某些 Babel 插件的功能:比如 macros
插件生态#
相比 Webpack 的海量插件,esbuild 的插件生态还在发展中。常用的都有,但冷门需求可能要自己写。
生产环境使用#
esbuild 可以用于生产,但很多团队选择:
- 开发环境用 esbuild(或 Vite)
- 生产环境用 Rollup(更成熟的 tree shaking 和代码分割)
与其他工具对比#
| 对比项 | esbuild | Webpack | Rollup | Vite |
|---|---|---|---|---|
| 语言 | Go | JavaScript | JavaScript | JavaScript |
| 构建速度 | 极快 | 慢 | 中等 | 快(用 esbuild) |
| HMR | 无 | 有 | 需插件 | 有 |
| 插件生态 | 发展中 | 非常丰富 | 丰富 | 丰富 |
| 配置复杂度 | 简单 | 复杂 | 中等 | 简单 |
| 适用场景 | 库、简单应用 | 复杂应用 | 库 | 现代应用 |
常见问题#
1. 构建后引入路径错误#
检查 publicPath 配置:
{ publicPath: '/assets/',}2. 第三方库报错#
有些库没有正确的 ESM 导出,用 mainFields 调整:
{ mainFields: ['module', 'main'], conditions: ['import', 'module'],}3. 动态 import 不工作#
确保用了 splitting: true 和 format: 'esm':
{ splitting: true, format: 'esm', outdir: 'dist', // 必须用 outdir}4. Node.js 内置模块报错#
打包给浏览器用时,需要排除或 polyfill Node 模块:
{ platform: 'browser', external: ['fs', 'path', 'crypto'],}