从前端模块化发展史去思考Vite

1,310 阅读12分钟

这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战 | 创作学习持续成长,夺宝闯关赢大奖

​ 随着互联网技术飞速发展,页面交互开始慢慢复杂以至于JavaScript代码越写越多、JS文件拆分得越来越多,在ES6之前JavaScript不支持模块化体系,对于开发复杂和大型的项目是一种巨大阻碍。前端模块化的提出就是为了解决没有模块化的JS文件间存在的命名冲突、全局变量污染、依赖顺序以及性能等等问题。可以把模块化看作是乐高玩具,每一个模块都有特定的功能,按照一定的规则使用模块就能拼出乐高。使用模块化就能解决:命名冲突、提供复用性、不再需要刻意注意JS文件的引入顺序等等问题。

前端模块化发展史

CommonJS 、AMD和CMD

在ES6之前,主要有两种模块化规范。一种是CommonJS服务于服务器端,另一种是AMD服务于浏览器端。

CommonJS

2009年诞生的NodeJS就采用的是CommonJS规范。它的特点是:

  • 每一个文件即是一个模块,每个模块都有自己的独立的作用域、变量以及方法等,对其他的模块都不可见。
  • 所有模块同步加载,第一次加载缓存运行结果,以后再加载就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • module变量表示当前模块,它是一个对象。通过module.exports暴露模块内的内容。
  • 通过require引入指定模块。

举个🌰:

// num1.js
var a = 1;
module.exports = {
  num1:a;
}

// num2.js
module.exports.num2 = 2;

// addNum.js
var addNum1 = require('./num1.js');
var addNum2 = require('./num2.js');

console.log(addNum1, addNum2);//{ num1: 1 } { num2: 2 }
console.log(addNum1.num1+addNum2.num2);// 3

CommonJS还有一个很坑的语法就是exports,内部的沙箱机制将exports指向module.exports,简单说就是相当于在看不见的地方执行了const exports = module.exports,所以在CommonJS规范里也可以这样写exports.a =...,但是千万不能写exports =...,这样相当于间接把module.exports覆盖了。。。。啊这,总之,搞不清楚就放弃这种语法吧。

CommonJS模块化的思想给了大家在思考前端模块化问题很大的启发,也推动了前端模块化的发展。并且最重要的是规则很简单、简洁和好用。但是CommonJS的局限性在于同步加载的特点使得它只能在基于Node的服务端使用,在浏览器端同步加载依赖是一件非常有损性能的事。所以后续AMD/CMD接踵而至为了解决浏览器端的模块化规范问题。

AMD

依旧是2009年诞生的AMD全称“Asynchronous Module Definition”(异步模块定义),采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到所有依赖加载完成之后(前置依赖),这个回调函数才会运行。也是一种模块规范。

不过离谱的是不是因为有了AMD规范才有的RequireJS,是RequireJS这个JS模块化工具在发展过程中推动形成了AMD模块化规范。RequireJS配置还挺复杂的,这里就不举例了。(毕竟举了大家也不会看(不是

CMD

CMD规范是在CommonJS和AMD规范的基础上建立的。CMD是通过Sea.js实现浏览器端的模块化规范。它的特点是:

  • 通过配置的入口文件来解析依赖关系,然后根据解析出的依赖关系将代码按顺序插入到<script>标签里。
  • 提倡一个文件即是一个模块
  • 提倡依赖就近,也就是需要用到某个模块再去引入。(AMD推崇依赖前置,也就是在定义模块时就要声明依赖的模块)
// Sea.js主要的语法就是define
// 因为以上的提倡,一个文件即是一个模块所以可以不单独去定义id,依赖就近所以也可以不写deps
define(id?, deps?, factory);

举个🌰:

// add.js
define(function (require) {
	require('./num1');  
 	require('./num2');
  console.log('add');
});
console.log('end-add');

// num1.js
define(function (require, exports, module) {
  console.log('num1');
});
console.log('end1');

// num2.js
define(function (require) {
  console.log('num2');
});
console.log('end2');

<body>
  <script src="/sea.js"></script>
	<script>
    // use方法加载模块
		seajs.use(['./add.js'], function () {
    	console.log('根下的callback');
  	});
	</script>
</body>
/**sea.js加载过程 
1.首先解析入口文件,add模块依赖num1和num2模块,依赖解析完成。
2.动态script标签下载依赖,输出:end-add end1 end2;
3.依赖模块加载完毕后解析模块内部,从入口模块开始:add
4.add依赖num1和num2,顺序输出:num1 num2
5.全部依赖加载完毕,执行根下的factory输出:根下的callback
*/

因为CMD融合了CommonJS和AMD的特点,所以总的来说Sea.js是一种兼容性比较强的模块化规范实现,包含两种模块规范的特点:

  • 可以像AMD一样依赖前置,也可以依赖就近。
  • 和CommonJS一样会缓存所有依赖。

这样浏览器端的模块化规范大致选择为CMD,服务器端的模块化规范为CommonJS,那如果想要一起用呢,这个时候又出现了一个叫作UMD的规范用以统一这两个端的规范。。。。总之,就是无穷无尽的大乱斗。

ESM Module

在前面社区内非官方的模块规范大乱斗一段时间之后,ES6终于制定了一套官方的模块化规范。ES6直接在语言标准层面上实现了模块功能,用法非常简单,所以可以直接统一浏览器和服务器端作为一种通过的解决方案。也就是说一套官方的ES6模块化规范可以干掉AMD/CMD,也不需要UMD。

// num1.js
var a = 1;
export {
  a as num1
};

// num2.js
export const num2 = 2

// add.js
import { num1 } from '/test111'
import { num2 } from './test'
console.log(num1 + num2); //3

// 要将ES6模块应用到浏览器还需要script标签的小变化type="module"
<script type="module" src="add.js"></script>

ES6的模块引入与导出主要是importexport这两个关键字。这里不得不说export导出的都是接口,所以import引入的也是接口(只读属性),是不能对其进行改变的。

export还有个不得不说的export default用法:

// export导出
export function a () {
  
}
// 引入必须要知道a的名称
import { a } from '...'


// export default导出默认模块
export default function () {
  
}
// 引入可以自定义名称
import AAA from '...'

export default既然是导出了默认输出,所以一个模块中只能有一次default,这样很多时候我们用import _ from 'lodash'导入就很方便。ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。更多ES6 Module参考阮一峰ECMScript 6入门

Vite会是下一代的web工具!吗?

前面花了很大篇幅去厘清前端模块化的发展史就是为了在这一块讨论这个问题。当然在讨论之前,要先说明什么是Vite以及Vite与Webpack的区别。

不得不说的Vite与Webpack

目前很多人提起vite就开始拿webpack对比,其实真要比的话用rollup和webpack对比才比较恰当。严格来说,vite并不是一个打包工具,而是基于rollup打包工具的一个dev server(本地开发服务器)。因为现目前构建大型项目时JavaScript的代码量是非常高的,现目前的JavaScript开发工具需要很长的时间才能启动服务器,最影响开发效率的是修改文件后至少需要几秒才能在浏览器更新,哪怕是使用了热更新(HMR)。出现这种情况的原因主要是(用webpack举例):

  1. 启动服务器缓慢

webpack的打包原理和vite的运行原理如下图。

IMG_5413.JPG

IMG_5414.JPG

对于webpack来说,总结一句话就是先打包再启动服务器。在打包时经历一个逐层识别模块依赖的过程(可以识别各种模块化规范AMD/CommonJS/ES6等等),然后构成AST抽象语法树,然后再转换为浏览器可识别的代码打包。这就会产生比较长的启动时间。

Vite则会在一开始将应用中的模块区分为依赖源码两类。依赖采用esbuild工具预构建依赖,也会将许多内部模块的ESM依赖关系转换为单个模块,以提高后续页面加载性能。据esbuild官网所述,其采用go语言编写,比一般的JavaScript打包器的构建依赖速度快到上百倍甚至更多。源码则采用原生的ESM方式,利用浏览器的网络请求接管所有模块。具体来说,当声明一个type为module的script标签的时候,比如:

 <script type="module" src="src/app.js"></script>

浏览器就会向服务器发起一个GET请求,得到app.js文件后如果文件内部继续有import某某模块又会继续请求里面的模块...Vite则利于这一点通过劫持浏览器的这些请求,在后端进行相应的处理将项目中使用的文件通过简单的分解与整合,然后再返回给浏览器,相当于vite整个过程中没有对文件进行打包编译直接启动服务器,所以其速度比webpack快很多。但是这个极其依赖ESM的模块化机制,下面是当前的浏览器对ESM模块化的支持情况。

use.png

除了IE绝大部分都支持,微软昨年已经宣布放弃IE浏览器了,所以紧接着Vue3也宣布放弃IE,现在看来Vite对IE也不兼容。

  1. 更新缓慢

在HMR方面,每次改动文件之后,webpack都会将首次冷启动产生的依赖树的相关依赖模块全部重新编译一次,导致HMR的刷新时间较长。而vite在启动的时候不需要打包,也就意味着不需要分析模块的依赖、不需要编译,当浏览器请求某个模块时,再根据需要对模块内容进行编译。这种按需动态编译的方式,极大的缩减了编译时间,项目越复杂、模块越多,vite的优势越明显。

Vite官网举例:一些包将它们的 ES 模块构建作为许多单独的文件相互导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!尽管服务器在处理这些请求时没有问题,但大量的请求会在浏览器端造成网络拥塞,导致页面的加载速度相当慢。通过预构建 lodash-es 成为一个模块,我们就只需要一个 HTTP 请求了!

网上有人总结以下这几点说明vite相对于其他JavaScript构建工具的优点:

  • 基于浏览器原生 ES imports,因而有更快的冷启动和热更新,整体速度与模块数量无关
  • 没有打包的过程,源码直接传输给浏览器,使用原生的script module语法进行引入,开发服务器拦截请求和对需要转换的代码进行转换
  • 实现了真正的按需编译
  • 生产环境提供了 vite build 脚本进行打包,它基于 rollup 进行打包

rollup打包工具内部的语言用法是基于ESM模块规范,webpack的写法是基于CommonJS规范。我估计Vite选择rollup不仅是因为rollup小而美,更是要将ESM模块规范一用到底,这样开发人员在开发的时候也比较统一。

讨论一下标题吧

先说我自己的观点:我觉得未来哪怕不是Vite成为下一代开发工具,下一代开发工具也一定会利于ESM模块的思想。

前端工具的发展其实是基于当下的某种困境然后诞生的:

  1. 最开始交互简单的情况,我们习惯通过操作DOM的方式写代码,那时候没有模块化思想,解决多个互相依赖的JS文件产生的问题是当下最迫切的事,于是JQuery的诞生席卷了前端界。JQuery将操作DOM的方式封装化,提供闭包的方式封闭作用域尝试去解决JS依赖文件产生的问题。这个时候开发就处于“我变懒了,但是效果更好了”的状态
  2. 模块化的思想提出后,渐渐地大家不需要关心JS文件相互影响并且随着互联网的发展交互已经变得越来越复杂,这个时候问题转移到了开发如何和设计解耦。作为开发当然是希望专注于数据和业务逻辑实现,Angular和React的出现,以及综合前两者优点的Vue将MVVM模式带到前端,这个时候开发又处于“我变懒了,但是效果更好了”的状态,于是原来JQuery那一套开发模式被淘汰。
  3. 使用渐进式的JavaScript开发框架后,随之又产生问题,比如.vue文件浏览器并不识别,然后就出现了webpack等一些JavaScript构建工具来适配渐进式开发框架。使用webpack加上社区的各种plugin,相较于之前开发效率就更高了,这个时候开发又又处于“我变懒了,但是效果更好了”的状态,于是之前的gulp等打包方式被淘汰。但是这个时候的模块化进程还处在大乱斗时期。
  4. 那么现在随着ESM模块化规范的提出,随着前端发展的推进,JS模块文件拆分出来可以达到上千个的时候。如果没有更好地解决方案,我们会忍耐,但是如果有更好的解决方案可以秒开dev server,HMR时秒刷新,学习成本更低等等。那么此时,人类进步的标识:“我变懒了,但是效果更好了” 就会起作用。

所以,体验一下让尤小右三天十更的Vite的丝滑真的会回不去,这就是Vite的魔力。虽然Vite目前的问题很多,但是毕竟它的目标不是这一代而是成为未来下一代的web开发工具。具体的使用方式请看文档Vite官网