前端模块化那些事
Lin. 切图仔

2002 年 AJAX 诞生至今,前端从刀耕火种的年代,经历了一系列的发展,各种标准和工具百花齐放。下图中我们可以看到,自 2009 年 Node.js 诞生,前端先后出现了 CommonJSAMDCMDUMDES Module 等模块规范,底层规范的发展催生出了一系列工具链的创新,比如 AMD 规范提出时社区诞生的模块加载工具requireJS,基于 CommonJS 规范的模块打包工具browserify,还有能让用户提前用上 ES Module 语法的 JS 编译器Babel、兼容各种模块规范的重量级打包工具Webpack以及基于浏览器原生 ES Module 支持而实现的 no-bundle 构建工具 Vite 等等。

从上图可以看到,业界经历了一系列由规范、标准引领工程化改革的过程,慢慢形成前端工程化,继而产生的一系列构建工具。也可以看到ES Module是现今最主流的前端模块化标准

无模块化标准阶段

模块化标准还没有诞生的时候,为了解决这个问题,早期拟定了一些开发手段,如文件划分命名空间IIFE 私有作用域

文件名划分

文件划分方式是最原始的模块化实现,简单来说就是将应用的状态和逻辑分散到不同的文件中,然后通过 HTML 中的 script 来一一引入。下面是一个通过文件划分实现模块化的具体例子:

1
2
// module-a.js
let data = "data";
1
2
3
4
// module-b.js
function method() {
console.log("export method");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="./module-a.js"></script>
<script src="./module-b.js"></script>
<script>
console.log(data); // data
method(); // export method
</script>
</body>
</html>

通过 script 标签分别引入到 HTML 中,看起来像是两个独立模块,但这有一些隐藏的风险:

  1. 会有变量名冲突的问题。比如 module-b 可能也存在data变量,这就会与 module-a 中的变量冲突。
  2. 难以定位问题。由于变量都在全局定义,我们很难知道某个变量到底属于哪些模块,因此也给调试带来了困难。
  3. 无法清晰地管理模块之间的依赖关系和加载顺序。假如 module-a 依赖 module-b ,那么上述 HTML 的 script 执行顺序需要手动调整,不然可能会产生运行时错误。

命名空间

命名空间是模块化的另一种实现手段,通过把模块数据注入 window ,实现数据共享。它可以解决上述文件划分方式中全局变量定义所带来的一系列问题。下面是一个简单的例子:

1
2
3
4
5
6
7
// module-a.js
window.moduleA = {
data: "moduleA",
method: function () {
console.log("execute A's method");
},
};
1
2
3
4
5
6
7
// module-b.js
window.moduleB = {
data: "moduleB",
method: function () {
console.log("execute B's method");
},
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="./module-a.js"></script>
<script src="./module-b.js"></script>
<script>
// 此时 window 上已经绑定了 moduleA 和 moduleB
console.log(moduleA.data);
moduleB.method();
</script>
</body>
</html>

每个变量都有自己专属的命名空间,我们可以清楚地知道某个变量到底属于哪个模块,同时也避免全局变量命名的问题。

IIFE(立即执行函数)

相比于命名空间的模块化,IIFE实现的模块化安全性要更高,对于模块作用域的区分更加彻底。IIFE 实现模块化的例子:

1
2
3
4
5
6
7
8
9
10
11
12
// module-a.js
(function () {
let data = "moduleA";

function method() {
console.log(data + "execute");
}

window.moduleA = {
method: method,
};
})();
1
2
3
4
5
6
7
8
9
10
11
12
// module-b.js
(function () {
let data = "moduleB";

function method() {
console.log(data + "execute");
}

window.moduleB = {
method: method,
};
})();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="./module-a.js"></script>
<script src="./module-b.js"></script>
<script>
// 此时 window 上已经绑定了 moduleA 和 moduleB
console.log(moduleA.data);
moduleB.method();
</script>
</body>
</html>

每个IIFE立即执行函数都会创建一个私有的作用域,在私有作用域中的变量外界是无法访问的,只有模块内部的方法才能访问。这就是模块私有成员功能,避免模块私有成员被其他模块非法篡改,相比于命名空间的实现方式更加安全。

但实际上,无论是命令空间还是IIFE,都是为了解决全局变量所带来的命名冲突及作用域不明确的问题,也就是在文件划分方式中所总结的问题 1问题 2,而并没有真正解决另外一个问题——模块加载

如果模块间存在依赖关系,那么 script 标签的加载顺序就需要受到严格的控制,一旦顺序不对,则很有可能产生运行时 Bug。

经历了漫长的发展阶段,即便是到现在也没有实现完全的统一,但也有了业界主流的三大模块规范: CommonJSAMDES Module。接下来,一起熟悉这三大模块规范。

CommonJS

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

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

举一个使用 CommonJS 的简单例子:

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 也存在一些问题:

  1. 浏览器不支持 CommonJs 模块。模块加载器由 Node.js 提供,依赖了 Node.js 本身的功能实现,直接放到浏览器是无法执行的。因此业界产生了 browserify

这种打包工具来支持打包 CommonJS 模块,从而顺利在浏览器中执行,相当于社区实现了一个第三方的 loader。

  1. CommonJS 的模块加载是同步的,模块请求会造成浏览器 JS 解析过程的阻塞,导致页面加载速度缓慢。

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

AMD

AMD全称为Asynchronous Module Definition,即异步模块定义规范。模块根据这个规范,在浏览器环境中会被异步加载,解决了 CommonJS 同步加载导致的阻塞问题。看个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// main.js
// 参数一声明了依赖,引用了 print 模块
define(["./print"], function (printModule) {
printModule.print("main");
});

// print.js
// 定义了一个 print 模块
define(function () {
return {
print: function (msg) {
console.log("print " + msg);
},
};
});

通过 define 去定义或加载一个模块,比如上面的 main 模块和print模块,如果模块需要导出一些成员需要通过在定义模块的函数中 return 出去(参考 print 模块),如果当前模块依赖了一些其它的模块则可以通过 define 的第一个参数来声明依赖(参考main模块),这样模块的代码执行之前浏览器会先加载依赖模块

也可以使用 require 关键字来加载一个模块,如:

1
2
3
4
// module-a.js
require(["./print.js"], function (printModule) {
printModule.print("module-a");
});

require 与 define 的区别在于前者只能加载模块,而不能定义一个模块
AMD 虽然解决了 CommonJS 同步加载的问题,但它并不能按需加载,而是提前加载所有的依赖。

CMD

CMD 是 sea.js 在推广过程中对模块定义的规范化产出,属于 CommonJS 的一种规范。
并实现了 按需加载模块化加载,依赖就近,延迟执行。

同 AMD 一样,使用 define 定义一个模块:

1
2
3
4
5
6
define(function (require, exports, module) {
var add = function (a, b) {
return a + b;
}
exports.add = add;
})

但是引用的方式略有不同,使用模块:

1
2
3
seajs.use(['math.js'], function (math) {
var sum = math.add(1, 2);
});

UMD

当然,你可能也听说过 UMD (Universal Module Definition)规范,其实它并不算一个新的规范,只是兼容 AMD 和 CommonJS 的一个模块化方案,可以同时运行在浏览器和 Node.js 环境。顺便提一句,后面将要介绍的 ES Module 也具备这种跨平台的能力。

规定如下:

  1. 优先判断是否存在 exports 方法,如果存在,则采用 CommonJS 方式加载模块;
  2. 其次判断是否存在 define 方法,如果存在,则采用 AMD 方式加载模块;
  3. 最后判断 global 对象上是否定义了所需依赖,如果存在,则直接使用;反之,则抛出异常。

ES6 Module

ES6 Module 也被称作 ES Module(或 ESM), 是由 ECMAScript 官方提出的模块化规范,作为一个官方提出的规范,ES Module 已经得到了现代浏览器的内置支持。在现代浏览器中,如果在 HTML 中加入含有type="module"属性的 script 标签,那么浏览器会按照 ES Module 规范来进行依赖加载和模块解析。

关于兼容性问题,其实 ES Module 的浏览器兼容性如今已经相当好了,覆盖了 90% 以上的浏览器份额,在 CanIUse 上的详情数据如下图所示:

不仅如此,一直以 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"属性:

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

使用 Node.js 运行,默认就会用 ES Module 规范去解析:

1
2
node main.js
// 打印 a

在 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,
};
  • 本文标题:前端模块化那些事
  • 本文作者:Lin.
  • 创建时间:2022-05-18 20:42:02
  • 本文链接:http://www.linzefan.top/hexo-blogs2022/05/18/前端模块化那些事/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
 评论