09-Esbuild 插件开发实战
本节课程代码的仓库 (opens new window):
11-vite-esbuild
# 0.前言
本篇博客为《深入浅出 Vite
》掘金手册第九章的总结。
# 1.基础-概念
# 问题1:为什么 Esbuild
性能极高?
Go
语言开发,GO
语言的优势主要体现两处,其一体现在编译型语言的特性,直接将逻辑代码构建为原生机器码,而js
先解析为字节码,在转化为机器码。其二为多线程共享内存共享。从零造轮子:几乎没有使用三方库,这种"闭源"特性不仅体现在性能上,也使得在频繁操作
AST
场景下,能共享数据,免去了不同AST
树的频繁解析和传递数据(string
->ts
->js
->string
)。此特性很像
Flutter
采用dart
语言带来的性能提升,通过自渲染引擎,打通客户端与原生端沟通成本,dart
取代传统的js Bridge
。
# 问题2:可以通过哪两种方式来使用 Esbuild
?
有两种方式:命令行 和 代码调用。
命令行:
"command:npx": "npx esbuild src/index.jsx --bundle --outfile=dist/out.js",
1代码调用:
"command:build": "node ./scripts/command/build.js", "command:serve": "node ./scripts/command/serve.js", "command:watch": "node ./scripts/command/watch.js",
1
2
3具体使用见下个问题。
# 问题3:ESBuild
一共提供哪儿三种基础模式?能简单介绍下具体的使用方式吗?
提供三种模式:build
、serve
、watch
build
:在esbuild
中此api
体现为异步,也同样提供了buildSync
函数,但是不推荐使用。serve
:可提供一个高性能的静态文件服务
。(特别注意的是:与watch
不同的时,只有当请求
到来的时候,才会触发构建)watch
:在watch
下代码变动会触发重新打包。
基础使用:
const buildConfig = {};
const result = await esbuild.build(buildConfig);
2
详细代码见仓库 (opens new window),配置参数大致可分为以下四类:通用参数、esbuild 特有参数、开发环境参数、生产环境参数。
通用:
- 入口:
entryPoints: [""]
是一个数组。 - 出口:
outdir: "dist"
- 指定模块语法规范:
iffe、esm、cjs
- 入口:
esbuild
特有参数:metafile:true
开启会在结果中注入元信息。(处理依赖相关时需要)loader: {}
与webpack
中的loader
概念相同 ,针对不同格式的文件,调用不同的loader
进行处理。内置的
loader
如下:export type Loader = 'base64' | 'binary' | 'copy' | 'css' | 'dataurl' | 'default' | 'empty' | 'file' | 'js' | 'json' | 'jsx' | 'text' | 'ts' | 'tsx'
{".png":"base64"}
:将图片格式处理为base64
格式。{".jsx": "jsx"}
:处理jsx
格式。
开发环境:支持
sourcemap
生成map
文件。生产环境:
是否打包:
bundle:true
,此参数配置的区别在于是否处理第三方依赖。可以写一个插件实现下面的内容折叠。
通过观察
metafile
可以观察输入输出。bundle: false
时,$ npm run command:build > 11-vite-esbuild@1.0.0 command:build > node ./scripts/command/build.js result { errors: [], warnings: [], outputFiles: undefined, metafile: { inputs: { 'src/react-component.jsx': [Object] }, outputs: { 'dist/command/build/react-component.js.map': [Object], 'dist/command/build/react-component.js': [Object] } }, mangleCache: undefined }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18bundle: true
时,$ npm run command:build > 11-vite-esbuild@1.0.0 command:build > node ./scripts/command/build.js result { errors: [], warnings: [], outputFiles: undefined, metafile: { inputs: { '../../node_modules/.pnpm/react@18.2.0/node_modules/react/cjs/react.development.js': [Object], '../../node_modules/.pnpm/react@18.2.0/node_modules/react/index.js': [Object], '../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/cjs/react-dom-server-legacy.browser.development.js': [Object], '../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/cjs/react-dom-server.browser.development.js': [Object], '../../node_modules/.pnpm/react-dom@18.2.0_react@18.2.0/node_modules/react-dom/server.browser.js': [Object], 'src/react-component.jsx': [Object] }, outputs: { 'dist/command/build/react-component.js.map': [Object], 'dist/command/build/react-component.js': [Object] } }, mangleCache: undefined }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
format: "esm"
下可开启splitting:true
可进行分包操作。bundle: true
属性下,可额外通过external:[]
指定不需要打入的第三方依赖。minify: true
开启压缩操作。write: true
和webpack-dev-server
一样默认是将产物输出到内存,开启后,可以将产物写进磁盘。
# 2. 进阶-插件开发
# 问题4:ESbuild
插件本质上是什么?有哪儿个钩子可供使用,请简要介绍?
ESbuild
插件本质上就是一个对象,两个属性 name
和 setup
和 四个钩子函数。
let esbuildPlugin = {
name: "esbuild:plugin", /* 插件名称 */
setup(build){
// build 暴露四个钩子函数
// 模块路径解析,例如 import xxx from "module-name"
build.onResolve({filter:/module-name/},args => ({}))
// 模块内容加载,即 xxx 返回的内容
build.onLoad({filter:/module-name/},args => ({}))
// 构建开始前触发
build.onStart(()=>{ console.log("构建开始")});
// 构建结束时触发
build.onEnd((buildResult) => {})
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 问题5:onResolve
和 onLoad
钩子的使用方式?
onResolve
和 onLoad
是一对非常重要的钩子函数。
常用的组合使用方式为:使用 onResolve
筛选出目标模块,并通过 namespace
标识模块。onLoad
可根据 module-name
拦截模块的加载信息。 典型案例见:问题7。
简单的筛选模板通过 {filter: //}
正则语法就能实现,如果想要实现复杂的功能,还需要获取当前解析模块的上下文信息,如:
导入的是哪儿个模块?(
args.path
),以及被谁导入的?(args.importer
)导入的模块规范?由
import
还是require
方式?(args.kind
)除了需要返回标识
namespace
属性,是否可以标识其余内容信息?如一些额外的内容。
在 onResolve
钩子中函数参数和返回值梳理如下:
build.onResolve({ filter: /^env$/ }, (args: onResolveArgs): onResolveResult => {
// 模块路径
console.log(args.path)
// 父模块路径
console.log(args.importer)
// namespace 标识
console.log(args.namespace)
// 基准路径
console.log(args.resolveDir)
// 导入方式,如 import、require
console.log(args.kind)
// 额外绑定的插件数据
console.log(args.pluginData)
return {
// 错误信息
errors: [],
// 是否需要 external
external: false;
// namespace 标识
namespace: 'env-ns';
// 模块路径
path: args.path,
// 额外绑定的插件数据
pluginData: null,
// 插件名称
pluginName: 'xxx',
// 设置为 false,如果模块没有被用到,模块代码将会在产物中会删除。否则不会这么做
sideEffects: false,
// 添加一些路径后缀,如`?xxx`
suffix: '?xxx',
// 警告信息
warnings: [],
// 仅仅在 Esbuild 开启 watch 模式下生效
// 告诉 Esbuild 需要额外监听哪些文件/目录的变化
watchDirs: [],
watchFiles: []
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
在 onLoad
钩子中函数参数和返回值梳理如下:
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, (args: OnLoadArgs): OnLoadResult => {
// 模块路径
console.log(args.path);
// namespace 标识
console.log(args.namespace);
// 后缀信息
console.log(args.suffix);
// 额外的插件数据
console.log(args.pluginData);
return {
// 模块具体内容
contents: '省略内容',
// 错误信息
errors: [],
// 指定 loader,如`js`、`ts`、`jsx`、`tsx`、`json`等等
loader: 'json',
// 额外的插件数据
pluginData: null,
// 插件名称
pluginName: 'xxx',
// 基准路径
resolveDir: './dir',
// 警告信息
warnings: [],
// 同上
watchDirs: [],
watchFiles: []
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 问题6:onStart
和 onEnd
钩子的使用方式?
使用方式比较简单,如下例所示:
let examplePlugin = {
name: 'example',
setup(build) {
/* 触发时间:在 watch 或者 serve 下重新构建*/
build.onStart(() => {
console.log('build started')
});
build.onEnd((buildResult) => {
if (buildResult.errors.length) {
return;
}
// 构建元信息
// 获取元信息后做一些自定义的事情,比如生成 HTML
console.log(buildResult.metafile)
})
},
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
注意如下:
onStart
的触发时机?onEnd
返回的结果,即为esbuild.build({})
的执行结果,同build
的使用一样?metafile
属性需要配置后才可获取。
# 3. 插件实践
# 问题7:如何实现"env-ns"虚拟模块,获取构建时环境 process.env
?
let envPlugin = {
name: "env",
setup(build) {
/* 解析 `import "env"` 依赖,并将其标识为 namespace "env-ns" */
build.onResolve({ filter: /^env$/ }, (args) => ({
path: args.path,
namespace: "env-ns",
}));
/* 通过 namespace 捕获模块,并导出对象 {contents: "", loader:"json"} */
build.onLoad({ filter: /.*/, namespace: "env-ns" }, () => ({
contents: JSON.stringify(process.env),
loader: "json",
}));
},
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 问题8:如何基于 ESbuild
编写一个支持识别 http
模块插件,简述大致流程?
目标:支持打包 http
格式的模块依赖,案例如下:
import { render } from "https://cdn.skypack.dev/react-dom";
import React from 'https://cdn.skypack.dev/react'
let Greet = () => <h1>Hello, juejin!</h1>;
render(<Greet />, document.getElementById("root"));
2
3
4
5
6
实现思路如下:
- 通过
onResolve
对http(s)
开头的 直接依赖 模块,并打上http-url
标识。 - 对
/-/react-dom@v17.0.1-o
等开头的 间接依赖 模块,需要先拼接出完成的http
路径后再返回。简介依赖会自动携带之前打上的http-url
标识,可根据此特性过滤。 - 通过
onLoad
拦截具有http-url
标识的模块。- 通过
http
或者https
第三方依赖封装一个fetch
函数,使用此fetch
函数去请求该模块(通过args.path
获取上下文 ) - 返回请求结果
{contents: 内容}
- 通过
实现方案如下:
const esbuilPlugin = {
name: "esbuild:http",
setup(build) {
let https = require("https");
let http = require("http");
// 1. 拦截直接依赖
build.onResolve({ filter: /^https?:\/\// }, (args) => ({
path: args.path,
namespace: "http-url",
}));
// 2. 拦截间接依赖
build.onResolve({ filter: /.*/, namespace: "http-url" }, (args) => ({
path: new URL(args.path, args.importer).toString(),
namespace: "http-url",
}));
// 2. 通过 fetch 请求加载 CDN 资源,并返回 contents
build.onLoad({ filter: /.*/, namespace: "http-url" }, async (args) => {
let contents = await new Promise((resolve, reject) => {
function fetch(url) {
console.log(`Downloading: ${url}`);
let lib = url.startsWith("https") ? https : http;
let req = lib
.get(url, (res) => {
if ([301, 302, 307].includes(res.statusCode)) {
// 重定向
fetch(new URL(res.headers.location, url).toString());
req.abort();
} 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}`)
);
}
})
.on("error", reject);
}
fetch(args.path);
});
return { contents };
});
},
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
根据案例仓库的代码,执行 npm run plugin:cdn
后会在 dist
目录下生成 plugins/cdn-module.js
文件。因此,可以额外开发一个 HTML
插件用于导入构建的 js
产物。
# 问题9:如何基于 ESbuild
编写一个 HTML
构建插件,将上述的 js
插入对预制的 html
模板,请简述大概流程?
实现思路:
通过
onEnd
钩子函数可以获取到bundle
产物打包后的一些元信息。需要提供三个辅助函数:
createScripts/createLink/generateHtml
获取元信息结果
const {metafile} = bundle; const {outputs} = metafile; // 拿到 metafile 后获取所有的 js 和 css 产物路径 const assets = Object.keys(outputs); // 获取 assets 数组
1
2
3使用
fs.writeFile
将html
字符传写入磁盘。
基础版-完整代码如下:
const esbuildHTML = {
name: "esbuild:html",
setup(build) {
/* 此钩子主要放在 onEnd */
build.onEnd(async (buildResult) => {
if (buildResult.errors.length) {
return;
}
/* 通过 esbuild 获取此结果 */
const { metafile } = buildResult;
/* 1. 拼接 html */
const scripts = [];
const cssLinks = [];
if (metafile) {
const { outputs } = metafile;
const assets = Object.keys(outputs);
assets.forEach((asset) => {
const relativePath = path.relative("dist/plugins", asset);
if (asset.endsWith(".js")) {
scripts.push(createScript(relativePath));
} else if (asset.endsWith(asset)) {
cssLinks.push(createLink(relativePath));
}
});
const templateContent = generateHTML(scripts, cssLinks);
/* 写入磁盘 */
const templatePath = path.join(
__dirname,
"../../dist/plugins",
"index.html",
);
await fs.writeFile(templatePath, templateContent);
}
});
},
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
构建后,可通过 serve .
启动一个静态文件文件服务器。
强烈推荐:三元老师写的 esbuild
代码 (opens new window),代码逻辑非常清晰值得学习。