Esbuild使用和插件开发
一、esbuild介绍
esbuild: An extremely fast JavaScript bundler,一个速度极快的javascript打包神器,可以看官网的各个打包器之间的对比,可以说esbuild是横扫其他打包器,esbuild不同于其他打包器,他是基于go开发的打包神器,他充分利用了go多线程并且能尽可能的利用各个cpu的核,对于打包过程的解析,代码生成都是并行执行的
二、esbuild的主要特性
- 没有缓存机制也有极快的打包速度
- 支持es6和cjs模块
- 支持es6 modules的tree-shaking
- 支持ts和jsx
- sourcemap
- 压缩工具
- 自定义的插件开发
三、esbuild使用
在node中使用esbuild你只需要安装正常安装就行了
yarn add esbuild
esbuild对于不同的平台有不同的二进制包下载,如果你仔细看的话会发现两个esbuild的包
其中下面的那个就是根据你的操作系统获取到的相应的esbuild二进制包了
使用命令来执行esbuild也很简单,可以在package.json下加入以下script就会对src/index.jsx文件进行打包,输出在dist/out.js
"build":./node_modules/.bin/esbuild src/index.jsx --bundle --outfile=dist/out.js
esbuild不光能打包web,也能够进行对node的打包,命令如下:
./node_modules/.bin/esbuild app.js --bundle --platform=node --target=node10.4 --outfile=dist/out.js
esbuild还自带esm的treeshaking 比如在app.js中写一下内容
import {foo} from './m'
foo()
m.js
export function foo(){
console.log(a)
}
export function bar(){
console.log('bar')
}
可以看到dist/out.js内容如下,bar已经是被删除了,因为bar没有直接或间接引用,esbuild会认为这是无用代码
// src/node/m.js
function foo() {
console.log(a);
}
// src/node/index.js
foo();
esbuild不光支持命令的方式来执行,也可以通过function的方式来进行,代码如下
const { build, buildSync, serve } = require("esbuild");
async function runBuild() {
// 异步方法,返回一个 Promise
const result = await build({
// ---- 如下是一些常见的配置 ---
// 当前项目根目录
absWorkingDir: process.cwd(),
// 入口文件列表,为一个数组
entryPoints: ["./src/index.jsx"],
// 打包产物目录
outdir: "dist",
// 是否需要打包,一般设为 true
bundle: true,
// 模块格式,包括`esm`、`commonjs`和`iife`
format: "esm",
// 需要排除打包的依赖列表
external: [],
// 是否开启自动拆包
splitting: true,
// 是否生成 SourceMap 文件
sourcemap: true,
// 是否生成打包的元信息文件
metafile: true,
// 是否进行代码压缩
minify: false,
// 是否开启 watch 模式,在 watch 模式下代码变动则会触发重新打包
watch: false,
// 是否将产物写入磁盘
write: true,
// Esbuild 内置了一系列的 loader,包括 base64、binary、css、dataurl、file、js(x)、ts(x)、text、json
// 针对一些特殊的文件,调用不同的 loader 进行加载
loader: {
'.png': 'base64',
}
});
console.log(result);
}
runBuild();
只需要引入build方法,build方法是异步的,需要传入一个配置对象信息就行了,各种配置项可以查看上面代码,这些配置项,只需执行runbuild就可以达到和命令相同的效果啦,默认的打包输出文件在dist/index.js
四、esbuild插件开发
esbuild的插件开发是一个对象,该对象中有name即插件名称,还有个setup function,格式如下
let plugin={
name:'my-plugin',
setup(build){
......
}
}
setup函数中有个build参数,build参数可以让我们达到esbuild打包过程中的hook,我们可以指定在不同的钩子中执行不同的方法。
开始esbuild插件开发之前需要先认识esbuild插件开发最主要的两个钩子,如果你熟悉其他打包工具比如webpack或者是rollup等,应该对这个概念不陌生。
1.onResolve
示例
build.onResolve({ filter: /^env$/ }, args => {
console.log(args)
return{
path: args.path,
namespace: 'env-ns',//作为标识
}})
onResolve钩子是一个路径解析的钩子,是esbuild去解析路径的时候触发的,也就是你执行import ... from ...的时候会调用onResolve钩子,onResolve钩子接收一个对象,对象的属性包含有filter和namespace,filter故名思义就是过滤,对于不符合要求的模块会直接跳过该钩子,namespace是可选参数,一般onResolve中会返回一个namespace,在其他钩子中使用这个namespace表示这个模块需要使用onResolve这个钩子产生的虚拟模块,虚拟模块的概念我们下面会讲到,onResolve第二个参数是个回调函数,他接收一个参数,这个参数里有啥内容大家可以自己打印下这边就直接贴出来了
// 模块路径
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)
我们也可以看到onResolve也返回了一个对象,对象中包含了path,和namespace,path就是当前模块的路径,namepace命名空间是让其他钩子通过这个命名空间将模块过滤出来,除了返回上面的两个参数外,onResolve返回中的对象还包括了很多种,如下
{ // 错误信息
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.onLoad
上面的代码我们看到了另一个钩子onLoad,这个onLoad会在每个唯一的路径/命名空间且没有被标记为external的时候执行,他会返回当前模块的内容并且告诉esbuild你需要使用哪个loader去加载这块内容,如下就是对以txt结尾文件进行内容获取,并且返回json告诉esbuild需要使用json的loader来进行数据解析,下面的案例就类似于esbuild执行到这块内容的时候调用JSON.parse来进行解析
build.onLoad({ filter: /.txt$/ }, async (args) => {
let text = await fs.promises.readFile(args.path, 'utf8')
return {
contents: JSON.stringify(text.split(/\s+/)),
loader: 'json',
}
})
如何在onLoad中使用上面所说到的命名空间呢,可以看看如下代码 src/build.js
let myplugin={
name:'my-plugin',
setup(build){
build.onResolve({filter:/^env$/},args=>({
path:args.path,
namespace:'env-ns'
}))
build.onLoad({filter:/*/,namespace:'env-ns'},args=>({
contents:JSON.stringfy(process.env),
loader:'json'
}))
}
}
require('esbuild').build({
entryPoints: ['src/plugin.js'],
bundle: true,
outfile: 'out.js',
// 应用插件
plugins: [envPlugin],
}).catch(() => process.exit(1))
src/plugin.js
import {PATH} from 'env'
console.log(PATH)
我们来分析下上面的代码:
首先我们知道没有env这个模块,所以我们需要构造一个env模块,这里就需要我们的onResolve钩子,他过滤出这个路径,并返回了一个namespace,这个时候其实我们的env模块就有一个虚拟模块的概念,他在我们操作系统的文件中并不存在,只是临时存在内存中的,我们在通过onLoad的时候指定namespace其实就是去加载这个虚拟模块,我们可以指定虚拟模块的返回内容,这边我们返回JSON.stringfy(process.env),process.env里面具有PATH属性,esbuild会使用json加载器来进行解析,最后就可以在我们的env模块中使用PATH字段了
也就是说env这个模块是一个在esbuild打包过程中临时存在的,中间他们变成 import {PATH} from 'process.env' process.env中就有我们所需要的PATH模块
我们执行上面src/build.js代码可以看到我们的打包类似下面的内容
(() => {
// env-ns:env
var PATH = "/Users/xiaoming/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/xiaoming/bin:/Users/xiaoming/.cargo/bin:/opt/homebrew/bin:/opt/homebrew/bin";
// src/plugin.js
console.log(PATH);
})();
关于虚拟模块的概念其实也只是代表了我自己的理解,可能理解的也还不是很透彻,大家可以在esbuild的官网出看看官方是怎么解释这个概念的,这边就贴一张图吧
当然除了上述钩子外还有onStart,onEnd两个钩子,这两个钩子理解起来都比较简单,这边就不说废话了
五、cdn拉取依赖插件开发
这是一个官网demo,这边讲解下,我们需要打包的是以下内容,可以看到react和reactDom都是通过cdn的方式来进行引入的,我们实际打包的话就需要拉取cdn的内容在进行打包,我们就可以通过插件来帮助我们拉取三方依赖
//src/cnd.js
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"));
1、首先我们肯定是路径解析,我们需要找出https或者是http开头的模块就要用到onResolve啦 2、在通过请求的方式将内容请求回来,在onLoad的方式中将内容返回让esbuild知道这个模块的内容
build.onResolve({filter:/^https?:\/\//},args=>({
path:args.path,
namespace:'http-url'
}))
build.onLoad({filter:/.*/,namespace:'http-url'},(args)=>{
const contents=http.get('.....')
return {contents}
})
拉取内容的代码就不做过多阐述,大家应该都能看懂
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);
}
// 根据返回的path字段来抓取文件
fetch(args.path);
});
完整的例子就如下面的代码
module.exports=()=>({
name:'esbuild:http',
setup(build){
let https=require('https')
let http=require('http')
//拦截以https开头的请求依赖
build.onResolve({ filter: /^https?:\/\// }, (args) => ({
path: args.path,
namespace: "http-url",
}));
//所有文件都使用http-url命名空间
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);
}
// 根据返回的path字段来抓取文件
fetch(args.path);
});
return { contents };
});
},
})
build.js
const { build } = require("esbuild");
const httpImport = require("./http-plugin");
async function runBuild() {
build({
absWorkingDir: process.cwd(),
entryPoints: ["./src/cdn.jsx"],
outdir: "dist",
bundle: true,
format: "esm",
splitting: true,
sourcemap: true,
metafile: true,
plugins: [httpImport()],
}).then(() => {
console.log("🚀 Build Finished!");
});
}
runBuild();
执行build.js,我们会发现报错了,错误如下
其实查看错误信息就可以知道说有部分模块的内容无法resolve,如果我们仔细发现就会知道这些间接依赖写的是相对路径,并不是绝对路径,你也可以直接点进cdn链接里也能看到他并不是写的绝对路径,所以拉取不到,因此我们这边还要加一步就是将相对路径转变成绝对路径,怎么加呢,重写路径就行了
build.onResolve({ filter: /.*/, namespace: "http-url" }, (args) => ({
// 重写路径
path: new URL(args.path, args.importer).toString(),
namespace: "http-url",
}));
所以plugin的完整代码就如下了
module.exports=()=>({
name:'esbuild:http',
setup(build){
let https=require('https')
let http=require('http')
//拦截以https开头的请求依赖
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).toString(),
namespace: "http-url",
}));
//所有文件都使用http-url命名空间
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);
}
// 根据返回的path字段来抓取文件
fetch(args.path);
});
return { contents };
});
},
})
参考文章:
esbuild官网
三元哥的小册yyds