经过上一篇概念篇的阅读,相信大家对 Esbuild 已经有了初步的了解。然而,我们在使用 Esbuild 的时候难免会遇到一些需要加上自定义插件的场景,并且 Vite 依赖预编译的实现中大量应用了 Esbuild 插件的逻辑。因此,插件开发是 Esbuild 中非常重要的内容,
因此本章我将会使用,多个例子和实战案例,让大家熟悉使用 Esbuild 的插件开发。同样,动起手来,光读不练都是空谈。《文档地址》
插件是一个包含 name
和 setup
函数的对象,通过 build
API 的 plugins
数组注入:
const myPlugin = {
name: 'my-plugin',
setup(build) {
// 注册钩子
}
};
esbuild.build({ plugins: [myPlugin] });
setup
函数在每个构建中调用一次,接收 build
对象注册钩子。file
命名空间(文件系统)。插件可创建虚拟模块(如 config-ns),避免与物理文件冲突。filter: /^
config$/
)。onResolve
:解析模块路径(如重定向或标记命名空间)。onLoad
:加载模块内容(如生成虚拟模块)。该例子实现全局变量替换功能
import { serve } from 'esbuild';
// 添加全局配置插件
const configPlugin = {
name: 'config',
setup(build) {
build.onResolve({ filter: /^config$/ }, args => ({
path: args.path,
namespace: 'config-ns'
}));
build.onLoad({ filter: /.*/, namespace: 'config-ns' }, () => ({
contents: JSON.stringify({
apiBaseUrl: process.env.API_URL || 'https://api.example.com',
appVersion: '1.0.0'
}),
loader: 'js'
}));
}
};
function runBuild() {
serve(
{
... 基础配置 ...
},
{
// 添加新插件到插件数组
plugins: [envPlugin, configPlugin],
// ... 其他配置 ...
}
)
}
runBuild();
使用:
import config from 'config'
// 在代码中使用配置
console.log(config.apiBaseUrl) // 输出: https://api.example.com 或环境变量设置的值
这个插件实现了:
import config from 'config'
时触发onResolve
钩子 和 onLoad
钩子在 Esbuild 插件中,onResolve
和 onload
是两个非常重要的钩子,分别控制路径解析和模块内容加载的过程。
首先,我们来说说上面插件示例中的两个钩子该如何使用。
build.onResolve({ filter: /^config$/ }, args => ({
path: args.path,
namespace: 'config-ns'
}));
build.onLoad({ filter: /.*/, namespace: 'config-ns' }, () => ({
contents: JSON.stringify({
apiBaseUrl: process.env.API_URL || 'https://api.example.com',
appVersion: '1.0.0'
}),
loader: 'json'
}));
可以发现这两个钩子函数中都需要传入两个参数: Options
和 Callback
。
先说说Options
。它是一个对象,对于onResolve
和 onload
都一样,包含filter
和namespace
两个属性,类型定义如下:
interface Options {
filter: RegExp;
namespace?: string;
}
filter
为必传参数,是一个正则表达式,它决定了要过滤出的特征文件。
注意: 插件中的
filter
正则是使用 Go 原生正则实现的,为了不使性能过于劣化,规则应该尽可能严格。同时它本身和 JS 的正则也有所区别,不支持前瞻(?<=)、后顾(?=)和反向引用(\1)这三种规则。
namespace
为选填参数,一般在 onResolve
钩子中的回调参数返回namespace
属性作为标识,我们可以在onLoad
钩子中通过 namespace
将模块过滤出来。如上述插件示例就在onLoad
钩子通过config-ns
这个 namespace 标识过滤出了要处理的 config
模块。
除了 Options 参数,还有一个回调参数 Callback
,它的类型根据不同的钩子会有所不同。相比于 Options,Callback 函数入参和返回值的结构复杂得多,涉及很多属性。
在 onResolve 钩子中函数参数和返回值梳理如下:
参数对象结构
interface OnResolveArgs {
path: string; // 导入的路径(如 'react')
importer: string; // 发起导入的文件路径(如 'src/app.js')
namespace: string; // 父模块的命名空间(默认 'file')
resolveDir: string; // 基础解析目录(常与 importer 相同)
kind: string; // 导入类型('import-statement'/'require-call' 等)
pluginData: any; // 自定义透传数据
}
返回值关键字段
// 示例:将本地依赖重定向到 CDN
return {
path: 'https://esm.sh/react@18', // 新路径
namespace: 'http-url', // 自定义命名空间(必须!)
external: true, // 标记为外部依赖(不打包)
pluginData: { isCDN: true } // 向 onLoad 传递数据
};
返回值关键字段中 ,errors 和 warnings 会中断构建流程,需谨慎使用
狙个例子:
1.路径别名替换
build.onResolve({ filter: /^@utils\// }, (args) => ({
path: path.resolve(__dirname, 'src/utils/', args.path.replace('@utils/', '')),
}));
2.忽略特定模块
build.onResolve({ filter: /^ignored-module$/ }, () => ({
path: '', // 空路径
namespace: 'skip', // 跳过加载
}));
在 onLoad 钩子中函数参数和返回值梳理如下:
参数对象结构
interface OnLoadArgs {
path: string; // 解析后的路径
namespace: string; // 来自 onResolve 的命名空间
pluginData: any; // 从 onResolve 传递的数据
}
返回值关键字段
// 示例:动态生成配置文件
return {
contents: `export const API_KEY = '${process.env.API_KEY}';`, // 模块内容
loader: 'ts', // 指定加载器(ts/js/css/json...)
resolveDir: '/src', // 设置嵌套导入的基础路径
pluginData: { type: 'env' } // 向下游插件传递数据
};
举个例子
1.加载远程模块
build.onLoad({ filter: /.*/, namespace: 'http-url' }, async (args) => {
const res = await fetch(args.path);
return { contents: await res.text(), loader: 'js' };
});
2.转换文件类型
build.onLoad({ filter: /.custom$/, namespace: 'file' }, async (args) => {
const raw = await fs.promises.readFile(args.path, 'utf8');
return { contents: convertCustomToJS(raw), loader: 'js' };
});
钩子 | 触发时机 | 核心作用 | 返回值要求 |
---|---|---|---|
onResolve |
遇到 import /require 时 |
解析模块路径(定位文件) | 返回新的路径信息或修改解析行为 |
onLoad |
路径解析完成后 | 加载模块内容(读取+转换) | 返回文件内容及加载类型(loader ) |
Esbuild 插件中的 onStart
和 onEnd
钩子是构建流程的关键生命周期钩子,用于在构建开始前和结束后执行自定义逻辑。
onStart
钩子
const cleanupPlugin = {
name: 'cleanup',
setup(build) {
build.onStart(async () => {
// 清理旧构建产物
await fs.promises.rm('dist', { recursive: true, force: true });
console.log('构建目录已清理');
});
}
};
关键能力
1.全局初始化
build.onStart(() => {
global.buildId = generateUUID(); // 生成唯一构建ID
});
2.依赖预检查
build.onStart(async () => {
if (!fs.existsSync('node_modules')) {
throw new Error('请先运行 npm install');
}
});
onEnd
钩子
参数结构
interface OnEndArgs {
errors: Message[]; // 错误信息数组
warnings: Message[]; // 警告信息数组
outputFiles?: OutputFile[]; // 输出文件(仅当 write:false 时存在)
}
典型使用场景
const reportPlugin = {
name: 'report',
setup(build) {
build.onEnd((result) => {
if (result.errors.length > 0) {
sendAlert(`构建失败:${result.errors[0].text}`);
} else {
console.log(`构建成功,耗时:${performance.now() - startTime}ms`);
}
});
}
};
onStart 的执行时机是在每次 build 的时候,包括触发
watch
或者serve
模式下的重新构建。onEnd 钩子中如果要拿到
metafile
,必须将 Esbuild 的构建配置中metafile
属性设为true
。
钩子 | 触发时机 | 核心用途 | 典型场景 |
---|---|---|---|
onStart |
构建启动前(首个任务执行前) | 初始化资源、清理缓存、全局配置预处理 | 生成构建ID、清理旧输出目录 |
onEnd |
构建完全结束后 | 分析结果、发送通知、后处理输出文件 | 生成报告、上传资源、修改输出内容 |
功能:从 URL 动态加载第三方库(如 CDN)
适用场景:避免本地安装依赖,减少构建体积
// http-import-plugin.js
import https from 'https';
import http from 'http';
export default () => ({
name: "esbuild:http",
setup(build) {
build.onResolve({ filter: /^https?:\/\// }, (args) => ({
path: args.path,
namespace: "http-url",
}));
build.onResolve({ filter: /.*/, namespace: 'http-url' }, args => ({
path: new URL(args.path, args.importer).href,
namespace: 'http-url',
}));
build.onLoad({ filter: /.*/, namespace: "http-url" }, async (args) => {
// 处理data:协议
if (args.path.startsWith('data:')) {
const [meta, data] = args.path.split(',');
// 检测二进制格式
const isBinary = meta.includes('image/') || meta.includes('font/');
// 设置正确编码
const encoding = isBinary ? 'base64' : (meta.includes('base64') ? 'base64' : 'utf8');
return {
contents: Buffer.from(data, encoding),
loader: isBinary ? 'binary' : (meta.includes('css') ? 'css' : 'text')
};
}
let retries = 3;
let timeout = 5000; // 5秒超时
let contents;
while (retries--) {
try {
contents = await new Promise((resolve, reject) => {
let currentReq = null;
const timer = setTimeout(() => {
currentReq?.abort();
reject(new Error(`Request timeout after ${timeout}ms`));
}, timeout);
function fetch(url) {
console.log(`Downloading: ${url} (retries left: ${retries})`);
const lib = url.startsWith("https") ? https : http;
const req = lib.get(url, (res) => {
currentReq = req;
clearTimeout(timer);
if ([301, 302, 307].includes(res.statusCode)) {
// 重定向
resolve(new Promise((newResolve, newReject) => {
fetch(new URL(res.headers.location, url).toString())
.then(newResolve)
.catch(newReject);
}));
} else if (res.statusCode === 200) {
// 响应成功
let chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => resolve(Buffer.concat(chunks)));
} else {
reject(
new Error(`GET ${url} failed: status ${res.statusCode}`)
);
}
});
currentReq = req;
req.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
}
fetch(args.path);
});
break; // 成功则跳出重试循环
} catch (err) {
if (!retries) throw err;
console.warn(`Retrying... (${retries} attempts left)`);
await new Promise(r => setTimeout(r, 1000)); // 1秒后重试
}
}
return {
contents,
loader: args.path.endsWith('.css') ? 'css' : 'jsx',
resolveDir: process.cwd()
};
});
}
});
接着我们来到main.js
// 使用ESM CDN加载依赖
import { createApp, h } from 'https://esm.sh/vue@3';
import ElementPlus from 'https://esm.sh/[email protected]';
// 通过CDN加载样式文件
import 'https://esm.sh/[email protected]/dist/index.css';
const App = {
setup() {
return () => h('div', [
h('h1', 'Hello World'),
h('el-button', { type: 'primary' }, 'ElementPlus按钮')
])
}
}
createApp(App)
.use(ElementPlus)
.mount('#app');
然后我们新建build.js
文件,内容如下:
import { serve, build } from 'esbuild';
import { default as httpImport } from './http-import-plugin.js';
async function runBuild() {
build({
absWorkingDir: process.cwd(),
entryPoints: ["./src/main.js"],
outdir: "dist",
bundle: true,
format: "esm",
splitting: true,
sourcemap: true,
metafile: true,
plugins: [httpImport()],
external: ['vue', 'element-plus'],
loader: {
'.js': 'jsx',
'.jsx': 'jsx',
'.json': 'json',
'.css': 'css'
}
}).then(() => {
console.log(" Build Finished!");
});
}
runBuild();
最后我们执行node build.js
,发现依赖已经成功下载并打包了。
功能:将静态资源(如图片、字体)复制到输出目录 适用场景:处理非 JS/CSS 资源
// copy-plugin.js
import fs from 'fs';
import path from 'path';
const copyPlugin = {
name: 'copy',
setup(build) {
// 1. 监听构建结束事件
build.onEnd(async (result) => {
const assets = ['src/assets/*.png', 'public/fonts/*.woff2'];
const outDir = build.initialOptions.outdir || 'dist';
// 2. 遍历匹配文件并复制
for (const pattern of assets) {
const files = glob.sync(pattern);
for (const file of files) {
const dest = path.join(outDir, path.basename(file));
fs.copyFileSync(file, dest);
}
}
});
},
};
filter: /\.css$/
代替 JS 逻辑判断,减少 JS/Go 通信开销namespace
隔离模块来源(如 http-url
、env-ns
)contents
可返回字符串或 Buffer,支持动态生成代码build.onResolve({ filter: /.*/ }, (args) => {
console.log('Resolving:', args.path);
if (args.path === 'illegal') throw new Error('非法路径');
});
console.log
调试解析流程