CommonJS,AMD,CMD,ESM模块化规范详述

2,372 阅读16分钟

JavaScript 语言诞生至今,模块规范化之路在曲折中前进。前端社区先后出现了各种解决方案: AMD、CMD、CommonJS 等,而后 ECMA 组织在 JavaScript 语言标准层面,增加了模块功能ESM(因为该功能是在 ES2015 版本引入的,所以也被称为 ES6 module)。

本文将会梳理整个前端模块化的发展历程,简述其升级打怪之路。

概述

模块化概念

将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起;
块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信。

上述内容给出了模块的几个关键词:分拆、组合、私有域、对外接口。
遗憾的是直到ES6发布之前,JavaScript这门语言一直没有一个官方认证的模块(module)体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。
这也不怪JavaScript的设计者,当初开发JavaScript的目的很简单,只是为了让浏览器完成与用户输入、点击等简单地交互。

1994年,网景公司(Netscape)发布了Navigator浏览器0.9版。这是历史上第一个比较成熟的网络浏览器,轰动一时。但是,这个版本的浏览器只能用来浏览,不具备与访问者互动的能力。......网景公司急需一种网页脚本语言,使得浏览器可以与网页互动。

Netscape_logo.png

只是随着JavaScript应用场景的不断扩展,越来越复杂的交互操作势必带来代码的复杂性和体积的双重增长。JavaScript亟待一套行之有效的模块化方案来应对日益复杂的编程环境。

模块化作用

在实际的开发过程中,经常会遇到变量、函数、对象等名字的冲突,这样就容易造成逻辑冲突,还会造成全局变量被污染;
同时,程序复杂时需要写很多代码,而且还要引入很多类库,这样稍微不注意就容易造成文件依赖混乱;
其次,对于某些功能其实是大量重复使用的,完全可以封装成一个可复用代码单元;
上面的问题和需求都依赖于模块化来解决,简而言之,模块的作用有:

  • 避免全局变量被污染
  • 便于代码编写和维护
  • 利于封装可重用逻辑

其他语言都有这项功能,比如 Ruby 的require、Python 的import,甚至就连 CSS 都有@import,但是 JavaScript 任何这方面的支持都没有,这对开发大型的、复杂的项目形成了巨大障碍。

在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJSAMD/CMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD/CMD 规范,成为浏览器和服务器通用的模块解决方案。

最初的模块化

1.普通写法(全局函数及变量)

将不同的函数或变量放一起就是简单的模块,这样弊端很明显,就是变量容易被污染,也谈不上良好的封装性,可复用性基本为零。
早期的在HTML页面中以内联script标签编写的脚本可以认为是普通写法的直观展现:

<!-- 页面内嵌的脚本 -->
<script type="application/javascript">
  // module code
</script>

<!-- 外部脚本 -->
<script type="application/javascript" src="./module1.js">
</script>

2.对象封装

将整个模块放在一个对象里面,外部访问时直接调用对象的属性或者方法就行。
这种方法虽然解决了变量冲突问题,但是容易被外部随意修改:

var utils = {
  request() {
    console.log(window.utils);
  }
}

// 使用时
utils.request();
window.utils.request();

3.匿名立即函数方式

浏览器环境下,在全局作用域声明的变量都是全局变量。全局变量存在命名冲突、占用内存无法被回收、代码可读性低等诸多问题。匿名立即执行函数(IIFE)出现了:

var say = {name: 'hello world'};
(function (say) {
  var say = {name: 'hahaha'};
  console.log('say.name = ',say.name);
}());
console.log('say.name = ', say.name)

采用上述写法后,匿名函数里的say和外部的say变量在不同的作用域下,不会互相干扰。
匿名函数方式基本上解决了函数污染及变量随意被修改问题,这个也是JavaScript模块化规范的基石!

模块化规范

进入本文的正题:JavaScript模块化规范。
根据匿名函数自调用的方式,同时为了增强代码依赖性,现在大部分JavaScript运行环境都有自己的模块化规范。 可以分为:AMDCMDCommonJSESM四大类。

AMD

面对一种模块化方案,首先要了解的是:1. 如何导出模块接口;2. 如何导入模块接口。
AMD是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。
它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

异步模块定义规范(AMD)制定了定义模块的规则,这样模块和模块的依赖可以被异步加载。这和浏览器的异步加载模块的环境刚好适应(浏览器同步加载模块会导致性能、可用性、调试和跨域访问等问题)。

其模块调用时序图如下:

2880px-Asynchronous_Module_Definition_overview_vector.svg.png

AMD语法:

// ts声明
/**
 * @param {string} id 模块名称
 * @param {string[]} dependencies 模块所依赖模块的数组
 * @param {function} factory 模块初始化要执行的函数或对象
 * @return {any} 模块导出的接口
 */
function define(id?, dependencies?, factory): any

// 语法模板
define([模块名称], [依赖模块], function(){
  name: 'vey-module-1',
  getName: function(){
    return name
  }
  return {getName: getName}
})

// 实例
define(['m1'], function(m1){
  name: 'vey-module-2',
  function show() {
    console.log(name, m1.getName())
  }
  return { show }
})

RequireJS

AMD 是一种异步模块规范,RequireJS则是AMD规范的实现。

特别说明:先有 RequireJS,后有 AMD 规范,随着 RequireJS 的推广和普及,AMD 规范才被创建出来。

接下来,我们用 RequireJS 重构上文中对象封装的demo。

// utils.js定义模块
define(function(config) {
  var utils = {
    request() {
      console.log(window.utils);
    }
  };
  return utils;
});

// main.js引用模块
require(['./utils'], function(utils) {
  utils.request();
});
<!-- index.html  -->
<body>
  <!-- 先引入require.js库  -->
  <script src="./lib/require.js"></script>
  <!-- 引入main.js  -->
  <script src="./main.js"></script>
</body>
</html>

可以看到,使用 RequireJS 后,每个文件都可以作为一个模块来管理,通信方式也是以模块的形式,这样既可以清晰的管理模块依赖,又可以避免声明全局变量。

主要特征

  • 关键词:异步、define定义接口、require引入接口、全量加载;
  • AMD的核心实现就是通过define来定义模块,然后通过require来加载模块;
  • AMD是依赖前置的,即不管你用没用到,只要你设置了依赖,就会去全量加载;
  • 运行时动态加载模块。

CMD

CMD 和 AMD 一样,也是一种异步模块化规范,使用场景也是浏览器环境。
CMD(Common Module Definition)更贴近 CommonJS Modules/1.1 和 Node Modules 规范,一个模块就是一个文件;
它推崇依赖就近,想什么时候 require就什么时候加载,在保留AMD规范所有特征的同时,实现了懒加载(延迟加载);
它也没有全局 require,每个API都简单纯粹 。 其语法模板为:

define(factory);

define 是一个全局函数,用来定义模块。

define(factory)

define 接受 factory 参数,factory 可以是一个函数,也可以是一个对象或字符串。
factory 为对象、字符串时,表示模块的接口就是该对象、字符串。比如可以如下定义一个 JSON 数据模块:

define({ "foo": "bar" });

也可以通过字符串定义模板模块:

define('I am a template. My name is {{name}}.');

factory 为函数时,表示是模块的构造方法。执行该构造方法,可以得到模块向外提供的接口。factory 方法在执行时,默认会传入三个参数:requireexports 和 module

define(function(require, exports, module) {
  // 模块代码
});

CMD 推崇的依赖就近写法支持对模块进行延迟加载,当然,虽然是延迟加载,但也是全量加载前提下的延迟加载。

// AMD
// 依赖必须一开始就写好
define(['./utils'], function(utils) {
  utils.request();
});

// CMD
define(function(require) {
  // 依赖可以就近书写
  var utils = require('./utils');
  utils.request();
});

sea.png

主要特征

  • 关键词:异步、延迟加载、define定义接口、require引入接口、全量加载;
  • CMD 是 SeaJS 在的推广和普及过程中被创造出来;
  • CMD是依赖后置,允许延迟加载,这是跟AMD最大的区别。

随着 ES6 模块规范的出现,AMD/CMD 终将成为过去,目前主流项目中对 AMD 和 CMD 的使用也越来越少,大家对 AMD 和 CMD 有大致的认识就好,此处不再过多赘述。但毋庸置疑的是,AMD/CMD 的出现,是前端模块化进程中重要的一步。

CommonJS

2009年,美国程序员Ryan Dahl创造了node.js项目,将javascript语言用于服务器端编程。

image.png

前面提到AMD、CMD主要用于浏览器端,随着 node 诞生,服务器端的模块化规范 CommonJS 被创建出来。
这标志"Javascript模块化编程"正式诞生。因为事实上在浏览器环境下,没有模块也不是特别大的问题,毕竟网页程序的复杂性有限;但是在服务器端,是一定要有模块的概念的,因此涉及到与操作系统和其他应用程序互动,没有模块根本没法正常编程!

node.js的模块系统,就是参照CommonJS规范实现的。在CommonJS中,有一个全局性方法require(),用于加载模块。还是以上面介绍到的utils.js、main.js 为例,看看 CommonJS 的写法:

// utils.js
var utils = {
  request() {
    console.log(window.utils);
  }
};
module.exports = utils;

然后,就可以调用模块提供的方法:

// main.js
var utils = require('./utils');
utils.request();

module.exports和exports

在开发node程序时进行模块导出时有的地方使用module.exports,而有的地方使用exports,这两个有什么区别呢?
CommonJS 规范仅定义了exports,但exports存在被重写的而丢失的问题,所以module.exports被创造了出来,它被称为 CommonJS2 。
在CommonJS规范里,每一个js文件都是一个模块,每个模块里都有一个全局module对象,这个module对象的exports属性用来导出接口,外部模块导入当前模块时,使用的也是module对象,这些都是 node 基于 CommonJS2 规范做的处理。

// hello.js
var s = 'hello world!'
module.exports = s;
console.log(module);

打印结果: image.png

其他模块导入该模块时:

// main.js
var hello = require('./hello.js'); // hello = hello world!

当在 hello.js 里这样写时:

// hello.js
var s = 'hello world!'
exports = s;

hello.js 模块的module.exports将会被重写,变成一个空对象:

// main.js
var hello = require('./hello.js'); // a --> {}

我们把上面的问题模拟一下,并对比module.exportsexports两者,就更清楚了:

var module = {
  exports: {}
}
var exports = module.exports;
console.log(module.exports === exports); // true

var s = 'hello world!'
exports = s; // module.exports 不受影响
console.log(module.exports === exports); // false

上面的代码很好地模拟了exports被重写的原因:

  1. 在模块初始化时,exportsmodule.exports指向同一块内存,因此有exports === module.exports;
  2. exports被重新赋值后,重新指向了新的内存地址,也就切断了跟原内存地址的联系,所以就有exports !== module.exports

所以,exports规范的使用方式是:

// hello.js
exports.s = 'hello world!';

// main.js
var hello = require('./hello.js');
console.log(hello.s); // hello world!

在CommonJ中禁止直接使用 exports = xxx 导出模块!!!

CommonJS 和 CommonJS2 经常被混淆概念,通常大家经常说到的 CommonJS 其实是指 CommonJS2

CommonJS与AMD

相同点:

  • CommonJS 和 AMD 都是运行时加载,换言之:都是在运行时确定模块之间的依赖关系。
    二者不同点:
  • CommonJS 是服务器端模块规范,AMD 是浏览器端模块规范。
  • CommonJS 加载模块是同步的,即执行var hello = require('./hello.js');时,在 hello.js 文件加载完成后,才执行后面的代码。AMD 加载模块是异步的,所有依赖加载完成后以回调函数的形式执行代码。

主要特征

  • 关键词:同步、内部变量私有、module.exportsexportsrequire
  • 在node环境下使用,不支持浏览器环境,因为浏览器没有 module, exports, require, global 四个环境变量;
  • 使用exports\module.exports导出模块,使用require()进行引入模块。
  • 模块是同步加载的,即只有加载完成,才能执行后面的操作;
  • 模块在首次执行后就会缓存,再次加载只返回缓存结果,如果想要再次执行,可清除缓存;
  • CommonJS输出是值的拷贝(即require返回的值是被输出的值的拷贝,模块内部的变化也不会影响这个值)。

ESM

ESM即ES6 module,在 ES6 之前,JavaScript社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS和 AMD/CMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
ESM规范指令很简单,只有三个:importexportexport default
export\export default 用来导出模块接口,import用来引入模块接口。

export

export导出接口有以下方式:

// config.js
// 直接导出
export const hello = 'hello world!';
export const api = `${prefix}/api`;

// 集中导出
const hello = 'hello world!';
const api = `${prefix}/api`;
export {
  hello,
  api,
}

上面两种导出方式只是写法不同,结果是一样的,都是把helloapi分别导出。

// foo.js
export default function foo() {}

// 等同于:
function foo() {}
export {
  foo as default
}

export default用来导出模块默认的接口,它等同于导出一个名为default的接口。配合export使用的as关键字用来在导出接口时为接口重命名。

导入导出简写(在导入的同时直接导出):

export { api } from './config.js';

// 等同于:
import { api } from './config.js';
export {
  api
}

import

根据导出的模式,有相应的导入方式:

import { api, hello } from './config.js';

// 配合`import`使用的`as`关键字用来为导入的接口重命名。
import { api as myApi } from './config.js';

整体导入:

import * as config from './config.js';
const api = config.api;

对于export default导出的模块:

import foo from './foo.js';

// 等同于:
import { default as foo } from './foo.js';

除了导入指定的方法和对象,import还可以整体导入模块但是不指定具体的内容:

import from './config.js'
import from './foo.js'

另外,在ES2020中,新引入的import()特性更是支持按需加载,极大提高了模块引用的灵活性,import()指令会返回一个Promise对象,里面包含有导入的模块对象:

function foo() {
  import('./config.js')
    .then(({ default }) => {
        default();
    });
}

ESM 与 CommonJS

在讨论两者之前,必须明确一个事实:ES6 模块与 CommonJS 模块完全不同。
它们之间有三个重大差异。

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
    • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
    • ESM 的运行机制与 CommonJS 不一样。JS 引擎在对脚本静态分析的时候,遇到模块加载命令import,会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ESM 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ESM 是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
  2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
    • 运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
    • 编译时加载: ESM 不是对象,而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载即所谓的“编译时加载”。
  • CommonJS 模块的require()是同步加载模块,ES6 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段。

其中,第二个差异是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ESM 不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。关于这三个差异的具体示例请参看ES6 模块与 CommonJS 模块的差异

总结

Javascript 程序本来很小——在早期,它们大多被用来执行独立的脚本任务,在你的 web 页面需要的地方提供一定交互,所以一般不需要多大的脚本。过了几年,我们现在有了运行大量 Javascript 脚本的复杂程序,还有一些被用在其他环境(例如 Node.js)。

因此,有必要开始考虑提供一种将 JavaScript 程序拆分为可按需导入的单独模块的机制。在这一点上JavaScript社区是走在标准委员会前面的。

Node.js 采用的 CommonJS,还有很多的 Javascript 库和框架 已经开始了模块的使用 AMD 的其他模块系统 如 RequireJS, 以及最新的 Webpack 和 Babel)。

这些模块化规范在社区内广泛流行并被使用。直到ES6的横空出世,JavaScript终于在语言层面原生支持模块功能了。这绝对是广大开发者的福音 — 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。

目前,随着ESM的推广,AMD\CMD已经逐渐退出历史舞台,大家只需要了解即可。在日常开发中使用得多的还是 CommonJSESM,但很多人只知其然而不知其所以然,希望通过本篇文章,大家对 JS 模块化之路能够有清晰完整的认识。

参考

developer.mozilla.org/zh-CN/docs/…
nodejs.cn/api/modules…
requirejs.org/
github.com/seajs/seajs
es6.ruanyifeng.com/#docs/modul…