esbuild 插件实战:5个真实场景带你自定义构建流水线
上周项目构建时间从 8 秒飙到 45 秒,排查发现是 Webpack 的 loader 链逐文件做 AST 转换。换成 esbuild 后构建回到 3 秒,但发现几个刚需功能没有开箱即用------环境变量注入、SVG 转 React 组件、构建时 HTTP 拉取远程配置。翻了一圈文档,esbuild 的插件 API 足够简单,半小时就能写完一个。这篇文章把我在项目中写过的 5 个 esbuild 插件整理出来,每个都来自真实需求,代码可以直接跑。
esbuild 插件机制速览
esbuild 的插件系统只有两个核心钩子:onResolve 和 onLoad。
- onResolve:拦截模块路径解析。你拿到 import 路径后,可以返回一个新的路径或者标记为外部模块。
- onLoad:拦截模块内容加载。你拿到文件路径后,可以返回转换后的内容(比如编译、注入、替换)。
一个最小的插件骨架长这样:
javascript
const helloPlugin = {
name: 'hello',
setup(build) {
build.onResolve({ filter: /^hello:/ }, (args) => ({
path: args.path.replace('hello:', ''),
namespace: 'hello-ns',
}));
build.onLoad({ filter: /.*/, namespace: 'hello-ns' }, (args) => ({
contents: `export default "Hello, ${args.path}"`,
loader: 'js',
}));
},
};
用 hello:world 作为 import 路径,esbuild 会自动走插件的 resolve → load 流程,最终拿到 "Hello, world"。filter 是正则,esbuild 用它做快速过滤,比每次调用 JS 回调快得多。
场景一:构建时环境变量注入
Vite 有 define 配置,Webpack 有 DefinePlugin,esbuild 也有 define------但它只能做字符串替换,不支持 .env 文件加载和类型转换。实际项目里我们通常需要:
- 从
.env文件读取变量 - 只有
VITE_前缀的变量注入到前端代码 - 布尔值和数字要自动转类型,而不是全部当字符串
javascript
import { readFileSync } from 'fs';
const envPlugin = {
name: 'env-inject',
setup(build) {
const envFile = readFileSync('.env', 'utf-8');
const envVars = {};
for (const line of envFile.split('\n')) {
const match = line.match(/^VITE_(\w+)=(.*)$/);
if (!match) continue;
const [, key, raw] = match;
// 自动类型转换
if (raw === 'true') envVars[key] = true;
else if (raw === 'false') envVars[key] = false;
else if /^\d+$/.test(raw)) envVars[key] = Number(raw);
else envVars[key] = JSON.stringify(raw);
}
const defineMap = {};
for (const [k, v] of Object.entries(envVars)) {
defineMap[`import.meta.env.${k}`] = typeof v === 'string' ? v : JSON.stringify(v);
}
// 注入到 esbuild 的 define
build.initialOptions.define = {
...build.initialOptions.define,
...defineMap,
};
},
};
使用时只需在 plugins 数组里加上 envPlugin,代码里写 import.meta.env.API_URL 就能拿到值。注意 define 是编译时替换,不会产生运行时开销。
场景二:SVG 转 React 组件
中后台项目大量使用 SVG 图标,通常的做法是用 @svgr/webpack 在构建时把 SVG 转成 React 组件。esbuild 没有官方支持,但用 onLoad 钩子 + @svgr/core 很容易实现:
javascript
import { transform } from '@svgr/core';
const svgPlugin = {
name: 'svg-to-component',
setup(build) {
build.onResolve({ filter: /\.svg$/ }, (args) => ({
path: args.path,
namespace: 'svg',
}));
build.onLoad({ filter: /\.svg$/, namespace: 'svg' }, async (args) => {
const svg = readFileSync(args.path, 'utf-8');
const component = await transform(
svg,
{ icon: true, replaceAttrValues: { '#000': 'currentColor' } },
{ componentName: 'SvgComponent' }
);
return { contents: component, loader: 'jsx' };
});
},
};
这样 import Icon from './logo.svg' 直接拿到一个 React 组件,currentColor 替换让你能用 CSS 控制图标颜色。相比 Webpack 方案,构建速度提升 5 倍以上。
场景三:远程配置拉取与注入
微前端架构下,子应用的某些配置(特性开关、AB 实验分组)在构建时从配置中心拉取,编译进代码,避免运行时请求。这个需求本质上是:构建启动时发 HTTP 请求,拿到 JSON 后注入到代码中。
javascript
const remoteConfigPlugin = (url) => ({
name: 'remote-config',
setup(build) {
let config = null;
// onStart 保证在构建前完成
build.onStart(async () => {
const res = await fetch(url);
if (!res.ok) throw new Error(`Config fetch failed: ${res.status}`);
config = await res.json();
console.log(`[remote-config] loaded ${Object.keys(config).length} keys`);
});
build.onResolve({ filter: /^remote-config:\/\// }, (args) => ({
path: args.path.replace('remote-config://', ''),
namespace: 'remote-config',
}));
build.onLoad({ filter: /.*/, namespace: 'remote-config' }, (args) => ({
contents: `export default ${JSON.stringify(config ?? {})}`,
loader: 'js',
}));
},
});
代码里用 import features from 'remote-config://features' 就能拿到配置中心的数据。onStart 确保每次构建前重新拉取,开发模式下 HMR 刷新也能拿到最新配置。
场景四:Markdown 文件作为页面组件
文档站或博客场景下,Markdown 文件需要编译成可渲染的组件。我们用 onLoad 拦截 .md 文件,调用 marked 解析,再包装成模块导出:
javascript
import { marked } from 'marked';
const markdownPlugin = {
name: 'markdown-loader',
setup(build) {
build.onLoad({ filter: /\.md$/ }, async (args) => {
const raw = readFileSync(args.path, 'utf-8');
const html = await marked(raw);
// 提取 frontmatter 中的 title
const titleMatch = raw.match(/^---\ntitle:\s*(.+)\n---/);
const title = titleMatch ? titleMatch[1] : 'Untitled';
return {
contents: `
export const title = ${JSON.stringify(title)};
export const html = ${JSON.stringify(html)};
export default function MarkdownPage() {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
`,
loader: 'jsx',
};
});
},
};
一个 .md 文件直接变成导出 title、html 和默认 React 组件的模块,路由系统里 import Page from './intro.md' 即可使用。
场景五:构建产物分析器
构建优化离不开产物分析。Webpack 有 webpack-bundle-analyzer,esbuild 没有官方方案------但 onEnd 钩子可以拿到 metafile,我们自己算:
javascript
const analyzerPlugin = {
name: 'bundle-analyzer',
setup(build) {
// 确保 metafile 生成
build.initialOptions.metafile = true;
build.onEnd((result) => {
const meta = result.metafile;
if (!meta) return;
const outputs = Object.entries(meta.outputs)
.map(([file, info]) => ({
file,
size: (info.bytes / 1024).toFixed(1) + ' KB',
}))
.sort((a, b) => parseFloat(b.size) - parseFloat(a.size));
console.log('\n📦 Bundle Analysis:');
console.log('─'.repeat(50));
for (const { file, size } of outputs) {
console.log(` ${file.padEnd(40)} ${size}`);
}
console.log('─'.repeat(50));
const total = outputs.reduce((s, o) => s + parseFloat(o.size), 0);
console.log(` ${'TOTAL'.padEnd(40)} ${total.toFixed(1)} KB\n`);
});
},
};
每次构建结束自动打印产物大小分布,快速定位体积异常的 chunk。配合 watch 模式,修改代码后立刻看到体积变化。
总结
- esbuild 插件核心只有
onResolve和onLoad两个钩子 ,配合namespace可以实现虚拟模块,学习成本极低 - 环境变量注入用
define而非运行时对象,编译时替换零开销;别忘了做类型转换 - SVG 转组件、Markdown 转页面 这类"非 JS 文件加载"是
onLoad的典型场景,loader参数指定输出格式即可 onStart适合异步前置任务 (远程配置拉取),onEnd适合后置分析(产物统计),别搞混时序filter用正则做快速路径过滤,比每次调 JS 回调高效得多,复杂匹配逻辑放到回调内部
esbuild 的插件 API 设计刻意保持极简,这既是优点也是限制------没有 Webpack 那种 tapable 的复杂钩子链,但 90% 的构建定制需求用 onResolve + onLoad + onStart + onEnd 就够了。下次遇到"esbuild 不支持 XX"的问题,先别急着换工具,写个插件试试,可能半小时就搞定了。