模块标准:为什么 ESM 是前端模块化的未来?
2002 年 AJAX
诞生至今,前端从刀耕火种的年代,经历了一系列的发展,各种标准和工具百花齐放。
下图中我们可以看到,自 2009 年 Node.js 诞生,前端先后出现了
CommonJS、AMD、CMD、UMD和ES
Module等模块规范,底层规范的发展催生出了一系列工具链的创新,比如 AMD
规范提出时社区诞生 的模块加载工具requireJS,基于 CommonJS
规范的模块打包工具browserify,还有能让用户提前用 上 ES Module语法的 JS
编译器Babel、兼容各种模块规范的重量级打包工具Webpack以及基于浏览器原 生
ES Module 支持而实现的 no-bundle 构建工具
2002 年 AJAX
诞生至今,前端从刀耕火种的年代,经历了一系列的发展,各种标准和工具百花齐放。
下图中我们可以看到,自 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 为什么能够成为现今最主流的前端模块化标准。
无模块化标准阶段
早在模块化标准还没有诞生的时候,前端界已经产生了一些模块化的开发手段,如文件划分、命名空间
和 IIFE 私有作用域。下面,我来简单介绍一下它们的实现以及背后存在的问题。
1. 文件划分
文件划分方式是最原始的模块化实现,简单来说就是将应用的状态和逻辑分散到不同的文件中,然后
通过 HTML 中的 script
来一一引入。下面是一个通过文件划分实现模块化的具体例子:
1// module-a.js
3
1// module-b.js
2 function method() {
3 console.log("execute method");
4 }
5
1 // index.html
2 <!DOCTYPE html>
3 <html lang="en">
4 <head>
5 <meta charset="UTF-8" />
6 <meta http-equiv="X-UA-Compatible" content="IE=edge" />
7 <meta name="viewport" content="width=device-width,
initial-scale=1.0" />8 <title>Document</title>
9 </head>
10 <body>
11 <script src="./module-a.js"></script>
12 <script src="./module-b.js"></script>
13 <script>
14 console.log(data);
15 method();
16 </script>
17 </body>
18 </html>
19
从中可以看到module-a和module-b为两个不同的模块,通过两个 script
标签分别引入到 HTML 中,这
么做看似是分散了不同模块的状态和运行逻辑,但实际上也隐藏着一些风险因素:
1.模块变量相当于在全局声明和定义,会有变量名冲突的问题。比如
module-b可能也存在data变量,这就会与 module-a中的变量冲突。2.由于变量都在全局定义,我们很难知道某个变量到底属于哪些模块,因此也给调试带来了困难。
3.无法清晰地管理模块之间的依赖关系和加载顺序。假如
module-a依赖module-b,那么上述 HTML 的 script
执行顺序需要手动调整,不然可能会产生运行时错 误。
2. 命名空间
命名空间是模块化的另一种实现手段,它可以解决上述文件划分方式中全局变量定义所带来的一系列问
题。下面是一个简单的例子:
1// module-a.js
8
1// module-b.js
8
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6 <meta name="viewport" content="width=device-width,
initial-scale=1.0" />7 <title>Document</title>
8 </head>
9 <body>
10 <script src="./module-a.js"></script>
11 <script src="./module-b.js"></script>
12 <script>
13 // 此时 window 上已经绑定了 moduleA 和 moduleB
14 console.log(moduleA.data);
15 moduleB.method();
16 </script>
17 </body>
18 </html>
19
这样一来,每个变量都有自己专属的命名空间,我们可以清楚地知道某个变量到底属于哪个模块,同时
也避免全局变量命名的问题。
3. IIFE(立即执行函数)
不过,相比于命名空间的模块化手段,IIFE实现的模块化安全性要更高,对于模块作用域的区分更加彻
底。你可以参考如下IIFE 实现模块化的例子:
1// module-a.js
13
1// module-b.js
13
1 // index.html
2 <!DOCTYPE html>
3 <html lang="en">
4 <head>
5 <meta charset="UTF-8" />
6 <meta http-equiv="X-UA-Compatible" content="IE=edge" />
7 <meta name="viewport" content="width=device-width,
initial-scale=1.0" />8 <title>Document</title>
9 </head>
10 <body>
11 <script src="./module-a.js"></script>
12 <script src="./module-b.js"></script>
13 <script>
14 // 此时 window 上已经绑定了 moduleA 和 moduleB
15 console.log(moduleA.data);
16 moduleB.method();
17 </script>
18 </body>
19 </html>
20
我们知道,每个IIFE即立即执行函数都会创建一个私有的作用域,在私有作用域中的变量外界是无法访
问的,只有模块内部的方法才能访问。拿上述的module-a来说:
1// module-a.js
13
对于其中的 data变量,我们只能在模块内部的
method函数中通过闭包访问,而在其它模块中无法直接
访问。这就是模块私有成员功能,避免模块私有成员被其他模块非法篡改,相比于命名空间的实现方式更
加安全。
但实际上,无论是命名空间还是IIFE,都是为了解决全局变量所带来的命名冲突及作用域不明确的问
题,也就是在文件划分方式中所总结的问题 1和问题
2,而并没有真正解决另外一个问题——模块加载。
如果模块间存在依赖关系,那么 script
标签的加载顺序就需要受到严格的控制,一旦顺序不对,则很 有可能产生运行时
Bug。
而随着前端工程的日益庞大,各个模块之间相互依赖已经是非常常见的事情,模块加载的需求已经成
为了业界刚需,而以上的几种非标准模块化手段不能满足这个需求,因此我们需要指定一个行业标准
去统一前端代码的模块化。
不过前端的模块化规范统一也经历了漫长的发展阶段,即便是到现在也没有实现完全的统一。接下
来,我们就来熟悉一下业界主流的三大模块规范: CommonJS、AMD和 ES Module。
CommonJS 规范
CommonJS 是业界最早正式提出的 JavaScript 模块规范,主要用于服务端,随着
Node.js 越来越普
及,这个规范也被业界广泛应用。对于模块规范而言,一般会包含 2 方面内容:
统一的模块化代码规范
实现自动加载模块的加载器(也称 loader)
对于 CommonJS 模块规范本身,相信有 Node.js
使用经验的同学都不陌生了,为了方便你理解,我 举一个使用 CommonJS
的简单例子:
1// module-a.js
13
代码中使用 require来导入一个模块,用module.exports来导出一个模块。实际上
Node.js 内部会有 相应的 loader
转译模块代码,最后模块代码会被处理成下面这样:
1(function (exports, require, module, __filename,
__dirname) {2 // 执行模块代码
3 // 返回 exports 对象
4 });
5
对 CommonJS 而言,一方面它定义了一套完整的模块化代码规范,另一方面
Node.js 为之实现了自
动加载模块的loader,看上去是一个很不错的模块规范,但也存在一些问题:
1.模块加载器由 Node.js 提供,依赖了 Node.js
本身的功能实现,比如文件系统,如果 CommonJS 模块直接放到
浏览器中是无法执行的。当然, 业界也产生了
browserify
这种打包工具来支持打包 CommonJS 模块,从而顺
利在浏览器中执行,相当于社区实现了一个第三方的 loader。2.CommonJS
本身约定以同步的方式进行模块加载,这种加载机制放在服务端是没问题的,一来模块都在本地,
不需要进行网络
IO,二来只有服务启动时才会加载模块,而服务通常启动后会一直运行,所以对服务的性能并没
有太大的影响。但如果这种加载机制放到浏览器端,会带来明显的性能问题。它会产生大量同步的模块请求,浏
览器要等待响应返回后才能继续解析模块。也就是说,模块请求会造成浏览器
JS 解析过程的阻塞,导致页面加 载速度缓慢。
总之,CommonJS
是一个不太适合在浏览器中运行的模块规范。因此,业界也设计出了全新的规范来
作为浏览器端的模块标准,最知名的要数 AMD了。
AMD 规范
AMD全称为Asynchronous Module
Definition,即异步模块定义规范。模块根据这个规范,在浏览器环
境中会被异步加载,而不会像 CommonJS
规范进行同步加载,也就不会产生同步请求导致的浏览器
解析过程阻塞的问题了。我们先来看看这个模块规范是如何来使用的:
1// main.js
14
在 AMD 规范当中,我们可以通过 define 去定义或加载一个模块,比如上面的
main模块和print模 块,如果模块需要导出一些成员需要通过在定义模块的函数中
return 出去(参考 print模块),如果当 前模块依赖了一些其它的模块则可以通过
define 的第一个参数来声明依赖(参考main模块),这样模块
的代码执行之前浏览器会先加载依赖模块。
当然,你也可以使用 require 关键字来加载一个模块,如:
1// module-a.js
2 require(["./print.js"], function (printModule) {
3 printModule.print("module-a");
4 });
5
不过 require 与 define 的区别在于前者只能加载模块,而不能定义一个模块。
由于没有得到浏览器的原生支持,AMD 规范需要由第三方的 loader
来实现,最经典的就是
requireJ
S 库了,它完整实现了 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% 以上的浏览器份额,在
CanIUse
上的详情数据如下图所示:
不仅如此,一直以 CommonJS 作为模块标准的 Node.js 也紧跟 ES Module
的发展步伐,
从
12.20版本开始正式支持原生
ES Module。也就是说,如今 ES Module 能够同时在浏览器与 Node.js
环境中执行,拥有天然的跨平台能力。
下面是一个使用 ES Module 的简单例子:
1// main.js
11
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <link rel="icon" type="image/svg+xml" href="/src/favicon.svg"
/>6 <meta name="viewport" content="width=device-width,
initial-scale=1.0" />7 <title>Vite App</title>
8 </head>
9 <body>
10 <div id="root"></div>
11 <script type="module" src="./main.js"></script>
12 </body>
13 </html>
14
如果在 Node.js 环境中,你可以在package.json中声明type: "module"属性:
1// package.json
5
然后 Node.js 便会默认以 ES Module 规范去解析模块:
1node main.js
2 // 打印 a
3
顺便说一句,在 Node.js 中,即使是在 CommonJS 模块里面,也可以通过
import方法顺利加载 ES 模块,如下所示:
1async function func() {
ES Module 作为 ECMAScript
官方提出的规范,经过五年多的发展,不仅得到了众多浏览器的原生支 持,也在
Node.js
中得到了原生支持,是一个能够跨平台的模块规范。同时,它也是社区各种生态库
的发展趋势,尤其是被如今大火的构建工具 Vite 所深度应用。可以说,ES
Module 前景一片光明, 成为前端大一统的模块标准指日可待。
当然,这一讲我们只简单介绍了
ESM。至于高级特性,我们将在「高级应用篇」专门介绍。你可以先
利用我这里给到的官方资料提前预习: MDN
官方解释、ECMAScript
内部提案细节。
小结
这一节,我们要重点掌握前端模块化的诞生意义、主流的模块化规范和
ESM 规范的优势。
由于前端构建工具的改革与底层模块化规范的发展息息相关,从一开始我就带你从头梳理了前端模块
化的演进史,从无模块化标准的时代开始谈起,跟你介绍了文件划分的模块化方案,并分析了这个方案
潜在的几个问题。随后又介绍了命名空间和IIFE两种方案,但这两种方式并没有解决模块自动加载的问
题。由此展开对前端模块化规范的介绍,我主要给你分析了三个主流的模块化标准:
CommonJS、AMD以 及 ES
Module,针对每个规范从模块化代码标准、模块自动加载方案这两个维度给你进行了详细的拆解,最后
得出 ES Module 即将成为主流前端模块化方案的结论。
本小节的内容就到这里了,希望能对你有所启发,也欢迎你把自己的学习心得打到评论区,我们下一
节再见~