前端打包工具 Esbuild
esbuild 作为一款新的构建工具,构建打包速度几乎完全碾压市面上流行的 webpack5,rollup 等工具。目前国内流行的 Vite 也使用了 esbuild 来预构建依赖,官方宣称速度快了 10 - 100 倍。
esbulid 为什么这么快呢?
- 使用 Golang 开发,构建逻辑代码直接被编译为原生机器码,而不用像 JS 一样先代码解析为字节码,然后转换为机器码,大大节省了程序运行时间。
- 多核并行。内部打包算法充分利用多核 CPU 优势,所有的步骤尽可能并行,这也是得益于 Go 当中多线程共享内存的优势。
- 从零造轮子。 几乎没有使用任何第三方库,所有逻辑自己编写,大到 AST 解析,小到字符串的操作,保证极致的代码性能。
- 高效的内存利用。Esbuild 中从头到尾尽可能地复用一份 AST 节点数据,而不用像 JS 打包工具中频繁地解析和传递 AST 数据(如 string -> TS -> JS -> string),造成内存的大量浪费。
大多数前端打包工具都是基于 JavaScript 实现的,而 Esbuild 则选择使用 Go 语言编写,两种语言各自有其擅长的场景,但是在资源打包这种 CPU 密集场景下,Go 性能更优。一般来说,JS 的操作是毫秒级,而 Go 则是纳秒级。
虽然现代 JS 引擎与10年前相比有巨大的提升,但 JavaScript 本质上依然是一门解释型语言,JavaScript 程序每次执行都需要先由解释器一边将源码翻译成机器语言,一边调度执行;
而 Go 是一种编译型语言,在编译阶段就已经将源码转译为机器码,启动时只需要直接执行这些机器码即可。
也就意味着,Go 语言编写的程序比 JavaScript 少了一个动态解释的过程。
这种语言层面的差异在打包场景下特别突出,说的夸张一点,JavaScript 运行时还在解释代码的时候,Esbuild 已经在解析用户代码;JavaScript 运行时解释完代码刚准备启动的时候,Esbuild 可能已经打包完毕,退出进程了!
多线程优势
Go 天生具有多线程运行能力,而 JavaScript 本质上是一门单线程语言,直到引入 WebWorker 规范之后才有可能在浏览器、Node 中实现多线程操作。
Rollup、Webpack 的代码,两者均未使用 WebWorker 提供的多线程能力。反观 Esbuild,它最核心的卖点就是性能,它的实现算法经过非常精心的设计,尽可能饱和地使用各个 CPU 核,特别是打包过程的解析、代码生成阶段已经实现完全并行处理。
除了 CPU 指令运行层面的并行外,Go 语言多个线程之间还能共享相同的内存空间,而 JavaScript 的每个线程都有自己独有的内存堆。这意味着 Go 中多个处理单元,例如解析资源 A 的线程,可以直接读取资源 B 线程的运行结果,而在 JavaScript 中相同的操作需要调用通讯接口 woker.postMessage 在线程间复制数据。
- Go 在线程之间共享内存,而 JavaScript 必须在线程之间序列化数据。
- Go 和 JavaScript 都有并行的垃圾收集器,但是 Go 的堆在所有线程之间共享,而对于 JavaScript, 每个 JavaScript 线程中都有一个单独的堆。
主要特点:
- 无需缓存的极速
- ES6 和 CommonJS 模块
- ES6 模块的 tree shaking
- 用于 JavaScript 和 Go 的API
- TypeScript 和 JSX 语法
- Source maps
- Minification
- Plugins
Esbuild 功能使用
使用 Esbuild 有 2 种方式,分别是 命令行调用和代码调用。
命令行调用
# 新建项目文件
mkdir learn-esbuild
# 新建一个项目
pnpm init -y
# 安装 esbuild
pnpm add —D esbuild
// src/app.jsx
import * as React from 'react'
import * as Server from 'react-dom/server'
let Greet = () => <h1>Hello, world!</h1>
console.log(Server.renderToString(<Greet />))
pnpm add react react-dom
接着到package.json
中添加build
脚本:
"build": "esbuild src/app.jsx --bundle --outfile=dist/out.js",
说明我们已经成功通过命令行完成了 Esbuild 打包!但命令行的使用方式不够灵活,只能传入一些简单的命令行参数,稍微复杂的场景就不适用了,所以一般情况下我们还是会用代码调用的方式。
代码调用
Esbuild 对外暴露了一系列的 API,主要包括两类: Build API
和Transform API
,我们可以在 Nodejs 代码中通过调用这些 API 来使用 Esbuild 的各种功能。
// build.js
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', result);
}
runBuild();
node build.js
在项目打包方面,除了build
和buildSync
,Esbuild 还提供了另外一个比较强大的 API——serve
。这个 API 有 3 个特点。
- 开启 serve 模式后,将在指定的端口和目录上搭建一个
静态文件服务
,这个服务器用原生 Go 语言实现,性能比 Nodejs 更高。 - 类似 webpack-dev-server,所有的产物文件都默认不会写到磁盘,而是放在内存中,通过请求服务来访问。
- 每次请求到来时,都会进行重新构建(
rebuild
),永远返回新的产物。
// build.js
const { build, buildSync, serve } = require("esbuild");
function runBuild() {
serve(
{
port: 8000,
// 静态资源目录
servedir: './dist'
},
{
absWorkingDir: process.cwd(),
entryPoints: ["./src/index.jsx"],
bundle: true,
format: "esm",
splitting: true,
sourcemap: true,
ignoreAnnotations: true,
metafile: true,
}
).then((server) => {
console.log("HTTP Server starts at port", server.port);
});
}
runBuild();
我们在浏览器访问localhost:8000
可以看到 Esbuild 服务器返回的编译产物如下所示:
单文件转译——Transform API
Esbuild 还专门提供了单文件编译的能力,即Transform API
,与 Build API
类似,它也包含了同步和异步的两个方法,分别是transformSync
和transform
。
// transform.js
const { transform, transformSync } = require("esbuild");
async function runTransform() {
// 第一个参数是代码字符串,第二个参数为编译配置
const content = await transform(
"const isNull = (str: string): boolean => str.length > 0;",
{
sourcemap: true,
loader: "tsx",
}
);
console.log(content);
}
runTransform();
Esbuild 插件开发
插件开发其实就是基于原有的体系结构中进行扩展
和自定义
。 Esbuild 插件也不例外,通过 Esbuild 插件我们可以扩展 Esbuild 原有的路径解析、模块加载等方面的能力,并在 Esbuild 的构建过程中执行一系列自定义的逻辑。
Esbuild
插件结构被设计为一个对象,里面有name
和setup
两个属性,name
是插件的名称,setup
是一个函数,其中入参是一个 build
对象,这个对象上挂载了一些钩子可供我们自定义一些钩子函数逻辑。以下是一个简单的Esbuild
插件示例:
let envPlugin = {
name: 'env',
setup(build) {
build.onResolve({ filter: /^env$/ }, args => ({
path: args.path,
namespace: 'env-ns',
}))
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
contents: JSON.stringify(process.env),
loader: 'json',
}))
},
}
require('esbuild').build({
entryPoints: ['src/index.jsx'],
bundle: true,
outfile: 'out.js',
// 应用插件
plugins: [envPlugin],
}).catch(() => process.exit(1))
钩子函数的使用
onResolve
钩子 和 onLoad
钩子
在 Esbuild 插件中,onResolve
和 onload
是两个非常重要的钩子,分别控制路径解析和模块内容加载的过程。
这两个钩子函数中都需要传入两个参数: Options
和 Callback
。
Options
是一个对象,对于onResolve
和 onload
都一样,包含filter
和namespace
两个属性,类型定义如下:
interface Options {
filter: RegExp;
namespace?: string;
}
filter
为必传参数,是一个正则表达式,它决定了要过滤出的特征文件。
namespace
为选填参数,一般在 onResolve
钩子中的回调参数返回namespace
属性作为标识,我们可以在onLoad
钩子中通过 namespace
将模块过滤出来。如上述插件示例就在onLoad
钩子通过env-ns
这个 namespace 标识过滤出了要处理的env
模块。
除了 Options 参数,还有一个回调参数 Callback
,它的类型根据不同的钩子会有所不同。相比于 Options,Callback 函数入参和返回值的结构复杂得多,涉及很多属性。
在 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: []
}
}
在 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: []
}
});
还有onStart
和onEnd
两个钩子用来在构建开启和结束时执行一些自定义的逻辑,使用上比较简单,如下面的例子所示:
let examplePlugin = {
name: 'example',
setup(build) {
build.onStart(() => {
console.log('build started')
});
build.onEnd((buildResult) => {
if (buildResult.errors.length) {
return;
}
// 构建元信息
// 获取元信息后做一些自定义的事情,比如生成 HTML
console.log(buildResult.metafile)
})
},
}
在使用这些钩子的时候,有 2 点需要注意。
- onStart 的执行时机是在每次 build 的时候,包括触发
watch
或者serve
模式下的重新构建。 - onEnd 钩子中如果要拿到
metafile
,必须将 Esbuild 的构建配置中metafile
属性设为true
。
参考:
esbuild 源码:github.com/evanw/esbui…
esbuild 文档:esbuild.github.io/
转载自:https://juejin.cn/post/7182495384197922873