本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
前言
说到构建工具,小编最开始接触的是 Webpack4。后来看到 Vite 大火,又和很多人一样,去研究了一下 Vite。在研究 Vite 的过程中,发现 Vite 内部使用了 Rollup 和 Esbuild 这两个构建工具,于是又去把这两个构建工具了解了一下。
完成整个学习以后,大脑中对目前流行的构建工具有了一套自己的理解,于是就对学习过程中的写的学习笔记做了一个梳理,形成本文。由于本文来源于学习笔记,内容多为干巴巴的文字描述,读起来可能会有一些枯燥,希望阅读的小伙伴能见谅。
本文的目录结构如下:
bundle vs unbundle
当下,流行的构建工具有 Webpack、Vite、Rollup、Esbuild、Parcel 等。
这些构建工具,根据是否会将源文件打包成一个 bundle,可以分为 bundle 类型构建工具和 unbundle 类型的构建工具。
bundle 类型构建工具,典型的有 Webpack、Rollup、Parsel、esbuild。unbundle 类型的构建工具,典型的有 Vite。不过 Vite 当前也只是开发模式下采用 unbundle 模式,生产模式下依旧推荐使用 bundle 模式。之前的 2.x 版, Vite 会借助 Rollup 进行打包,而最新的 3.x 版本, Vite 可以使用 Esbuild 进行打包,保证了开发环境和生产环境的统一。
bundle 模式,最显著的特点就是需要在打包构建时以入口文件为起点,分析各个源文件之间的依赖关系,构建一个模块依赖图,然后根据一定的策略将这个模块依赖图分解为多个 bundle,多个 bundle 之间也存在依赖关系。
在整个过程中,分析源文件的依赖关系是最重要的一个环节,这一环节中需要用到一个在前端中占据重要位置的对象 - AST。
AST,全称是 abstract syntax tree, 即抽象语法树,是对源代码语法结构的树状对象表示。源代码中每一行代码,都可以对应 AST 中的一个树节点。通过遍历 AST 对象,我们就可以知道源文件的内容结构。 AST 的应用范围非常广,我们常用的 babel 转换代码、eslint 代码检测、代码压缩混淆、构建工具依赖分析等功能都是基于 AST 实现的。
通常源文件之间的依赖关系可以归纳为两类:静态依赖 - static dependence 和动态依赖 - dynamic dependence。
典型的 import 'xxx' from 'xxx' 属于静态依赖, import('xxx') 属于动态依赖。
在遍历 AST 节点的过程中,如果节点对应的语句是 import 'xxx' from 'xxx',那就可以收集到一个静态依赖;如果节点对应的语句是 import('xxx'), 可以收集到一个动态依赖。需要注意的是, require('xxx') 也属于静态依赖。收集好的依赖,会被构建工具继续用来做依赖关系的分析,直到所有的源文件都解析完毕为止。这样一个模块依赖图也就构建完成了。
模块依赖图构建完成以后,有一个非常重要的事情要做,就是 tree shaking,即将模块依赖图中并没有用到的模块以及模块中没有用到的 export 移除掉,用来缩小最后 bundle 文件的体积,达到优化的目的。模块级别的移除,称为 module level tree shaking;模块内部语句块的移除,称为 statement level tree shaking。
不管是 Webpack,还是 Parcel、Rollup、Esbuild,都只对符合 ESM 规范的模块进行 tree shaking,符合 Commonjs 规范的模块不做处理。究其原因,是因为符合 ESM 规范的模块,构建工具通过基于 AST 的静态分析,就可以知道源文件中的哪些 export 被其他模块使用,进而可以判断出整个源文件有没有被使用。如果 export 没有被使用,那么对应的代码可以直接移除。如果源文件所有的 export 都没有被使用,那么对应的整个模块就可以从模块依赖图中移除了。
而 Commonjs 规范的模块,无法通过静态分析 AST 准确判断出模块中的哪些 export 有被使用,因此无法做 statement level tree shaking。
这里要举个 🌰:
```
// a.js
const app1 = { ... };
const app2 = { ... };
module.exports = {
app1,
app2
}
// b.js
const app = require('./a.js');
const key = 'app' + 1;
// app2 没有被使用是无法通过静态分析而感知的。
console.log(app[key]);
```
```
// a.js
const app1 = { ... };
const app2 = { ... };
export {
app1,
app2
}
// b.js
import { app1 } from './a.js';
// app2 没有被使用是可以分析出来的
console.log(app1)
```
不过关于 tree shaking,小编还是有一个疑惑,就是如果通过 require 引入的模块完全没有被使用,实际上是可以通过 AST 分析出来的,按道理是可以做 module level tree shaking 的,但当前的构建工具都不支持,😂。这个小编还需要再理解理解。
做完 tree shaking 以后,接下来要做的就是将模块依赖图分离为多个 bundle。之所以要做分离,主要是为了将首屏不需要的资源先分离出来,减小请求文件的体积,达到首屏优化的目的。通常情况下,整个模块依赖图会默认分解为 1 个入口文件所在的 initial bundle(有时候也叫 main bundle) 和多个动态依赖所在的 async bundles。在此之外,Webpack、Parcel、Rollup 都提供了自定义分包配置(Esbuild 目前还不支持),可以由开发人员根据实际情况自定义分包策略。
分离好的 bundle,再经过内容构建、压缩混淆等处理,就可以输出到构建工具 output 配置项指定位置了。
可以看出,在整个打包构建的过程中,构建模块依赖图和分离 bundle 是耗时大户,涉及到 url 解析、文件读写、转换源文件、基于 AST 解析源文件来分析依赖关系、代码压缩等,这就导致项目规模越大,打包耗时越久。这一点在本地开发和发布上线过程中,影响尤为明显,一直备受开发、测试人员吐槽。
针对 bundle 类型构建工具打包构建耗时较久的优化,一直是大家最关心的。
目前,主要的优化策略有:
- 缓存;
- 使用更高效的语言;
- 缩小打包构建的范围;
- 多线程;
缓存是一种非常有效的优化策略。不管是 Webpack5 的持久化缓存,还是 Webpack4 中 loader 的 cache,都是首次构建时将构建内容缓存到本地。等到第二次构建时候,通过对比时间戳或者 md5 等,判断源文件是否发生变化。如果没有变化,就直接复用缓存。复用缓存以后,就不需要对源文件进行 I/O 操作、内容 transform、依赖分析等,节省了大量的时间。
使用高效的语言也是一种有效的策略,比如是 Esbuild 和 SWC。其中 Esbuild 是基于 Go 语言开发的, SWC 是基于 Rust 开发的,这两种语言都比 js 更加高效,能提升打包构建效率。
缩小打包构建的范围,如 Webpack 的 DLL 策略、external 策略、loader 的 include / exclude 配置,可以减少不必要的打包构建。
多线程,如对打包以后的文件做压缩混淆时,可以采用多线程。
这些优化策略,一定程度上可以优化打包构建时间。
聊完 bundle 模式,再来看看 unbundle 模式。
unbundle 模式目前的应用场景主要是本地开发。它最大的特点就是快,dev server 启动往往可在 10s 内完成,而且热更新速度也比 bunlde 类型的构建工具快,给开发人员带来了极致的开发体验。它的核心机制是借助了浏览器对 ESM 规范的支持,源文件的依赖关系由浏览器解析,不再需要构建工具自己去处理,因此也就没有解析源文件的依赖关系、构建模块依赖图、tree shaking、将模块依赖图分离成 bundle 这些步骤,极大的节省了打包构建时间。
由于 unbundle 模式是基于浏览器对 ESM 规范的支持实现的,这就要求浏览器请求的文件必须符合 ESM 规范。在实际项目中,业务代码没有问题,可以完全遵循 ESM 规范开发,但依赖的第三方库就无法保证了,如 react。因此在浏览器发起请求之前,必须对不符合 ESM 规范的第三方库做预处理,将其转变为符合 ESM 规范的代码。另外,为了防止浏览器出现的瀑布流请求情况,影响性能,还需要对符合 ESM 规范的第三方库的多个文件做合并处理,减少 http 请求数量。
这个过程,在 Vite 中称为预构建。
以 Vite 为例, unbundle 模式的工作机制可以总结为:
- 进行预构建,提前将项目的三方依赖格式化为
ESM模块; - 启动一个
node服务; - 打开浏览器,去访问
index.html; - 基于浏览器已经支持原生的
ESM模块, 逐步去加载入口文件以及入口文件的依赖模块;
不过需要注意的是,不管是 bundle 模式,还是 unbundle 模式,源文件转换都是必不可少的一步。
原因是我们的源文件可能是各种五花八门的格式,如 tsx、vue、ts、scss、less 等。这些格式目前还不能被浏览器直接支持,需要转换成浏览器支持的 js、css 等格式。转换过程是通过 loader 处理(不同的构建工具,叫法不同。Webpack、Esbuild 是 loader, Rollup、Parcel、Vite 是 plugin)。
不同的是,bundle 模式下转换过程是在 build 过程中完成,是打包构建耗时较久的罪魁祸首之一。而 unbundle 模式下,转换过程是在浏览器发起请求以后,由 server 端借助 middleware 中的 plugin 完成的。由于转换过程也需要消耗一定的时间,导致 unbundle 模式下,首屏和懒加载的性能会有一定的影响。
构建工具的发展
构建工具的发展,其实是有脉络可循的。
首先是 Webpack 的出现,它奠定了现代静态打包的工作模式 - bundle 机制,后来的 Rollup、Esbuild、Parcel 都是基于 bundle 机制实现的,具有跨时代的意义。
不过 Webpack 虽好,但它打包出来的代码含有大量的 polyfill 代码,并不适合用作组件库的开发。这个时候,随着 ESM 规范的成熟,Rollup 出现了。Rollup 推崇 ESM 标准开发,打包出来的代码符合 ESM 规范,非常干净。并且借助 ESM 规范,可以实现 tree shaking 功能。
借鉴 Rollup 的 tree shaking 功能, Webpack 发布了 2.0 版本, 也实现了自己的一套 tree shaking 功能。
除了不适用组件库开发外,Webpack 另一个让人诟病的点是配置太灵活了,这对开发人员来说门槛实在是太高了。这时,Parcel 出现了。Parcel 号称零配置,内部封装了构建过程中许多通用处理过程,对新人来说特别友好。
基于此,Webpack 也与时俱进,发布了 4.0 版本。在 4.0 版本中,Webpack 给 development 和 production 模式都分别定义了默认的配置,大大减少了开发人员的心智负担。
另外,Webpack 还有一个让人抓挂的点,就是随着项目规模的变大,打包构建时间会越来越长。而出现这个问题的根本原因,就是其 bundle 机制导致的。为了从根本上解决这个问题,业内提出了 nobundle 概念,并开发了相应的工具 - Vite 、Snowpack。nobundle 类型的构建工具,通过浏览器对 ESM 规范的支持,不再需要做打包构建,极大的提升了开发体验。
到这一步,Webpack 是没有办法借鉴了,因为 nobundle 机制本质上是和 bundle 机制相互矛盾的。但是 Webpack 依旧在原有的基础上做了优化,增加了持久化缓存、module federation 等功能。虽然无法在开发模式下和 Vite 竞争,但依旧凭着其强大的功能、丰富的生态、持续的迭代,占据着构建工具中最大的一块蛋糕。
这么多的构建工具,并不能一概而论的说谁好谁坏,没必要踩这个捧那个,只能说各有优缺点,各有自己的应用场景。
Parcel:
- 特性:号称零配置,支持
js/jsx/tsx、css、html、vue、图片等文件类型,支持code splitting、tree shaking、压缩、devServer、hmr、hash等; - 优点:零配置;在
js、css的转译上使用了Rust,效率提升; - 缺点:扩展性不强,不太适合有大量定制化需求的项目;生态较差;
Rollup:
- 特性:
Rollup推崇ESM模块标准开发,这个特点借助了浏览器对ESM的支持; - 优点: 打包的代码比起
Webpack来干净的很多,是作为组件库开发的首选;生态丰富; - 缺点: 和
Webpack一样,分离模块依赖关系借助acorn,速度较慢;浏览器兼容性问题;
Vite:
- 特性:开发模式下借助浏览器对
ESM的支持,采用nobundle的方式进行构建,能提供极致的开发体验;生产模式下借用Rollup或者Esbuild进行构建; - 优点:开发模式下
dev server启动和热更新都比Webpack快, 开发体验好; - 缺点:目前生态还不如
Webpack;也有一定的上手成本;本地开发模式启动以后,首屏、懒加载响应速度对比Webpack会慢;二次预构建会对开发体验造成影响;
Webpack:
- 优点: 大而全;生态丰富;配置多样、灵活;
- 缺点: 上手成本较高;随着项目规模的变大,构建速度越来越慢;无法打包出符合
ESM规范的代码;开发组件库时,最后的打包结果汇中冗余代码较多;
Esbuild:
- 特性:
Go语言开发,可以多线程打包,代码直接编译成机器码(不用先解析为字节码),可充分利用多核cpu优势; - 优点:快;
- 缺点: 无法修改
AST,防止暴露过多的api而影响性能;不支持自定义代码拆分;产物无法降级到es5之下;
结束语
由于篇幅的关系,关于构建工具的理解,到这里就先告一段落了。其实还有很多东西并没有梳理到,小编会持续跟进,在之后的文章中再做总结。如果小伙伴有其他感兴趣的点,或者小编有梳理的不对的地方,欢迎在留言区评论哦。
如果大家觉得本文还不错,那就给小编点个赞吧,哈哈!
传送门
往期构建相关文章: