下一代前端构建工具

2022 年,Webpack + Babel + Terser 仍然是前端项目构建工具的主流,广泛用于各种生产环境。不可否认,Webpack 和 Babel 仍然在持续进化,对各类研发提供了最稳定且全面的支持。但是随着前端项目规模的增长,CI 流水线的普及,构建性能问题已经成为了影响开发效率的一个关键因素。下一代构建工具,试图从不同的角度实现对构建效率的提升。

esbuild

用 Go 语言实现的构建工具,提供了编译,打包,压缩能力。由于是原生程序,且几乎不依赖第三方库,充分利用 CPU 多核架构等原因,esbuild 实现了夸张的毫秒级编译速度,效率数百倍于 Webpack + Babel。

优点:

  • 高性能。目前最快的实现,整体上略优于 swc。
  • 低占用。Go 语言对字符串的内存优化很好,即使采用多线程工作方式,内存占用仍然远低于基于 Webpack 的工具。

缺点:

  • 自身不提供 HMR,需要依赖其他工具实现。
  • 不支持 React Refresh,对 React 的 HMR 不能用 webpack-dev-server + esbuild 编译的方案。
  • 产物代码分割功能仍处在实验阶段,相比 Webpack 和 Rollup 还是比较简单,不太可靠。
  • 插件生态不健全。

适用场景:

  • 纯 JS 库构建。不需要 HMR,只需要 watch 能力。
  • Node.js 后端应用构建。不需要 HMR,只需要 watch 能力。
  • Jest 和 SSR 等 Node 运行环境的代码转译。
  • 嵌入其他构建工具。比如 Vite 用了 esbuild 的编译和压缩能力,不足的部分用 Rollup 插件体系和自己实现的 HMR 补全。

swc

用 Rust 语言实现的构建工具,swc 实现了编译和压缩能力,swcpack 实现了打包能力(实验性)。swc 设计之初就是完全对标 Babel 的,相比 esbuild 拥有更广泛的适用场景。

优点:

  • 高性能。实际测试略慢于 esbuild,但也比 webpack+babel+terser 快上几十倍。
  • 支持 React Refresh。这一点是对 esbuild 的巨大优势,且短期内 esbuild 无法克服的问题。

缺点:

  • 自身不提供 HMR,需要依赖其他工具实现。
  • 打包能力还是实验性质,缺乏高级配置支持。
  • 插件生态不健全,用 Rust 编写插件门槛较高。

适用场景:

  • 纯 JS 库构建。不需要 HMR,只需要 watch 能力。
  • Node.js 后端应用构建。不需要 HMR,只需要 watch 能力。
  • Jest (@swc/jest) 和 SSR 等 Node 运行环境的代码转译。
  • 嵌入其他构建工具。比如 Parcel 用了 swc 的编译和压缩能力,不足的部分用自己实现的 HMR 补全,并有 Parcel 自己的插件体系。

Vite

基于 esbuild 的编译和压缩能力,Bundleless 的 HMR 实现,并用 Rollup 插件生态补足了常用的打包特性。目前性能和特性综合考量的最佳选择。

更早的 Bundleless 实现 Snowpack 已经泯然众人了。Bundleless 最大的问题在于,现在前端应用的依赖树太深太广,模块文件太多,逐个文件加载导致启动速度极其缓慢。而 Vite 则是融合了 Bundle 和 Bundleless 两种策略,对 node_modules 还是用 esbuild 去快速 bundle,一次性加载,对于源码再采用 bundleless 策略。这样就平衡了启动速度和热加载速度。

优点:

  • 构建速度更快
  • 热更新速度更快
  • 能够使用众多的 Rollup 插件

缺点:

  • 由于 esbuild 不支持 React Refresh,React 项目还是要走 Babel,一定程度上拖慢了速度。
  • Rollup 及其插件生态还是以 JS 为主,性能优化空间有限。Vite 作者寄希望于 esbuild 打包能力和插件生态成熟,用 esbuild 替换掉 Rollup。

适用场景:

  • Web 前端应用
  • 浏览器插件
  • Electron 应用
  • 小程序

Parcel 2

Parcel 基于 swc 并有自己的 HMR 和插件生态。设计上与 Vite 类似,但 Parcel 自研部分更多,而 Vite 依赖且受限于 Rollup。性能上 Parcel 甚至更快一些,尤其是缓存性能,热启动极快。但在稳定性上 Parcel 控制的并不好,一个最简单的 React 应用都需要一些 hack 才能工作。

优点:

  • 构建速度快
  • 缓存效率高
  • 热更新速度快

缺点:

  • 不兼容 pnpm
  • 插件生态不足

适用场景:

  • Web 前端应用
  • 浏览器插件
  • Electron 应用
  • 小程序

Turbopack(Alpha)

用 Rust 实现 Webpack 替代,工作原理和 Webpack + Webpack Dev Server 保持一致,而没有走时下流行的 bundleless 路线。当项目源码(不包括 node_modules)文件数量达到数千,Vite 启动速度会随文件数量降低,即使本地几乎不存在网络延迟,数以千计的 HTTP 请求以及代码转译任务仍然耗时不菲。而 Turbopack 则通过提升打包性能,在保持 Webpack 工作方式的情况下,实现了快速启动和 HMR。当前 Turbopack 的测试性能是领先 Vite 的。

假如 Vite/Parcel 也用 Rust/Go 完全重写,那么 Vite/Parcel 和 Turbopack 应该具有以下关系:

  • 在文件数量小于 N 时,Vite/Parcel 的冷启动速度更快,但是 Turbopack 也足够快。
  • 在文件数量大于 N 时,Turbopack 的冷启动速度更快,且于 Vite/Parcel 的差距越来越大。
  • Vite/Parcel 的 HMR 速度总是快于 Turbopack,但是两者都足够快,以至于差异肉眼无法分辨。

可惜 Turbopack 目前并没有推出正式版本,完全没有达到 Vite/Parcel 的成熟度,因此完全没有可比性。目前只能关注,深入分析或评测意义不大。

JavaScript 十进制库选型

众所周知,计算机以二进制存储数字,而真实世界中用十进制表示数字。二进制和十进制的差异可以用一个乍看非常怪异的例子说明:

> 0.1 + 0.2
0.30000000000000004

不管是 JavaScript, Python 还是 Java,都能够得到类似的结果。

分析原因,就像十进制中的 1/3 是无限循环小数,二进制中的 1/10 和 1/5 也是无限循环小数。计算机的基本数字类型都是有精度限制的。两个无限循环小数相加,导致精度损失,产生了 0.30000000000000004 这样的结果。

在很多情况下,这个并不会有实际影响。因为十进制同样有精度误差,十进制并不比二进制更精确。它真正导致的问题是违反直觉。对于人直接可见的数字计算,比如购物车结算总价,出现二进制精度问题显然会让不了解二进制原理的普通用户感到诧异。

另外,原生浮点数的取整方法都是四舍五入。按照统计学规律,大量累加起来,总数会偏大。对于电商,银行和支付系统,会导致交易的某一方长期多付出,是违反公平的。因此这些系统常常采用银行家算法(四舍六入五成双)。

最后,整数和浮点数都有固定的精度限制,且受限于硬件或编译器无法更改。计算超出范围的大数会出错,不安全。

基于这三点原因,在电商和金融系统中,会使用十进制类而非基本数据类型来计算和存储交易数字。

JS 常用库比较

JavaScript 目前仍然没有原生的十进制类。我们只能从一些提供十进制类的 NPM 包中选择。不同的十进制库提供的功能不完全相同。通常功能越多体积越大。目前开发比较活跃且比较流行的 JS 库都来自同一位作者。差异详见此处

NPM 包big.jsbignumber.jsdecimal.js
打包尺寸 Minified + Gzipped2.9kB8.1kB12.3kB
精度控制N位小数N位小数N位有效数字
除法精度损失有损有损有损
加、减、乘法精度损失无损无损有损
超大数支持,计算较慢支持,计算较快支持,计算较快
超小数支持,计算较慢支持,计算较慢支持,计算较快
舍入模式ROUND_DOWN
ROUND_HALF_UP
ROUND_HALF_EVEN
ROUND_UP
ROUND_UP
ROUND_DOWN
ROUND_CEIL
ROUND_FLOOR
ROUND_HALF_UP
ROUND_HALF_DOWN
ROUND_HALF_EVEN
ROUND_HALF_CEIL
ROUND_HALF_FLOOR
ROUND_UP
ROUND_DOWN
ROUND_CEIL
ROUND_FLOOR
ROUND_HALF_UP
ROUND_HALF_DOWN
ROUND_HALF_EVEN
ROUND_HALF_CEIL
ROUND_HALF_FLOOR
EUCLID
指数计算支持支持支持
对数计算不支持不支持支持
复数计算不支持不支持支持
三角函数计算不支持不支持支持

从简单到复杂排列:big.js < bignumber.js < decimal.js

从打包体积来看,big.js 最小,在浏览器端占据优势。

在电商和金融场景,更常见的精度控制方法是控制 N 位小数,big.js 和 bignumber.js 更加合适。而在科学计算和工程领域,可能 decimal.js 的控制 N 位有效数字的特性会更合适一些。

除法都是有精度损失的,这是无法避免的。但是 decimal.js 的加减乘法也有精度损失,这一点需要注意。

big.js 对于超大数的计算会比 bignumber.js 或 decimal.js 慢,要结合具体场景分析是否会性能瓶颈。在前端/客户端场景,或者小型电商后端,计算量很小,且大数不常见,那么 big.js 完全可以胜任。在大型电商平台和金融交易后端,大数计算量繁重,那么 bignumber.js 会更好一些。科学和工程领域需要的超大数计算,则 decimal.js 会更合适。(但是这些领域通常也不会用 JS 这么慢的语言,所以 decimal.js 的定位有点尴尬)

超小数通常只在金融,科学和工程领域出现,这种数据的计算 decimal.js 会比 big.js 和 bignumber.js 快。和上面的大数应用场景分析类似,要视情况选择。

默认的舍入模式都是四舍五入(ROUND_HALF_UP),且都支持银行家算法——四舍六入五成双(ROUND_HALF_EVEN)。对于大多数电商和金融场景都足够用了。

decimal.js 还支持对数,复数,三角函数等高级数学运算。

按应用场景选择

中小型电商

中小型电商的交易数字都偏小,big.js 在前后端都可以胜任,满足精度控制,舍入模式和性能的需求。同时 big.js 的体积较小,有利于优化前端传输速度。

大型电商平台和金融系统

前端/客户端通常只处理单一客户的计算需求,大数出现频率较少,通常不存在性能问题。 big.js 依然可以胜任。

后端则可能会频繁处理大数,这时候 bignumber.js 会更快一些。但是,这种情况通常表明整个系统都面临性能问题,更加实际的做法是将重度计算的服务用 Java 之类的更高性能语言重构……

科学计算和工程领域

decimal.js 功能更加丰富,对极大和极小数计算的性能更好,比 big.js 和 bignumber.js 更合适。

但是,这些领域通常对性能要求很高,用 JS 的情况非常少见。建议直接使用一种更合适的语言……

总结

前端选择 big.js 一般都没错。它简单小巧,却又能提供所有我们需要的功能。

后端 Node.js 不需要考虑打包尺寸,如果有比较多的大数计算量,也可以上 bignumber.js 的。

decimal.js 的定位则比较尴尬,除非你特别需要某些科学计算功能,否则没有必要选它。

实践

  1. 阿里巴巴 Fusion Design 从 bignumber.js 切换到 big.js PR 链接