探索 JS 中的模块化

偶然的一个周末复习了一下 JS 的模块标准,刷新了一下对 JS 模块化的理解。

JS Module

从开始 Coding 以来,总会周期性地突发奇想进行 Code Review。既是对一段时期的代码进行总结,也是对那一段时光的怀念。

距离上一次 Review 已经过去近两个月,这次竟然把两年前在源续写的代码翻了出来,代码杂乱无章的程度就像那时更加浮躁的自己,让人感慨时光流逝之快。

话不多说,直接上码。

当时在做的是一个境外电商项目(越南天宝商城),作为非 CS 的新手程序员,接触 Coding 时间不长和工程化观念不强,在当时的项目中出现了这样的代码:

import.js:
import.js

这段代码看起来就是不断地从 DOM 中插进 CSS 和 JS,虽然写得很烂,但是很能反映以前的 Web 开发方式。

在 Web 开发中,有一个原则叫“关注点分离(separation of concerns)“,意思是各种技术只负责自己的领域,不互相耦合混合在一起,所以催生出了 HTML、CSS 和 JavaScript。

其中,在 Web 中负责逻辑和交互 的 JavaScript,是一门只用 10 天设计出来的语言,虽然借鉴了许多优秀静态和动态语言的优点,但却一直没有模块 ( module ) 体系。这导致了它将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。其他语言都有这项功能,比如 RubyrequirePythonimport,甚至就连 CSS 都有 @import,但是 JavaScript 任何这方面的支持都没有。而且 JS 是一种加载即运行的技术,在页面中插入脚本时还需要考虑库的依赖,JS 在这方面的缺陷,对开发大型的、复杂的项目形成了巨大障碍。

发展过程

虽然 JS 本身并不支持模块化,但是这并不能阻挡 JS 走向模块化的道路。既然本身不支持,那么就从代码层面解决问题。活跃的社区开始制定了一些模块方案,其中最主要的是 CommonJS 和 AMD,ES6 规范出台之后,以一种更简单的形式制定了 JS 的模块标准 (ES Module),并融合了 CommonJS 和 AMD 的优点。

大致的发展过程:

CommonJS(服务端) => AMD (浏览器端) => CMD / UMD => ES Module

CommonJS 规范

2009年,Node.js 横空出世,JS 得以脱离浏览器运行,我们可以使用 JS 来编写服务端的代码了。对于服务端的 JS,没有模块化简直是不能忍。

CommonJs (前 ServerJS) 在这个阶段应运而生,制定了 Module/1.0 规范,定义了第一版模块标准。

标准内容:

  1. 模块通过变量 exports 来向外暴露 API,exports 只能是一个对象,暴露的 API 须作为此对象的属性。
  2. 定义全局函数 require,通过传入模块标识来引入其他模块,执行的结果即为别的模块暴露出来的 API。
  3. 如果被 require 函数引入的模块中也包含依赖,那么依次加载这些依赖。

特点:

  1. 模块可以多次加载,首次加载的结果将会被缓存,想让模块重新运行需要清除缓存。
  2. 模块的加载是一项阻塞操作,也就是同步加载。

它的语法看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// a.js
module.exports = {
moduleFunc: function() {
return true;
};
}
// 或
exports.moduleFunc = function() {
return true;
};

// 在 b.js 中引用
var moduleA = require('a.js');
// 或
var moduleFunc = require('a.js').moduleFunc;

console.log(moduleA.moduleFunc());
console.log(moduleFunc())

AMD 规范(Asynchromous Module Definition)

CommonJS 规范出现后,在 Node 开发中产生了非常良好的效果,开发者希望借鉴这个经验来解决浏览器端 JS 的模块化。

但大部分人认为浏览器和服务器环境差别太大,毕竟浏览器端 JS 是通过网络动态依次加载的,而不是像服务端 JS 存在本地磁盘中。因此,浏览器需要实现的是异步模块,模块在定义的时候就必须先指明它所需要依赖的模块,然后把本模块的代码写在回调函数中去执行,最终衍生出了 AMD 规范

AMD 的主要思想是异步模块,主逻辑在回调函数中执行,这和浏览器前端所习惯的开发方式不谋而合,RequireJS 应运而生。

标准内容:

  1. 用全局函数 define 来定义模块,用法为:define(id?, dependencies?, factory);
  2. id 为模块标识,遵从 CommonJS Module Identifiers 规范
  3. dependencies 为依赖的模块数组,在 factory 中需传入形参与之一一对应,如果 dependencies 省略不写,则默认为 ["require", "exports", "module"] ,factory 中也会默认传入 require, exports, module,与 ComminJS 中的实现保持一致
  4. 如果 factory 为函数,模块对外暴露 API 的方法有三种:return 任意类型的数据、exports.xxx = xxx 或 module.exports = xxx
  5. 如果 factory 为对象,则该对象即为模块的返回值

特点:

  1. 前置依赖,异步加载
  2. 便于管理模块之间的依赖性,有利于代码的编写和维护。

它的用法看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// a.js
define(function (require, exports, module) {
console.log('a.js');
exports.name = 'Jack';
});

// b.js
define(function (require, exports, module) {
console.log('b.js');
exports.desc = 'Hello World';
});

// main.js
require(['a', 'b'], function (moduleA, moduleB) {
console.log('main.js');
console.log(moduleA.name + ', ' + moduleB.desc);
});

// 执行顺序:
// a.js
// b.js
// main.js

人无完人,AMD/RequireJS 也存在饱受诟病的缺点。按照 AMD 的规范,在定义模块的时候需要把所有依赖模块都罗列一遍(前置依赖),而且在使用时还需要在 factory 中作为形参传进去。

1
define(['a', 'b', 'c', 'd', 'e', 'f', 'g'], function(a, b, c, d, e, f, g){ ..... });

看起来略微不爽 …

RequireJS 模块化的顺序是这样的:模块预加载 => 全部模块预执行 => 主逻辑中调用模块,所以实质是依赖加载完成后还会预先一一将模块执行一遍,这种方式会使得程序效率有点低。

所以 RequireJS 也提供了就近依赖,会在执行至 require 方法才会去进行依赖加载和执行,但这种方式的用户体验不是很好,用户的操作会有明显的延迟(下载依赖过程),虽然可以通过各种 loading 去解决。

1
2
3
4
5
6
7
8
// 就近依赖
define(function () {
setTimeout(function () {
require(['a'], function (moduleA) {
console.log(moduleA.name);
});
}, 1000);
});

CMD 规范(Common Module Definition)

AMD/RequireJS 的 JS 模块实现上有很多不优雅的地方,长期以来在开发者中广受诟病,原因主要是不能以一种更好的管理模块的依赖加载和执行,虽然有不足的地方,但它提出的思想在当时是非常先进的。

既然优缺点那么必然有人出来完善它,SeaJS 在这个时候出现。

SeaJS 遵循的是 CMD 规范,CMD 是在 AMD 基础上改进的一种规范,解决了 AMD 对依赖模块的执行时机处理问题。

SeaJS 模块化的顺序是这样的:模块预加载 => 主逻辑调用模块前才执行模块中的代码,通过依赖的延迟执行,很好解决了 RequireJS 被诟病的缺点。

SeaJS 用法和 AMD 基本相同,并且融合了 CommonJS 的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// a.js
define(function (require, exports, module) {
console.log('a.js');
exports.name = 'Jack';
});

// main.js
define(function (require, exports, module) {
console.log('main.js');
var moduleA = require('a');
console.log(moduleA.name);
});

// 执行顺序
// main.js
// a.js

除此之外,SeaJS 还提供了 async API,实现依赖的延迟加载。

1
2
3
4
5
// main.js
define(function (require, exports, module) {
var moduleA = require.async('a');
console.log(moduleA.name);
});

SeaJS 的出现,貌似以一种比较完美的形式解决了 JS 模块化的问题,是 CommonJS 在浏览器端的践行者,并吸收了 RequestJS 的优点。

ES Module

ES Module 是目前 web 开发中使用率最高的模块化标准。

随着 JS 模块化开发的呼声越来越高,作为 JS 语言规范的官方组织 ECMA 也开始将 JS 模块化纳入 TC39 提案中,并在 ECMAScript 6.0 中得到实践。

ES Module 吸收了其他方案的优点并以更优雅的形式实现模块化,它的思想是尽量的静态化,即在编译时就确定所有模块的依赖关系,以及输入和输出的变量,和 CommonJS 和 AMD/CMD 这些标准不同的是,它们都是在运行时才能确定需要依赖哪一些模块并且执行它。ES Module 使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,实现一些只能靠静态分析实现的功能(比如引入宏(macro)和类型检验(type system)。

标准内容:

  1. 模块功能主要由两个命令构成:exportimportexport 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。
  2. 通过 export 命令定义了模块的对外接口,其他 JS 文件就可以通过 import 命令加载这个模块。

ES Module 可以有多种用法:

模块的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* export 只支持对象形式导出,不支持值的导出,export default 命令用于指定模块的默认输出,
* 只支持值导出,但是只能指定一个,本质上它就是输出一个叫做 default 的变量或方法
*/
// 写法 1
export var m = 1;
// 写法 2
var m = 1;
export { m };
// 写法 3
var n = 1;
export { n as m };
// 写法 4
var n = 1;
export default n;

模块的引入:

1
2
3
4
5
6
// 解构引入
import { firstName, lastName, year } from 'a-module';
// 为输入的变量重新命名
import { lastName as surname } from 'a-module';
// 引出模块对象(引入所有)
import * as ModuleA from 'a-module';

在使用 ES Module 值得注意的是:importexport 命令只能在模块的顶层,在代码块中将会报错,这是因为 ES Module 需要在编译时期进行模块静态优化,importexport 命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行,这种设计有利于编译器提高效率,但也导致无法在运行时加载模块(动态加载)。

对于这个缺点,TC39 有了一个新的提案 – Dynamic Import,提案的内容是建议引入 import() 方法,实现模块动态加载。

1
2
// specifier: 指定所要加载的模块的位置
import(specifier)

import() 方法返回的是一个 Promise 对象。

1
2
3
4
5
6
7
import('b-module')
.then(module => {
module.helloWorld();
})
.catch(err => {
console.log(err.message);
});

import() 函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import() 函数与所加载的模块没有静态连接关系,这点也是与 import 语句不相同。import() 类似于 Node 的 require 方法,区别主要是前者是异步加载,后者是同步加载。

通过 importexport 命令以及 import() 方法,ES Module 几乎实现了 CommonJS/AMD/CMD 方案的所有功能,更重要的是它是作为 ECMAScript 标准出现的,带有正统基因,这也是它在现在 Web 开发中广泛应用的原因之一。

但 ES Module 是在 ECMAScript 6.0 标准中的,而目前绝大多数的浏览器并直接支持 ES6 语法,ES Module 并不能直接使用在浏览器上,所以需要 Babel 先进行转码,将 import 和 export 命令转译成 ES2015 语法才能被浏览器解析。

总结

JS 模块化的出现使得前端工程化程度越来越高,让使用 JS 开发大型应用成为触手可及的现实(VScode)。纵观 JS 模块化的发展,其中很多思想都借鉴了其他优秀的动态语言(Python),然后结合 JS 运行环境的特点,衍生出符合自身的标准。但其实在本质上,浏览器端的 JS 仍没有真正意义上的支持模块化,只能通过工具库(RequireJS、SeaJS)或者语法糖(ES Module)去 Hack 实现模块化。随着 Node 前端工程化工具的繁荣发展(Grunt/Gulp/webpack),使我们可以不关注模块化的实现过程,直接享受 JS 模块化编程的快感。

在复习 JS 模块化的过程中,对 Webpack 等工具的模块化语法糖转码产生了新的兴趣,希望有时间可以去分析一下模块化的打包机制和转译代码,然后整理出来加深一下自己对模块化实现原理的认识和理解。

期待下一篇。

参考文章: