模块化编程在前端领域已非常普遍,应用程序中将各种功能细分成独立的模块(单独文件)进行开发。module bundler 将所有文件串联起来变成了必须。
JavaScript 社区中有很多程序的打包工具,如 Webpack、Rollup、Parcle 等,它们都是使用 JavaScript 构建的,性能方面有很多不足。下面要介绍的 Esbuild,采用 Go 语言开发,运行速度得到了显著提高。
Esbuild 也被称为下一代构建工具(使用 Go 语言编写,基于 ESM)。
esbuild:An extremely fast JavaScript bundler
Our current build tools for the web are 10-100x slower than they could be. The main goal of the esbuild bundler project is to bring about a new era of build tool performance, and create an easy-to-use modern bundler along the way.
其定位为一款极快的 JavaScript 打包工具。“极快”是源于同当前市场上比较流行工具的对比(下图来自官方Github)。
在 ESM 出现之前,在浏览器中运行 JavaScript 有两种方法:
第一种方式,引用一些脚本来存放每个功能;此解决方案很难扩展,因为加载太多脚本会导致网络瓶颈;
第二种方式,使用一个包含所有项目代码的大型 .js
文件,但是这会导致作用域、文件大小、可读性和可维护性方面的问题。总之,在浏览器支持 ES 模块之前,没有 JavaScript 的原生机制可以让开发者以模块化的方式开发。这是 webpack 等打包工具诞生的原因之一。
ESM 的出现后及相关主流浏览器的支持,ESM 模块成了首选,因为原生速度要于远远优于其他方式(不再需要引入额外的库来实现模块化)。
本文的重点是要讲述 esbuild,但在讲述之前,不得不提及ESM、Babel 和 Webpack中几个相关联的重要知识 。
ESM
Snowpack 是首次提出利用浏览器原生 ESM 能力的工具。开发过程中,Snowpack 为你的应用程序提供 unbundled server**。**每个文件只需要构建一次,就可以永久缓存。文件更改时,Snowpack 会重新构建该单个文件。在重新构建每次变更时没有任何的时间浪费,只需要在浏览器中进行HMR更新。
ESM
代表 ES
模块。这是 Javascript
提出的实现一个标准模块化解决方案。模块化的出现为了更好的维护代码的同时,对 scope(作用域)有了更好的管理,关于前端模块化的汇总,可以看这里和这里。
<script src="index.js" type="module"></script>
通过 type="module"
告诉浏览器,当前脚本使用 ESM 模式,浏览器会构建一个依赖关系图,借助浏览器原生的 ESM 能力完成模块的查找、解析、实例化到执行的过程。
这里也不再赘述 ESM 的使用方式及相关语法,重点介绍执行机制,详细内容可以看这篇:
-
Parsing(解析): 递归(深度优先后序遍历)的加载所有导入的模块,构建一个依赖关系图。每一个模块都只有一个module record,这也保证了每一个模块只会被执行一次。
-
Instantiating(实例化): 对于每个新加载的模块,都会创建一个模块实例,并使用该模块中
export
的内容的 内存地址 对import
进行映射(导出的模块和导入的模块都指向同一段内存地址–实时绑定)。 -
Evaluating(求值): 运行每个新加载模块的主体代码。
所有模块的静态依赖在该模块代码执行前都必须下载、解析并进行实例化。
浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入的代码,即只在当前屏幕上实际使用时才会被处理。
因此,引出了使用 ESM 最核心的两个特点:
1、构建复杂度非常低,修改任何组件都只需做单文件编译(不需要重新构建和重新打包应用程序的整个bundle),时间复杂度永远是 O(1)
2、借助 ESM 的能力,模块化交给浏览器端,不存在资源重复加载问题,如果不是涉及到 jsx 或者 typescript 语法,甚至可以不用编译直接运行
更加详细的,可以阅读 为什么选vite
Babel
Babel 是一个 JavaScript 编辑器,将采用 ECMAScript 2015+ 语法编写的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。
重点介绍工作过程:
- 「Parse(解析)」:将源代码转换成更加抽象的表示方法(如抽象语法树)
- 「Transform(转换)」:对(抽象语法树)做一些特殊处理,让它符合编译器的期望
- 「Generate(代码生成)」:将第二步经过转换过的(抽象语法树)生成新的代码
webpack
Webpack 的构建流程简单来说就是递归编译每一个模块文件,对于不同类型的文件使用不同的 webpack loader 进行处理。并可以自动构建并基于你所引用或导出的内容推断出依赖的图谱。
Webpack 在很多方面处理的很好,特别是在大型项目中得到了实战测试,已成熟并且可以处理很多用例。
esbuild-loader 是一个构建在 esbuild 上的 webpack loader,且可以替代 babel-loader 或 ts-loader 来提高构建速度。
module.exports = {
module: {
rules: [
- {
- test: /\.js$/,
- use: 'babel-loader',
- },
+ {
+ test: /\.js$/,
+ loader: 'esbuild-loader',
+ options: {
+ loader: 'jsx', // Remove this if you're not using JSX
+ target: 'es2015' // Syntax to compile to (see options below for possible values)
+ }
+ },
...
],
},
}
esbuild
esbuild 重点提到的就是构建速度方面,为什么会比 webpack 快呢?而且不在同一个数量级。
-
Go 语言与 JavaScript 语言差异(webpack 采用 JavaScript;esbuild 采用 Go)
- Go 是为并行性而设计的,而 JavaScript 是单线程的
- Go 在线程之间共享内存,而 JavaScript 必须在线程之间序列化数据
- Go 可直接编译成机器码,不依赖其他库,必然比 JIT 快(JIT相关看这里)
-
对构建流程进行了优化,充分利用 CPU 资源
- 解析 => 链接 => 代码生成。解析和代码生成采用并行化
- 当导入同一 JavaScript 的不同入口时,可以轻松共享(线程间共享内存)
-
尽量少做全 AST 传递以获得更好的缓存局部性(esbuild 中只有三次全量 AST pass)
- Lexing + parsing + scope setup + symbol declaration
- Symbol binding + constant folding + syntax lowering + syntax mangling
- Printing + source map generation
扫描阶段: 这个阶段从一组入口点开始,遍历依赖图以找到需要在包中的所有模块。这是bundler.ScanBundle()
作为并行工作列表算法实现的。列表中的每个文件都在单独的 goroutine 上被解析为 AST,如果它有任何依赖项(ES6import
语句、ES6import()
表达式或 CommonJSrequire()
表达式),可能会向工作列表添加更多文件。扫描继续,直到工作清单为空。
编译阶段: 这个阶段为每个入口点创建一个包,这涉及首先“链接”导入和导出,然后将解析的 AST 转换回 JavaScript,然后将它们连接在一起形成最终的 bundle。这发生在(*Bundle).Compile()
.
示例
index.js
import {add} from './math.js'
let result = add(1,2)
console.log(result)
math.js
const add = function (p1, p2) {
return p1 + p2;
}
const sub = function (p1, p2) {
return p1 - p2;
}
export { add, sub }
构建:
$ npx esbuild src/index.js --bundle
结果:
(() => {
// src/math.js
var add = function(p1, p2) {
return p1 + p2;
};
// src/index.js
var result = add(1, 2);
console.log(result);
})();
默认输出 iife 格式,也可以通过 --format
指定其他格式输出(cjs、esm)
$ npx esbuild src/index.js --bundle --format=cjs
$ npx esbuild src/index.js --bundle --format=esm
针对 esm 格式,也支持 Tree Shaking
$ esbuild index.js main.js --bundle --splitting --outdir=dist --format=esm
main.js 内容同上述 index.js
import {add} from './math.js'
console.log(add(3,4))
结果
import { add } from "./chunk-2YHQ3R6P.js";
// main.js
console.log(add(3, 4));
chunk-2YHQ3R6P.js
// math.js
var add = function(p1, p2) {
return p1 + p2;
};
export { add };
esbuild 构建默认开启了 Tree shaking(sub 并没有被编译输出),还可以进行 minify、 sourcemap,指定 platform ,以及 watch(监视模式)等等。具体可以查看官方 API
缺点
当然 esbuild 不是万能的,由于其为了保证编译效率,并没提供 AST 的操作能力,所以对一些处理 AST 的 plugin(如 babel-plugin-import) 暂时不能过渡到 esbuild 中。
总结
在当前前端环境中,直接使用 esbuild 代替 webpack 不现实;主流方案是在 webpack 中使用 esbuild 去做一些代码的 transform (代替 babel-loader)。当然随时后续 vite(采用 esbuild 预构建依赖) 、snowpack 等构建工具的发展,其会在某些场景下替代 webpack(vue3 的官方推荐构建工具为 vite)。esbuild 针对构建 应用 的重要功能仍然还在持续开发中 —— 特别是代码分割(可以获得最佳的加载性能)和 CSS 处理方面。当未来这些功能稳定后,不排除使用 esbuild
作为生产构建器的可能。
vite 中引用 esbuild: https://github.com/vitejs/vite/blob/main/packages/vite/src/node/optimizer/index.ts#L5
webpack 在 v5 版本中也是针对编译的性能做出了不少努力,除了提供了物理缓存的优化之外,还提供 Module Federation 的方案,这给我们上层的应用实践带来了很多想象的空间。以前 webpack 大有一统构建工具的趋势,而现在我们可以结合业务的特点有更多的选择。
最后
通过 ESM 构建,提到 esbuild,还有一个 swc;esbuild 采用 go 语言编写,而 swc 采用 rust 语言编写。他们的速度都比目前市面上成熟的打包工具要快太多,带来性能提升的关键是底层编写语言的天生特性导致。关于这个话题esbuild为什么不用Rust,而使用了Go? 在知乎上有专门讨论,感兴趣的小伙伴可以查看一下。