前端构建工具测评

2022-04-02 更新:增加测评 JS Compiler 的测试数据

2022-03-21 更新:增加测评 Bundler 和 JS Minifier 的测试数据


前端构建工具已经形成了一个或多个复杂的生态系统。从最基础的 webpack, rollup 和 babel, esbuild, swc 等工具库,衍生出各种各样的插件,封装组合成 crate-react-app, vite, parcel, snowpack 这样通用解决方案,进而发展出 Next.js, ICE.js, UMI.js 这种特异化的行业解决方案。

这个生态中的每个部分,都有其替代品。比如 swc 可以替代 babel, rollup 可以替代 webpack。swc 确实比 babel 快得多,但是插件生态不如 babel 全面,两者互有胜负。项目的实际需求,决定了构建工具的选型。

随着前端工程越来越大,构建性能和热加载性能逐渐下降,大型项目不得不面临两个选择:

  1. 对仓库/应用进行拆分,采用微前端等复杂技术整合多个子应用。这样固然能够减少每次发布构建的时间,但是会增加开发人力开销。由于影响了项目架构,严重依赖人工,无法大批量复用到多个项目。
  2. 提升构建工具的性能,比如用 esbuild 这种原生程序替代 babel 这种 Node 程序,用 Vite 这种 Bundleless 工具替代 webpack-dev-server。相比迁移项目架构,改造构建工具的成本更低,完全可以通过迁移脚本自动化。

这篇文章会介绍和评测当前各种流行的构建工具,从底层库到完整解决方案,帮助大家选择和定制最适合自己的方案。

Bundler

Bundler 是能够通过 import/export 将很多 JavaScript 文件,打包成一个/几个文件的工具。在前端开发中,也经常会 Bundle 其他文件类型,比如 CSS/LESS/SCSS,以及图片和字体。这些部分通常是以 Bundler 插件的形式提供。因此 Bundler 通常是一个平台,可以通过插件来扩展特性。

Webpack 是最流行的 Bundler。Rollup,Parcel 则是后起之秀。以编译器见长的 esbuild 和 swc 也提供了 Bundle 能力,但是插件生态不足。

特性比较

spack 还处在实验阶段,功能缺失非常多,因此不建议在任何实际项目中使用。

esbuild 稍好一些,如果你不需要 CSS Modules,不用代码分割,不需要支持 IE,也不需要输出 UMD,那么 esbuild 是可以满足你的需求的。 对于个人项目来说,这完全是可行的。对于企业项目,esbuild 作为 bundler 显然还不够全面,但它作为编译器和 Rollup 配合使用还是很好用的。

parcel,rollup 和 webpack 是功能最为全面的,能够应对绝大多数前端项目需求。webpack 的插件生态最丰富,兼容性也最好。rollup 和 parcel 则更加轻量化。Parcel 的 UMD 打包格式缺失,不适合用于组件库或微前端构建,但是对普通应用还是够用的。

webpackrollupparcelesbuildspack
CSS Modules✔️✔️✔️✔️
LESS✔️✔️✔️✔️
Sass✔️✔️✔️✔️
代码分割✔️✔️✔️
CJS 输出格式✔️✔️✔️✔️✔️
ESM 输出格式✔️✔️✔️✔️✔️
UMD 输出格式✔️✔️

性能比较

测试打包 antd + lodash + react + react-dom + three.js 的速度。为了控制变量,统一采用 esbuild 作为 minifier(spack 不支持除外)。

详细测试数据见 https://github.com/guoyunhe/benchmark-bundlers

webpackrollupparcelesbuildspack
ThinkPad T4805.347s14.548s6.069s0.249s1.082s
MacBook 16, 20194.229s12.790s5.225s0.227s1.012s

esbuild 是用 Go 语言编写,spack(swcpack)是用 Rust 语言编写。两者都编译成二进制文件运行,速度比其他 JS 编写的 Bundler 快几十倍。从 20s 到 0.2s 已经是一个质的飞跃了。

相比之下,传统的 JS Bundler 就慢得多了。令人意外的是,被很多人认为过时了的 webpack 居然比 parcel 和 rollup 更快。尤其是 rollup 的性能,令人意外地差。我们分析是因为 lodash 和 react 都是 CJS 模块,rollup 需要将其转化成 ESM 才能使用,因此比 webpack 慢了一倍多。

综合评价

目前 webpack 仍然是最全面的 Bundler 选择,支持特性最丰富,生态最为庞大。而且它的性能并不差,在应用打包场景要优于 rollup 和 parcel。

而放眼未来,esbuild 的潜力最大。它的性能无与伦比,且具有成熟的插件接口。它的代码分割能力已经在测试中,前途一片光明。甚至 Vite 也希望在 esbuild 成熟之后,移除 rollup 而直接用 esbuild 打包。

JS Minifier

Production 构建需要对 JS 文件和 CSS 文件进行 Minify 处理,让生成的文件更小,加载更快。JS 的 Minifier 包括 terser,esbuild,swc。衡量它们优劣的主要指标是速度和压缩率。

以下测试用 webpack + terser/esbuild/swc 打包和压缩 antd + lodash + react + react-dom+three.js。详情见 https://github.com/guoyunhe/benchmark-js-minifiers

no-minifyterseresbuildswc
产物尺寸4.7MB1.8MB1.9MB1.9MB
运行时间 ThinkPad T4804.677s17.046s5.615s8.997s
运行时间 MacBook 16, 20193.556s13.834s4.052s6.892s

esbuild 和 swc 在速度上大幅度领先 terser。对于这类高计算量任务,Node.js 的性能比原生程序差得多。同为原生程序,esbuild 比 swc 的性能更好,说明它的设计更优秀。而从压缩率上来看,terser 略好于 esbuild 和 swc 但差距不大。因此综合来看,我认为 esbuild 是最佳的 minifier。

JS/TS Compiler

我们在开发中喜欢使用最新的 ES 语法,但在发布时需要兼容旧的浏览器。JSX 和 Vue 文件还包含了特殊语法,即使最新的浏览器也无法识别和运行。因此越来越多的前端项目需要用 JS Compiler 或者叫 Transpiler 来转换源码。这也是一项非常耗时的任务。目前最广泛使用的编译器是 babel,它功能全面,但性能堪忧。而 esbuild 和 swc 则是以速度见长的面向未来的编译器。TypeScript 有自己的编译器 tsc,支持 TypeScript 的类型系统检查,但是它的效率极差。因此 Babel, esbuild 和 swc 都提供了自己对 TypeScript 转译的支持。

特性比较

从特性上来看,babel 仍然是最全面的。esbuild 和 swc 则注重对最新 ES 标准的支持,因此少了对非标准的 JS 修饰器语法(但是支持 TS 修饰器语法)和 Flow 语言的支持。esbuild 不兼容 ES5 和 IE 浏览器,但某些项目仍然有这方面的需求,开发者需要平衡利弊。

babelesbuildswc
ES Next
ES5, IE11
JS 修饰器语法
Flow 语言
TypeScript 语言
TS 修饰器语法
JSX

性能比较

以下测试用 Webpack + babel-loader/swc-loader/esbuild-loader 转译 antd + three.js 源码到 ES5 语法,不进行 minify。详见

babelesbuildswc
运行时间 ThinkPad T48010.133s0.464s0.549s
运行时间 MacBook 16, 2019

综合评价

如果不需要支持 IE11,那么 esbuild 是最好的选择,它的性能非常好。

如果需要支持 IE11,那么推荐 swc,性能接近 esbuild 且浏览器兼容性更好。

只有当 esbuild 和 swc 都无法支持你的项目需求时(比如用到了 JS 修饰器语法),再考虑 babel。babel 虽然功能最为全面,但性能比 esbuild 和 swc 慢几十倍,已经无法支持大型项目了。

Dev Servers

Webpack Dev Server

Webpack Dev Server 的出现,极大提升了 React 应用开发的效率:修改代码时,组件会自动被替换,无需手动刷新,也不会重新加载页面。然而早期的 Webpack 和 Webpack Dev Server 每次修改文件都会重新编译打包,给人越来越慢,又无能为力的痛苦感受。Webpack 5 和 Webpack Dev Server 4 已经在性能方面有了极大的改善。中小型项目的瓶颈大多不是 Webpack 本身造成,而是上面介绍过的 Compiler 和 Minifier。如果结合 swc 和 esbuild,Webpack Dev Server 仍然很能打。

但是在大型应用场景,Webpack Dev Server 还是有不足的。想象一下,你有一个包含几万行代码的 SPA 应用,且没有做代码分割。Webpack Dev Server 会将所有代码编译打包之后才能启动。这样一来,启动速度就会变得很慢。但实际上,我们每次只开发项目中很小的一部分,余下的页面/弹窗等组件并不需要提前编译加载。

但是这也并不是我们一定要抛弃 Webpack Dev Server 理由。大型应用场景,也极少硬着头皮上 SPA,而是用微前端框架将应用做进一步的切分。开发时 Webpack Dev Server 只需要运行子应用即可。

但是如果我们就是要做巨石 SPA 应用呢?在本地开发场景下,可以利用浏览器原生的 ES Module 能力动态地按需加载模块,而不需要编译打包所有内容。这样是不是就可以解决我们上面遇到的那个问题了呢?理论上行得通,但实际效果不尽如人意。

Snowpack

Snowpack 就是这样一个设计。理论上动态加载本地 JS 文件几乎没有延迟。但是这些模块并不都能直接在浏览器中运行。比如有很多 NPM 依赖是 CJS 而非 ESM 模块,我们写的代码有 JSX 和 TypeScript,更不要说还有 Sass,LESS,CSS Modules 这些要处理。因此动态加载每个文件时,都有一定的等待时间,而且无法并行处理。结果是在冷启动过程奇慢。在中小型项目开发中,不及 Webpack Dev Server 有效率。大型项目。而且,它并不能构建部署生产环境,仍然需要使用 Webpack 或 Rollup 来补足,无疑增加了项目维护成本。

因此在实际项目中,很难看到 Snowpack 的身影。Snowpack 本身的开发也陷入了很长时间的停滞不前。不过,Snowpack 的思路被 Vite 和 Parcel 2 所继承并改进,取得了成功。

Vite

Vite 给出了更好的答案:将依赖模块预先用 esbuild 进行 Bundle 以快速加载,项目模块采用 Unbundle 模式动态编译加载。

但是,Vite 对不同前端框架的支持程度是不同的,实际运行效率也差别很大。它的 Vue 插件用 esbuild 编译,速度极快。而它的 React 插件用的是 Babel 编译,速度一般。

另外我们发现,Vite 的缓存校验机制存在比较大的问题。更新 node_modules 中的依赖之后,Vite 缓存并没有更新。在开发过程中只能禁用缓存,这导致开发效率进一步降低。

Parcel 2

Parcel 2 和 Vite 的思路类似,但是有两个重要改进:

  • 缓存范围更广,效率极高,热启动在 1s 内
  • 采用了 swc 而非 babel 编译,构建和刷新速度更快

这两个优势使得 Parcel 2 在几乎各个性能指标上都领先于其他竞争对手。

性能比较

TODO

TypeScript 枚举 Enum

JavaScript 本身是没有枚举的,Enum 是 TypeScript 带来的特性。

虽然早在 TypeScript 2.4 就已经提供了 Enum 但是并没有被广泛使用。TypeScript 一开始就有 Union Type,且语法更简单。因此 Enum 在 TypeScript 并不是必需的,出现较晚,且语法更加复杂,阻碍了它的流行。

但是,在大型项目中,Enum 有很多不可替代的优势,结合编辑器和IDE的功能,让类型的使用和维护更加轻松。

和 Union Type 比较

在 Enum 诞生之前,我们经常使用 Union Type 来表示枚举类型:

type Role = 'superadmin' | 'admin' | 'editor' | 'contributor';

const myRole: Role = 'editor';

如果改写成 Enum 的话:

enum Role {
  SuperAdmin = 'superadmin',
  Admin = 'admin',
  Editor = 'editor',
  Contributor = 'contributor',
}

const myRole = Role.Editor;

乍一看是不是代码繁琐了很多?那为什么我们还需要用 Enum 呢?Enum 和 Union Type 相比有两个优点:

  1. 可以对每个枚举成员添加注释
  2. 可以快速且精准地修改值和重命名

项目越大,类型也就会越多,相应的术语也就会越多。对每个枚举值成员添加注释,能够帮助新加入的开发者快速熟悉项目。尤其是国内的项目,中文的项目文档和英文代码之间的联系,注释必不可少。

/** 用户角色 */
enum Role {
  /** 超级管理员,可以增删管理员,配置和删除项目 */
  SuperAdmin = 'superadmin',
  /** 管理员,可以管理除了管理员外的成员角色 */
  Admin = 'admin',
  /** 编辑,可以直接编辑内容 */
  Editor = 'editor',
  /** 贡献者,可以参与讨论和提交补丁 */
  Contributor = 'contributor',
}

const myRole = Role.Editor;

当我们需要修改枚举值时,比如将 superadmin 改为 owner,Enum 就会非常简单。只需要将枚举值更改,而不需要去更改其他引用的地方。然而对于 Union Type,我们要小心翼翼地查找和替换每个出现的地方。加入我们想把 SuperAdmin 替换成 Owner,也只需要用 IDE 和编辑器提供的重命名/重构功能,快速而精确地重命名。这种维护的便利,也是 Enum 最大的魅力。

单元测试:从 Jest 到 Karma+Mocha+Chai

我所参与过的 Web 项目大部分都使用 React 框架。Jest 是我们曾经最主要使用的单元测试 (Unit Test) 框架。Jest 配置足够简单,功能也足够丰富。但是随着前端开发逐渐进入深水区,Jest 的短板开始逐渐显现。我在经过探索和尝试后,迁移到了 Karma+Mocha+Chai 的解决方案。在此分享这两种技术方案的区别和取舍,希望对遇到同样问题的同学有所帮助。

继续阅读 →

前端必学&勿学清单(2022更新)

前端技术发展很快,有些技术如常青树,有些则已日渐式微。不断涌现的新技术,有的是真创新,有的是换汤不换药。本文希望帮助大家避免在没有学习价值的技术上浪费事件。

需要注意的是,仅凭个人经历去判断一项技术是不是不流行,是很片面的。很多人在整个职业生涯中都没有遇到过 Angular 项目,但这并不代表 Angular 没有人用。有人觉得 Vue 小众,是玩具,但是其生态仍然在蓬勃发展。本文尽量从技术架构和统计数据来分析,而非个人经验。

更新于 2022 年二月。

继续阅读 →

编程英语基础

编程是一门语言艺术。要写出赏心悦目的代码,最重要的便是恰当的命名变量,函数,类和包等。每种编程语言都自己独特的语法和代码风格。比如 C 语言常用 snake_case 命名,而 Java 语言用 camelCase 命名。这篇文章将不去探讨具体语言的特有规范,而是去介绍通用的命名的英语语法,比如变量类型与词性的关系,词组和短语的顺序,省略的用法,近义词的辨析等。示例以 Java/TypeScript 为主。正确示例标记为✔️,错误示例标记为❌。

本文有待完善,欢迎读者意见。

继续阅读 →

position: fix 没用?你大概是被 transform 坑了

在 CSS 里很多位置布局都是相对于容器的。但是 position: fixed; 比较直白,只相对于窗口,通常不会被干扰。但是我最近就遇到了这么个问题:

transform:scale(1);
position:fixed; right:10px; bottom:10px;

这不对呀,我明明写的是 position: fixed; 为啥没用呢?

于是我一个一个翻看父元素的 CSS 布局属性,发现一个 Modal 库有个奇怪的 transform: scale(1); 。这个属性没有任何视觉的效果,显然是动画执行之后留下的。然而在技术实现上,即使是 scale(1)translateX(0) 这种没有任何效果的 transform ,也会重建一个坐标系,导致内部元素的 position:fixed 不再相对于窗口,而是相对于这个 transform 元素。

这是不是某个浏览器实现的缺陷呢?实际测试 Firefox 和 Chrome 都是一样的效果。这也许是一个 Web 标准中比较含糊的灰色区域。

解决方法比较简单粗暴,把 transform: scale(1) 改成 transform: unset 即可。在实践中,尽量避免对比较大的容器使用 transform,比如 Sidebar 和 Modal。如果要用的话,则要确保子元素不会用到 position: fixed,比如一些 Popup。

Web 内嵌字体格式

简而言之,WOFF2 是你唯一需要的字体格式。如果你需要支持 IE11,那么就加上 WOFF 作为备用字体格式。

@font-face {
  font-family: 'Source Code Pro';
  font-weight: 400;
  font-style: normal;
  src: local('Source Code Pro'),
    url('source-code-pro-regular.woff2') format('woff2');
}
继续阅读 →

比较 HTML 和 JavaScript Input 验证

如果你想验证表单输入是否有效,比如你想让输入框只接受整数,而不是小数或文字,在 HTML5 中你可以用以下代码轻松实现:

<input type="number" pattern="[0-9]*" />

但是,用户仍然可以输入无效信息:

  1. 在 Firefox 里,你可以输入任何字符:比如“fsielfs”。
  2. 在 Chrome 里,你可以输入一些无效的数字:比如“1..2.2.2”。
继续阅读 →

构建基于 React 的 WebExtension 浏览器扩展

创建一个 React 应用,然后把它转化成一个 Firefox/Chrome 浏览器扩展。

继续阅读 →