构建工具对比与模块化规范
Published in:2024-04-15 | category: 前端 工程化

前端工程的痛点

前端的模块化需求

前端业界的模块标准非常多,包括 ESM、CommonJS、AMD 和 CMD 等等。
前端工程一方面需要落实这些模块规范,保证模块正常加载
另一方面需要兼容不同的模块规范,以适应不同的执行环境

兼容浏览器,编译高级语法

由于浏览器的实现规范所限,只要高级语言/语法(TypeScript、 JSX 等)想要在浏览器中正常运行,就必须被转化为浏览器可以理解的形式。这都需要工具链层面的支持,而且这个需求会一直存在。

线上代码的质量问题

和开发阶段的考虑侧重点不同,生产环境中,我们不仅要考虑代码的安全性、兼容性问题,保证线上代码的正常运行,也需要考虑代码运行时的性能问题
由于浏览器的版本众多,代码兼容性和安全策略各不相同,线上代码的质量问题也将是前端工程中长期存在的一个痛点。

开发效率

开发效率也不容忽视。我们知道,项目的冷启动/二次启动时间热更新时间都可能严重影响开发效率,尤其是当项目越来越庞大的时候。因此,提高项目的启动速度热更新速度也是前端工程的重要需求。

前端构建工具如何解决痛点

  • 模块化方面,提供模块加载方案,并兼容不同的模块规范。
  • 语法转译方面,配合 Sass、TSC、Babel 等前端工具链,完成高级语法的转译功能,同时对于静态资源也能进行处理,使之能作为一个模块正常加载。
  • 产物质量方面,在生产环境中,配合 Terser 等压缩工具进行代码压缩和混淆,通过 Tree Shaking 删除未使用的代码,提供对于低版本浏览器的语法降级处理等等。
  • 开发效率方面,构建工具本身通过各种方式来进行性能优化,包括使用原生语言 Go/Rust、no-bundle 等等思路,提高项目的启动性能和热更新的速度。

Vite 为什么是当前最高效的构建工具

到底哪个工具更好用?或者说,哪个工具解决前端工程痛点的效果更好?
The State of JavaScript Survey 最近的调查结果中显示, Vite 在全球开发者中的满意度超过 98%,已经被用到了 SvelteKit、Astro 这些大型框架中,成为当下最受瞩目的前端构建工具。我也最推荐你使用它。为什么是 Vite 呢?我们可以根据上面说的四个维度来审视它。

开发效率

开发效率方面,传统构建工具普遍的缺点就是太慢了,与之相比,Vite 能将项目的启动性能提升一个量级,并且达到毫秒级的瞬间热更新效果。
拿 Webpack 来对比能发现,一般的项目使用 Webpack 之后,启动花个几分钟都是很常见的事情,热更新也经常需要等待十秒以上。这主要是因为:

  • 项目冷启动时必须递归打包整个项目的依赖树
  • JavaScript 语言本身的性能限制,导致构建性能遇到瓶颈,直接影响开发效率

这样一来,代码改动后不能立马看到效果,自然开发体验也越来越差。
而其中,最占用时间的就是代码打包文件编译

Vite 很好地解决了这些问题

  • 一方面,Vite 在开发阶段基于浏览器原生 ESM 的支持实现了 no-bundle 服务
  • 另一方面,借助 Esbuild 超快的编译速度来做第三方库构建和 TS/JSX 语法编译,从而能够有效提高开发效率。

模块化方面

Vite 基于浏览器原生 ESM 的支持实现模块加载,并且无论是开发环境还是生产环境,都可以将其他格式(如 CommonJS)的产物转换为 ESM。

语法转译方面

语法转译方面,Vite 内置了对 TypeScript、JSX、Sass 等高级语法的支持,也能够加载各种各样的静态资源,如图片、Worker 等等。

产物质量

产物质量方面,Vite 基于成熟的打包工具 Rollup 实现生产环境打包,同时可以配合 Terser、Babel 等工具链一起,极大程度保证构建产物的质量。

总结

Vite 将会是你当下的一个最好的选择。它不仅解决了传统构建工具的开发效率问题而且具备一个优秀构建工具的各项要素还经历了社区大规模的验证与落地

使用 Vite 过程产生的问题

对生态了解不够,不知道使用哪些插件或者解决方案

  • 第三方库里面含有 CommonJS 代码导致报错了怎么办?
  • 想在开发过程中进行 Eslint 代码规范检查怎么办?
  • 生产环境打包项目后,如何产出构建产物分析报告?
  • 如果要兼容不支持原生 ESM 的浏览器,怎么办?

对 Vite 底层使用的构建引擎 Esbuild 和 Rollup 不够熟悉,遇到一些需要定制的场景时

  • 写一个 Esbuild 插件来处理一下问题依赖
  • 对于 Rollup 打包产物进行自定义拆包,解决实际场景中经常出现的循环依赖问题
  • 使用 Esbuild 的代码转译和压缩功能会出现哪些兼容性问题?如何解决?

当然,作为一个构建工具,Vite 的难点不仅在于它本身的灵活性,也包含了诸如 Babel、core-js 等诸多前端工具链的集成和应用

  • @babel/preset-env 的 useBuiltIns 属性各个取值有哪些区别?
  • @babel/polyfill 与 @babel/runtime-corejs 有什么区别?
  • @babel/plugin-transform-runtime 与@babel/preset-env 的 useBuiltIn 相比有什么优化?
  • core-js 的作用是什么?其产物有哪些版本?core-js 和 core-js-pure 有什么区别?

ESM 为什么是前端模块化的未来

自 2009 年 Node.js 诞生,前端先后出现了 CommonJS、AMD、CMD、UMD 和 ES Module 等模块规范
底层规范的发展也催生出了一系列工具链的创新,比如:

  • AMD 规范提出时社区诞生的模块加载工具 requireJS
  • 基于 CommonJS 规范的模块打包工具 browserify
  • 能让用户提前用上 ES Module 语法的 JS 编译器 Babel
  • 兼容各种模块规范的重量级打包工具 Webpack 以及基于浏览器原生 ES Module 支持而实现的 no-bundle 构建工具 Vite 等等。

前端模块化的演进过程

了解前端模块化的演进过程,才能更清楚地了解到各种模块化标准诞生的背景和意义,也能更好地理解 ES Module 为什么能够成为现今最主流的前端模块化标准。

无模块化标准阶段

模块化标准还没有诞生的时候,前端界已经产生了一些模块化的开发手段:

  1. 文件划分
  2. 命名空间
  3. IIFE 私有作用域

这几种方式都没有真正解决一个问题——模块加载。如果模块间存在依赖关系,那么 script 标签的加载顺序就需要受到严格的控制,一旦顺序不对,则很有可能产生运行时 Bug

业界主流的三大模块规范

CommonJS

CommonJS 是业界最早正式提出的 JavaScript 模块规范,主要用于服务端,随着 Node.js 越来越普及,这个规范也被业界广泛应用。
对于模块规范而言,一般会包含 2 方面内容:

  • 统一的模块化代码规范
  • 实现自动加载模块的加载器(也称为 loader)

示例:

1
2
3
4
5
6
7
8
9
10
11
12
// module-a.js
var data = "hello world";
function getData() {
return data;
}
module.exports = {
getData,
};

// index.js
const { getData } = require("./module-a.js");
console.log(getData());

代码中使用 require 来导入一个模块,用 module.exports 来导出一个模块。这个过程实际上 Node.js 内部会有相应的 loader 转译模块代码,最后模块代码会被处理成下面这样:

1
2
3
4
(function (exports, require, module, __filename, __dirname) {
// 执行模块代码
// 返回 exports 对象
});

对于 CommonJS 而言,一方面它定义了一套完整的模块化代码规范,另一方面 Node.js 为之实现了自动加载模块的 loader,看上去是一个很不错的模块规范。
但也存在一些问题:

  • 模块加载器由 Node.js 提供,太过依赖 Node.js 本身的功能实现。比如文件系统,如果 CommonJS 模块直接放到浏览器中是无法执行的。当然, 业界也产生了 browserify 这种打包工具来支持打包 CommonJS 模块,从而顺利在浏览器中执行,相当于社区实现了一个第三方的 loader。
  • CommonJS 本身约定以同步的方式进行模块加载。这种加载机制放在服务端是没问题的,一来模块都在本地,不需要进行网络 IO,二来只有服务启动时才会加载模块,而服务通常启动后会一直运行,所以对服务的性能并没有太大的影响。但如果这种加载机制放到浏览器端,会带来明显的性能问题。它会产生大量同步的模块请求,浏览器要等待响应返回后才能继续解析模块。也就是说,模块请求会造成浏览器 JS 解析过程的阻塞,导致页面加载速度缓慢。

总之,CommonJS 是一个不太适合在浏览器中运行的模块规范。因此,业界也设计出了全新的规范来作为浏览器端的模块标准,最知名的要数 AMD 了。

AMD

AMD 全称为 Asynchronous Module Definition,即异步模块定义规范
模块根据这个规范,在浏览器环境中会被异步加载,而不会像 CommonJS 规范进行同步加载,也就不会产生同步请求导致的浏览器解析过程阻塞的问题了。
我们先来看看这个模块规范是如何来使用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.js
define(["./print"], function (printModule) {
printModule.print("main");
});

// print.js
define(function () {
return {
print: function (msg) {
console.log("print " + msg);
},
};
});

在 AMD 规范当中,我们可以通过 define 去定义或加载一个模块,比如上面的 main 模块和 print 模块,如果模块需要导出一些成员需要通过在定义模块的函数中 return 出去(参考 print 模块),如果当前模块依赖了一些其它的模块则可以通过 define 的第一个参数来声明依赖(参考 main 模块),这样模块的代码执行之前浏览器会先加载依赖模块。
由于没有得到浏览器的原生支持,AMD 规范需要由第三方的 loader 来实现,最经典的就是 requireJS 库了,它完整实现了 AMD 规范,至今仍然有不少项目在使用。
不过 AMD 规范使用起来稍显复杂,代码阅读和书写都比较困难。因此,这个规范并不能成为前端模块化的终极解决方案,仅仅是社区中提出的一个妥协性的方案,关于新的模块化规范的探索,业界从仍未停止脚步。
同期出现的规范当中也有 CMD 规范,这个规范是由淘宝出品的 SeaJS 实现的,解决的问题和 AMD 一样。不过随着社区的不断发展,SeaJS 已经被 requireJS 兼容了。
当然,你可能也听说过 UMD(Universal Module Definition)规范,其实它并不算一个新的规范,只是兼容 AMD 和 CommonJS 的一个模块化方案,可以同时运行在浏览器和 Node.js 环境。

顺便提一句,后面将要介绍的 ES Module 也具备这种跨平台的能力。

ES6 Module

ES6 Module 也被称作 ES Module 或 ESM, 是由 ECMAScript 官方提出的模块化规范,作为一个官方提出的规范,ES Module 已经得到了现代浏览器的内置支持。
在现代浏览器中,如果在 HTML 中加入含有 type="module" 属性的 script 标签,那么浏览器会按照 ES Module 规范来进行依赖加载和模块解析,这也是 Vite 在开发阶段实现 no-bundle 的原因由于模块加载的任务交给了浏览器,即使不打包也可以顺利运行模块代码,具体的模块加载流程我们会在下一节进行详细的解释。

大家可能会担心 ES Module 的兼容性问题,其实 ES Module 的浏览器兼容性如今已经相当好了,覆盖了 90% 以上的浏览器份额。

不仅如此,一直以 CommonJS 作为模块标准的 Node.js 也紧跟 ES Module 的发展步伐,从 12.20 版本开始正式支持原生 ES Module。也就是说,如今 ES Module 能够同时在浏览器与 Node.js 环境中执行,拥有天然的跨平台能力。

下面是一个使用 ES Module 的简单例子:

1
2
3
4
5
6
7
8
9
10
// main.js
import { methodA } from "./module-a.js";
methodA();

//module-a.js
const methodA = () => {
console.log("a");
};

export { methodA };
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.js"></script>
</body>
</html>

如果在 Node.js 环境中,你可以在 package.json 中声明 type: “module” 属性,然后 Node.js 便会默认以 ES Module 规范去解析模块:

1
2
3
4
// package.json
{
"type": "module"
}

顺便说一句,在 Node.js 中,即使是在 CommonJS 模块里面,也可以通过 import 方法顺利加载 ES 模块,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
async function func() {
// 加载一个 ES 模块
// 文件名后缀需要是 mjs
const { a } = await import("./module-a.mjs");
console.log(a);
}

func();

module.exports = {
func,
};

ES Module 作为 ECMAScript 官方提出的规范,经过五年多的发展,不仅得到了众多浏览器的原生支持,也在 Node.js 中得到了原生支持,是一个能够跨平台的模块规范。
同时,它也是社区各种生态库的发展趋势,尤其是被如今大火的构建工具 Vite 所深度应用。可以说,ES Module 前景一片光明,成为前端大一统的模块标准指日可待。

Prev:
qiankun 微前端落地实践
Next:
vercel部署跨域问题