esbuild 的速度让人很难拒绝,但真正决定它能否替代一套复杂构建流程的,不是速度,而是插件机制。多数项目一开始只需要 esbuild.build() 编译几个入口文件,但随着环境变量、自定义文件格式、平台差异逻辑慢慢出现,你就需要用自己的代码告诉 esbuild:某个模块路径该怎么解析,某类文件内容该怎么变成 JavaScript。

esbuild 的插件 API 并不复杂,但它和 webpack 插件的思路差别很大。如果带着 webpack 的经验来写,很容易在 resolve 和 load 的边界上迷路。这篇文章我会从两个最核心的钩子开始,用一个常见的 YAML 加载需求和一个虚拟模块需求,把 esbuild 插件开发的完整路径走一遍。
先理解两个钩子:onResolve 与 onLoad
esbuild 插件本质上是一组在构建阶段不同时机触发的回调。平时用最多的是 onResolve 和 onLoad:前者决定模块路径最终指向哪里,后者决定模块内容最终编译成什么。
一个最简单的插件长这样:
const myPlugin = {
name: 'my-plugin',
setup(build) {
build.onResolve({ filter: /^virtual:/ }, (args) => {
return { path: args.path, namespace: 'virtual' }
})
build.onLoad({ filter: /.*/, namespace: 'virtual' }, () => {
return { contents: 'export default 1', loader: 'js' }
})
},
}
await esbuild.build({
entryPoints: ['src/index.js'],
plugins: [myPlugin],
})
这段代码里,所有以 virtual: 开头的导入都会进入 virtual 命名空间。onLoad 收到这类模块后,直接返回一段 JS 字符串,esbuild 就会把它当成普通模块继续打包。
在继续之前,值得花一分钟看看几个钩子的定位:
| 钩子 | 触发时机 | 典型用途 |
|---|---|---|
| onStart | 构建开始时 | 清理产物目录、重置临时缓存 |
| onResolve | 解析每个模块路径时 | 自定义路径解析、虚拟模块、替换模块 |
| onLoad | 加载模块内容时 | 自定义文件加载器、注入代码内容 |
| onEnd | 构建结束时 | 输出构建结果、统计产物、写额外文件 |
很多教程会强调“onResolve 负责入口,onLoad 负责内容”,这基本准确。但有一点很容易被忽略:onLoad 默认也作用在 file 命名空间上。也就是说,如果某个文件已经被解析成磁盘上的绝对路径,你不需要写 onResolve,直接在 onLoad 里按扩展名拦截内容也是一种可行做法。
第一个实战:让 esbuild 直接加载 YAML
我在好几个项目里都遇到过同样的问题:希望 import config from './config.yaml' 能像导入 JS 模块一样直接使用,但 esbuild 默认不认识 .yaml,遇到它就会报 No loader is configured for ".yaml"。解决办法就是写一个插件,把 YAML 文件内容转换成 JSON。
const fs = require('fs')
const YAML = require('js-yaml')
const yamlPlugin = {
name: 'yaml-loader',
setup(build) {
build.onLoad({ filter: /[.]yaml$/ }, async (args) => {
const source = await fs.promises.readFile(args.path, 'utf8')
const data = YAML.load(source)
return {
contents: JSON.stringify(data),
loader: 'json',
}
})
},
}
这里没有写 onResolve,原因正如前面说的:esbuild 已经把 ./config.yaml 解析成了绝对路径,onLoad 的 args.path 直接就是文件路径。我们只需要把文件内容读出来,经过 YAML 解析后返回一段 JSON 字符串,并把 loader 指定为 json。这样 esbuild 就会把 JSON 转换成 ES module 导出。
如果你处理的不是 YAML,而是其他自定义格式,思路完全一样:读取文件、做转换、返回可被 esbuild 识别的 contents 和 loader。常见的 loader 有 js、json、text、css 等。
一个小提醒:onLoad 里最好使用异步文件读取,而不是
fs.readFileSync。esbuild 的插件执行是并行异步的,同步读取会阻塞整个构建进程,模块越多影响越明显。
第二个实战:用虚拟模块注入动态配置
大部分插件示例都会讲虚拟模块。原因很简单:esbuild 的模块系统默认基于文件系统,但现实工程里总有一些“不是文件”的模块存在。比如我想让多个业务模块共享同一个构建时生成的当前时间、Git 版本号或者环境变量,这时候直接写一个真实文件反而麻烦。
虚拟模块需要走完整的“路径解析 + 内容生成”流程,所以 onResolve 和 onLoad 都要写:
const runtimePlugin = {
name: 'runtime',
setup(build) {
build.onResolve({ filter: /^virtual:runtime$/ }, (args) => {
return {
path: args.path,
namespace: 'runtime',
}
})
build.onLoad({ filter: /.*/, namespace: 'runtime' }, () => {
const content = `export const buildTime = ${JSON.stringify(new Date().toISOString())}`
return { contents: content, loader: 'js' }
})
},
}
在这个例子里,其他模块只需要写 import { buildTime } from 'virtual:runtime',构建时 buildTime 就会被替换成当前时间。
需要特别注意的是:onResolve 返回的 namespace 必须和 onLoad 里的 namespace 一致。如果 onResolve 里没有显式设置 namespace,esbuild 会默认把 path 当作文件路径去解析;如果两个钩子的 namespace 不一致,模块会滞留在某个没有 loader 的命名空间里,报出 No loader is configured 之类的错误。
虚拟模块很适合做条件编译和配置注入,但它也有一个代价:构建输出里不会保留原来的 import 路径,调试时不能直接在 DevTools 里定位源码。如果虚拟模块内容逻辑复杂,我建议只生成“数据”,不要生成复杂函数。
进阶场景:按平台替换模块路径
另一个常见需求是:不同平台加载不同实现。比如代码里写 import platform from '#platform',构建时根据环境变量决定实际加载 platform.web.ts 还是 platform.node.ts。这类需求本质上是在“路径解析”阶段做替换:
const platformPlugin = {
name: 'platform',
setup(build) {
build.onResolve({ filter: /^#platform$/ }, (args) => {
const platform = process.env.PLATFORM || 'web'
return {
path: `./src/platform.${platform}.ts`,
resolveDir: args.resolveDir,
namespace: 'file',
}
})
},
}
注意这里返回了 resolveDir,否则 esbuild 不知道相对路径应该以哪个目录为基准。args.resolveDir 是当前导入者所在目录,把它传给返回结果后,esbuild 会继续基于这个目录去解析 ./src/platform.web.ts。
这个用法也可以用来实现按运行环境切换 mock 与真实接口。不过要控制好 filter 的匹配范围,避免把正常导入也拦截下来。
几个容易踩的坑
下面是写 esbuild 插件时最容易出问题的地方。
- filter 是正则,不是 glob。经常有人想匹配所有
.yaml文件,于是写filter: '*.yaml',然后插件毫无反应。filter 必须是一个RegExp,匹配的对象是模块路径字符串。 - onResolve 返回的 path 不一定要真实存在。如果设置了自定义 namespace,这个 path 只是身份标识,esbuild 不会去访问文件系统。反之,如果不设置 namespace,就必须保证返回的 path 可以被后续解析到。
- onLoad 里做大量同步操作会拖慢构建。虽然 esbuild 原生速度很快,但插件里的同步文件读取会阻塞整个进程。能用异步就用异步,能减少读取就减少读取。
- 插件顺序会影响结果。同一个 import 路径会按注册顺序询问每个插件的 onResolve,一旦某个插件返回了非空结果,后续插件就不会再收到这次解析请求。越精确的匹配,越应该放在前面。
- onStart 和 onEnd 不能替代 onLoad。这两个钩子只适合做外围工作,如果你试图在 onStart 里读取文件并修改模块内容,方向就错了。模块内容的修改只能发生在 onResolve/onLoad 阶段。
如果插件行为和你预期不一致,最简单的排查方式是加上 --log-level=debug 重建,观察 esbuild 打印出来的模块解析日志。插件回调里的 console.log(args.path) 也能帮你快速定位是哪个阶段被拦截了。
除这几点外,还有一个设计层面的建议:不要把业务逻辑揉进插件。插件最好只负责“路径到内容”的变换,具体的环境判断、内容过滤交给外部函数。这样后续无论是升级 esbuild 还是迁移构建工具,插件本身都不至于变成重写成本最高的部分。
该不该自己写插件:几个判断标准
不是项目一遇到构建问题就该立刻写插件。如果只是想把某个目录里的静态文件复制到产物目录,直接写一段 Node 脚本反而更直白。真正适合插件的是:功能需要和模块解析深度耦合,并且同一个逻辑会被多个入口反复触发。
我通常用下面的标准来判断:
| 方案 | 典型场景 | 维护成本 |
|---|---|---|
| esbuild 插件 | 自定义文件格式、虚拟模块、按平台替换 | 中等,但可复用 |
| Node 预构建脚本 | 复制静态资源、生成一次性文件 | 低,但容易失控 |
| 封装 esbuild.build() | 多项目复用同一套构建参数 | 视封装程度而定 |
另外,如果只是需要给输出文件头部或尾部加内容,esbuild 自带的 banner 和 footer 选项就能解决,完全没必要写插件。插件是用来解决“默认流程做不到”的事情的,不是用来包裹默认选项的。
写在最后
esbuild 插件的核心其实就一句话:用 onResolve 控制路径,用 onLoad 控制内容。理解这两个钩子,再结合 namespace 的语义,你就能覆盖绝大多数自定义构建需求。
所以,与其追求“写一个插件解决所有问题”,不如记住这个正确姿势:用 onResolve 控制路径,用 onLoad 控制内容,用 namespace 标明来源,把业务逻辑隔绝在插件之外。如果发现某个需求让插件变得越来越复杂,先回头看看是不是一开始的构建设计就选错了方向。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/673/