JavaScript 语言诞生至今,模块规范化之路在曲折中前进。前端社区先后出现了各种解决方案: AMD、CMD、CommonJS 等,而后 ECMA 组织在 JavaScript 语言标准层面,增加了模块功能ESM(因为该功能是在 ES2015 版本引入的,所以也被称为 ES6 module)。
本文将会梳理整个前端模块化的发展历程,简述其升级打怪之路。
概述
模块化概念
将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起;
块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信。
上述内容给出了模块的几个关键词:分拆、组合、私有域、对外接口。
遗憾的是直到ES6发布之前,JavaScript这门语言一直没有一个官方认证的模块(module)体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。
这也不怪JavaScript的设计者,当初开发JavaScript的目的很简单,只是为了让浏览器完成与用户输入、点击等简单地交互。
1994年,网景公司(Netscape)发布了Navigator浏览器0.9版。这是历史上第一个比较成熟的网络浏览器,轰动一时。但是,这个版本的浏览器只能用来浏览,不具备与访问者互动的能力。......网景公司急需一种网页脚本语言,使得浏览器可以与网页互动。
只是随着JavaScript应用场景的不断扩展,越来越复杂的交互操作势必带来代码的复杂性和体积的双重增长。JavaScript亟待一套行之有效的模块化方案来应对日益复杂的编程环境。
模块化作用
在实际的开发过程中,经常会遇到变量、函数、对象等名字的冲突,这样就容易造成逻辑冲突,还会造成全局变量被污染;
同时,程序复杂时需要写很多代码,而且还要引入很多类库,这样稍微不注意就容易造成文件依赖混乱;
其次,对于某些功能其实是大量重复使用的,完全可以封装成一个可复用代码单元;
上面的问题和需求都依赖于模块化来解决,简而言之,模块的作用有:
- 避免全局变量被污染
- 便于代码编写和维护
- 利于封装可重用逻辑
其他语言都有这项功能,比如 Ruby 的require
、Python 的import
,甚至就连 CSS 都有@import
,但是 JavaScript 任何这方面的支持都没有,这对开发大型的、复杂的项目形成了巨大障碍。
在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD/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运行环境都有自己的模块化规范。
可以分为:AMD、CMD、CommonJS、ESM四大类。
AMD
面对一种模块化方案,首先要了解的是:1. 如何导出模块接口;2. 如何导入模块接口。
AMD是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。
它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
异步模块定义规范(AMD)制定了定义模块的规则,这样模块和模块的依赖可以被异步加载。这和浏览器的异步加载模块的环境刚好适应(浏览器同步加载模块会导致性能、可用性、调试和跨域访问等问题)。
其模块调用时序图如下:
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
方法在执行时,默认会传入三个参数:require
、exports
和 module
:
define(function(require, exports, module) {
// 模块代码
});
CMD
推崇的依赖就近写法支持对模块进行延迟加载,当然,虽然是延迟加载,但也是全量加载前提下的延迟加载。
// AMD
// 依赖必须一开始就写好
define(['./utils'], function(utils) {
utils.request();
});
// CMD
define(function(require) {
// 依赖可以就近书写
var utils = require('./utils');
utils.request();
});
主要特征
- 关键词:异步、延迟加载、
define
定义接口、require
引入接口、全量加载;- CMD 是 SeaJS 在的推广和普及过程中被创造出来;
- CMD是依赖后置,允许延迟加载,这是跟AMD最大的区别。
随着 ES6 模块规范的出现,AMD/CMD 终将成为过去,目前主流项目中对 AMD 和 CMD 的使用也越来越少,大家对 AMD 和 CMD 有大致的认识就好,此处不再过多赘述。但毋庸置疑的是,AMD/CMD 的出现,是前端模块化进程中重要的一步。
CommonJS
2009年,美国程序员Ryan Dahl创造了node.js项目,将javascript语言用于服务器端编程。
前面提到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);
打印结果:
其他模块导入该模块时:
// 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.exports
和exports
两者,就更清楚了:
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
被重写的原因:
- 在模块初始化时,
exports
和module.exports
指向同一块内存,因此有exports === module.exports
; - 当
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.exports
、exports
、require
;- 在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规范指令很简单,只有三个:import
,export
,export 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,
}
上面两种导出方式只是写法不同,结果是一样的,都是把hello
和api
分别导出。
// 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 模块完全不同。
它们之间有三个重大差异。
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
- ESM 的运行机制与 CommonJS 不一样。JS 引擎在对脚本静态分析的时候,遇到模块加载命令
import
,会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ESM 的import
有点像 Unix 系统的“符号连接”,原始值变了,import
加载的值也会跟着变。因此,ESM 是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
- 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已经逐渐退出历史舞台,大家只需要了解即可。在日常开发中使用得多的还是 CommonJS
和 ESM
,但很多人只知其然而不知其所以然,希望通过本篇文章,大家对 JS 模块化之路能够有清晰完整的认识。
参考
developer.mozilla.org/zh-CN/docs/…
nodejs.cn/api/modules…
requirejs.org/
github.com/seajs/seajs
es6.ruanyifeng.com/#docs/modul…