js基础之模块化规范:CommonJS、AMD、CMD、ES6

一、什么是模块化?

模块化是一种将复杂系统分解为更好的可管理模块的方式(引自百度百科)。

在软件开发领域,当一个系统的规模变得十分庞大,功能变得非常复杂时,开发和维护该系统就会变得相当困难。这个问题在软件开发早期就已经初见端倪。随着软件行业的成熟,它更是成为软件开发的头号敌人,并在20世纪60年代末期造成了一场著名的“软件危机”。这次软件危机还催生了软件工程这个研究领域,旨在研究如何对大型软件项目进行科学化地管理。

其中模块化开发就是该领域最重要的成果之一。它的基本思想是将整个系统划分成若干个模块,每个模块负责一个相对独立的功能,然后单独开发和维护这些模块,最后再将这些模块进行整合,以支撑整个系统的运行。

互联网的发展也是类似的。

互联网刚刚诞生时,各个网站的功能相对简单,编写和维护都没有太大的难度。但进入21世纪后,web的发展更加成熟,数以百万计的web站点被开发出来,取代了许多复杂的桌面应用。伴随着这一浪潮,网站的开发和维护工作又变得相当困难。由于有之前“软件危机”的应对经验,模块化开发迅速被应用于web领域。不过这里指的主要是后端开发,因为彼时的前端开发还处于萌芽期。

但是现在,你可能很难想象如何在没有模块化规范的情况下,开发出像React、Vue这般复杂的前端框架。前端的发展自然而然地催生了前端的模块化方案。

目前前端的模块化方案主要有四个:CommonJS、AMD、CMD,以及ES6规范所定义的import/export规范。

其中CommonJS主要被应用于Nodejs、Browerify等;AMD规范的主要实现方案是RequireJS,主要用于浏览器端;CMD规范的主要实现是淘宝的模块化框架Sea.js;ES6的模块化方案则是未来的模块化标准。

下面我们具体来看一下这四个规范。

二、前端模块化规范详解

1. CommonJS

(1)基本用法

CommonJS是最早诞生的面向JavaScript的模块化规范,由Mozilla工程师Kevin Dangoor于2009年1月启动,最初名为ServerJS,同年8月更名为CommonJS。目前使用CommonJS规范的最重要的项目包括:Nodejs、Browerify。

在CommonJS规范中,每个文件就是一个模块(module),有自己的作用域。它内部定义的所有变量、方法、类都是私有的,对其他文件不可见。如:
moduleA.js

var a = 1;
function getA(){
  return a;
}

在CommonJS规范下,这里的变量a和函数getA对外都是不可见的。

CommonJS规范的全局对象是global(类似于浏览器环境下的window对象),因此如果将变量或方法添加到global对象上,它对其他文件是可见的,如:

global.a = 1;

此时变量a被添加到了CommonJS的全局对象上,在任意文件内都可以访问。当然,这种做法在模块化开发中是极不推荐的。

CommonJS推荐的做法是通过module.exports向外暴露接口。如:

var a = 1;
function getA(){
  return a;
}

module.exports.getA = getA;

在CommonJS规范下,每个文件用来定义一个模块,对应一个module对象。它的exports属性是当前模块向外暴露的接口对象,其他文件在加载当前模块时,其实就是在加载该模块对象的exports属性。我们将函数getA添加为exports的一个属性,它就可以被外部文件所访问了。而没有被添加到exports属性上的变量a对外仍然是不可见的,此时它只能作为当前模块的私有变量使用。

为了方便,CommonJS还在每个模块内专门定义了一个exports变量,指向当前模块的exports属性,因此,上面的代码可以简写为:

exports.getA = getA;

也就是说,CommonJS相当于进行了下面这样一个赋值过程:

var exports = module.exports;

因此,如果你直接为变量exports赋值,你将切断该变量与Module.exports属性的联系,此时它将无法再向外提供接口:

exports = 'Hello World';

外部无法得到这里暴露出去的值,因为exports变量此时不再指向module.exports。不过module.exports是可以直接赋值的,此时当前模块将只对外暴露这一个值(一般来说会是一个函数或一个类),如:

var a = 1;

module.exports = function(){
  return a;
}

CommonJS通过require命令来导入其他模块。前面说了,导入的实际上就是该模块对象moduleexports属性。比如对于上面的例子:

var fun = require('moduleA.js');
// 相当于var fun = moduleA.exports;

fun();  // 1

(2)运行机制

假如有以下两个文件:
moduleA.js

var a = 1;
var getA = function(){
  return a;
}

module.exports.getA = getA;

moduleB.js

// ES6的解构赋值,从接口对象中提取属性getA
var { getA } = require('moduleA.js'); 

getA();  // 1

此时moduleB.jsmoduleA.js中导入了一个函数使用。以Nodejs环境为例,当我们输入以下命令运行moduleB.js时:

node moduleB.js

Nodejs引擎会按照CommonJS规范做下面几件事:

  1. 读取moduleB.js,开始执行
  2. 执行到第一行require语句时,同步加载依赖的模块moduleA.js
  3. 执行moduleA.js内的代码,执行完后得到下面这个模块对象:
{ id: '.',
  exports: { 'getA': [Function] },
  parent: (moduleB.js对应的模块对象),
  filename: '(文件路径)/moduleA.js',
  loaded: false,
  children:[],
  paths:[......]
}
  1. 将该对象的exports属性作为require的结果进行解构赋值,即var { getA } = require('moduleA.js');,变量getA即可从模块A中提取接口函数getA
  2. 运行getA,得到结果1

通过以上的介绍我们看到,CommonJS规范是实时同步加载依赖的。也就是说,只有每次执行到一个require语句时,它才会去加载和执行该模块。得到执行结果后,引擎会进行赋值,然后继续执行后面的代码。

在Nodejs环境下,模块文件都是存储在本地硬盘中的,加载速度相对较快,所以这种实时加载,同步执行并不会带来什么性能问题。

但是在浏览器环境下则大不一样。因为浏览器加载模块文件需要通过http请求,从服务器下载,而这个过程是非常耗时的。假如浏览器在执行某个模块时,发现依赖了另一个模块,那么它必须去下载该模块,并且在模块下载完之前,浏览器一直处于等待状态(同步执行所导致的),这会带来非常严重的性能问题。因此,CommonJS并不是一个适合浏览器端的模块加载方案,而是多用于服务端(这也是它最初为什么叫ServerJS)。

2. AMD

AMD规范就是为了解决CommonJS实时加载,同步执行所带来的的弊端而出现的,主要用于浏览器端。它兼容CommonJS规范,符合CommonJS规范的模块可以被AMD规范正确导入。

它的全称是async module define,即异步模块定义。顾名思义,这是一个异步模块加载方案。它的主要思想是,在模块的顶部声明所有的依赖模块(也可以使用require导入,但不同于CommonJS,它们会被提前预加载),并提供一个回调函数。浏览器预先加载好这些依赖模块后,会将这些模块对象作为参数传给回调函数并执行。

AMD规范使用统一的define方法来定义和导入模块。它最多接收三个参数:

  1. id(可选),即模块名,字符串类型
  2. 依赖列表,数组类型,数组每项的值是依赖的模块路径或模块名(此时需要在配置文件中配置该模块的路径)
  3. 回调函数或当前模块定义的模块对象

一个完整的AMD模块看起来大致是这样的:
moduleA.js

define('moduleA', ['jquery', 'Base64'], function($, Base64){
  ...
})

第一个参数是可选的,它定义当前模块的名字,当它被其他模块引入时将使用该名字。在省略该参数的情况下,将自动使用文件名(即moduleA,这里是不带文件后缀的)作为该模块的名字。

第二个参数是个依赖列表,指定了当前模块所依赖的模块。该参数也可以省略,此时有两种可能:一是当前模块没有依赖的模块,二是你将在回调函数中通过require引入模块。在使用具体的框架(如require.js)时,你需要提供一个配置文件,来指定这些模块的路径(以及声明依赖关系)。如:
index.html

<script src="/js/requirejs/require.js"></script>
<script src="/js/require/require.config.js"></script>

require.config.js

require.config({
	baseUrl: "/",
	waitSeconds: 0,
	paths: {
		jquery: "/js/jquery-3.2.1.min",
		Base64:"/js/Base64",
		bootstrap: '/js/bootstrap.min',
	},
	shim: {
	// 指定模块的依赖关系
		'bootstrap': { 
			deps: ["jquery"]
		}
	}
});

require.js将根据这个配置文件,预先加载好所有的模块。当模块加载完毕,引擎会自动调用传入的回调函数,并将依赖的模块对象按照声明的顺序依次传入回调函数使用(如上面声明了对jqueryBase64的依赖,它们就会分别作为参数传入回调函数,我们通过形参$Base64来接收它们)。

该参数也是可以省略的,但这并不表示当前模块一定没有依赖外部模块,你可以通过require语句来导入依赖的模块。如:

define(function(){
  var $ = require('/js/jquery.min');
  ...
})

乍看上去,这里的require语法似乎又会导致实时加载、同步执行的性能问题,但其实并不是这样的。

AMD规范并不会在执行该模块时才去下载和执行依赖模块,而是先调用回调函数的toString方法,将其转化为字符串。然后用正则表达式匹配其中的require(...)语句,提取依赖模块,并依次下载这些模块。

注意,Opera下函数的toString方法存在一定问题,可以通过构建工具解决该问题。

回调函数最终的返回值即是当前模块对外的输出,如:

define(function(){
  var a = 1;
  return function(){
    return a; 
  }
})

这里只对外输出了一个接口函数。你也可以通过return一个对象,向外输出一个接口对象:

...
return {
  a: a,
  getA: function(){
    return a; 
  }
}

你可以像下面一样输出一个符合CommonJS规范的对象:

define(function (require, exports, module) {
     var a = require('a'),
         b = require('b');

     exports.action = function () {};
   });

AMD规范会帮你自动注入这三个参数。

当然,你可以仅仅向define传入一个对象,此时该对象就是模块对象:

define({
     add: function(x, y){
       return x + y;
     }
   });

如果当前文件不是为了定义一个模块,而是需要像普通的js一样执行,可以直接使用require函数:

require(['jquery', 'Base64'], function($, Base64){
  ...
})

这个文件将不会作为一个模块使用,但是它同样可以从AMD规范中受益。

3. CMD

CMD规范与AMD规范非常相似,但是相对来说更严格。它的基本语法有点类似于CommonJS,如:

define(function(require, exports, module) {
  var $ = require('jquery');
  module.exports = {...}
  exports.doSomething = function(){}
})

在AMD规范中我们也举了一个类似的例子,这就是说,其实CMD规范定义的模块在AMD中也是合法的。只是CMD严格要求使用这种语法来定义模块。这使得CMD规范看上去更直观和简洁。

AMD规范所推崇的是依赖前置,即提前声明好需要依赖的模块。而CMD则推崇依赖就近,也就是在用到的地方去声明依赖模块(尽管AMD模块也允许依赖就近,但只是允许,而不是推荐)。

CMD的define函数接收的三个参数与CommonJS模块系统的概念是一致的:

  1. require是导入其他模块的方法
  2. exports是指向module.exports的变量
  3. module即模块对象,同CommonJS

因此CMD规范保持了很好的对CommonJS和AMD规范的兼容性,同时它又比AMD规范的api定义得更加严格和简洁。

注意,与AMD一样,尽管CMD也使用require导入模块,但它也是异步加载的。CMD规范也会提前扫描回调函数,预下载依赖的模块,然后才会执行回调函数,以防止代码在执行过程中被阻塞。

4. ES6的模块规范

(1)基本用法

ES6的模块规范是JavaScript语言层面的模块规范,目标是取代上述所有规范,成为前端领域的标准模块规范。未来的浏览器将原生支持该模块规范。

ES6模块规范的一大特点是静态化,也就是可以在预编译的时候就尽可能地确定模块的依赖关系,以及需要输入和输出的变量。它使用import命令导入其他模块提供的接口,使用export命令导出接口。基本语法如下:
moduleA.js

import {a, getA} from 'moduleB';

getA();

moduleB.js

var a = 1;

function getA(){
  return a;
}

export { a, getA };

实现了ES6模块规范的引擎可以在编译的时候就分析出moduleA依赖了moduleB向外提供的接口{ a, getA }。同样的,它也可以在编译阶段检测出moduleB向外提供了两个接口:agetA。这种静态化的导入导出可以极大提高引擎的执行效率,另外可以在编译阶段检测出异常接口。

(2)export命令

export命令用于导出模块的接口,其实现逻辑与闭包很类似。

在ES6规范中,模块不是对象,因此这些接口也就不像CommonJS规范一样是模块对象的属性。你可以理解为,每个ES6模块独自占有一块内存区(如同由闭包形成的一个封闭空间一样),通过export命令指定外部可访问的接口(这些接口就像是闭包的返回值一样,可以直接访问该模块所在的内存区)。
在这里插入图片描述
你可以在任何位置输出一个或多个接口:

export var a = 1;

function getA(){
  return a;
}

export { getA };

上面的代码与下面的闭包有着几乎一致的逻辑:

(function(){
  var a = 1;
  function getA(){
    return a;
  }
  
  return {a, getA};
})()

正是由于这种机制,下面的写法不能暴露出变量a(注意,export var a = 1与下面的写法不等价):

var a = 1;

export a; 

可以参考下面的写法:

(function(){
  var a = 1;
  
  return a;
})()

显然这是几乎没有意义的,因为接口规范要求暴露出来的是接口,而不是一个常量值。

另外,由于采用了这种导出机制,ES6的模块系统可以实时拿到接口最新的值。比如下面的例子:
moduleA.js

import {a} from 'moduleB'

console.log(a);  // 1
setTimeout(function(){
  console.log(a);  // 2
}, 100)

moduleB.js

var a = 1;

setTimeout(function(){
  return a + 1;
})

export {a};

上面的过程大致如下:

  1. 引擎在解析到import {a} from 'moduleB'语句时,会去执行模块B,并为其开辟一块内存区(就像闭包一样)。
  2. 将模块B暴露出的接口{a}导出,并在模块A中导入。由于模块B有自己的内存区,因此它保留着变量a。
  3. 当同步逻辑执行完毕后,引擎将去任务队列取出模块B中的异步任务,将变量a的值加一。
  4. 100毫秒后,模块A中的异步任务被执行,它会实时从模块B所在的内存区去取变量a的值,因此会输出新的值2。

在CommonJS模块内,使用exports.a = a导出变量就等价于export a,也就是只导出了一个值,因此这样无法获取变量a实时的值,但这并不意味着CommonJS总是无法得到变量a最新的值:

// moduleB
module.exports.tempA = {a}

//moduleA
var {tempA} = require('moduleB');
// temp现在持有moduleB中的对象{a}的内存地址,
// 这样它就可以间接操作变量a

看到了吗?我们没有将变量直接作为exports的属性返回,而是将其封装成了一个对象返回,这样它就会持有模块B中变量a的内存地址,因此也就可以同步跟踪变量a的值变化。

从这个角度来说,ES6的模块规范和CommonJS规范其实都是基于闭包原理实现的。下面我们给出一个例子:

(function(){
  var a = 1,
      b = 2;
  return {a};  // 普通的闭包

  export {a};  // ES6的模块规范
  export {b};  // 你可以export任意多的接口,而不像return,只能返回一次
})()

// CommonJS规范
// moduleB
var moduleB = {};
(function(){
  var a = 1;
  // CommonJS下是在操作闭包外的一个对象
  moduleB.exports.a = a;
})()

// moduleA
var { a } = moduleB.exports; // 解析出变量a

所以CommonJS并不是无法跟踪变量的值,而是你本来就只是把变量的值1保存在了模块对象moduleB.exports.a中了而已。

(3)import命令

在理解了export的实现之后,import命令已经极其简单,就是从ES6的模块注册区导入其他模块向外暴露出的接口(其实就是变量地址)。

import {a} from 'moduleB'

引擎在执行模块B的代码时会开辟一块内存,这里存在一个变量a,模块B通过export命令将其注册到ES6的模块系统,然后模块A从这个模块系统中拿到变量a的地址,仅此而已。由于模块A和模块B拿到的都是变量a的地址,因此它们的值总是同步的。

注意,每个模块都可以指定一个默认的对外接口:

// moduleB
export default function(){...}

// moduleA
import getName from 'moduleB'

这时导入的时候不需要匹配原模块的接口名,可以任意定义一个变量来接收这个接口,并且不需要使用大括号包裹。

三、其他规范

这里涉及的规范严格来讲并不是规范,而是基于基本规范的解决方案或执行环境。我们主要介绍UMD规范和webpack模块规范。

1. UMD规范

全称是universal module define,通用模块定义。它是一种适配CommonJS、AMD和CMD的模块定义标准。一个标准的UMD规范模块如下:

!( function( root, name, factory) {
    // 是否在AMD规范下
    if ( typeof define === 'function' && define.amd) {
        define( name, factory);
    // 是否在CommonJS或CMD规范下
    } else if ( typeof exports === 'object') {
        module.exports = factory();
    // 没有使用模块化
    } else {
        root[name] = factory();
    }
})( this, 'run', function () {
    function run () {
        return 'this is run function!';
    }
    return run;
});

可以看到,UMD规范在主函数中会通过检查define、exports这两个变量,来判断当前处于哪种模块化规范。判断依据如下:

  1. typeof define === 'function' && define.amd,这是判断是否在AMD规范的条件
  2. typeof exports === 'object',这个条件可以判断是否处于CommonJS或CMD规范下。CommonJS自然不用说。我们上面说到,CMD规范是兼容CommonJS的,它也通过一个变量exports来暴露接口对象,因此语句module.exports = factory();可以同时用于CommonJS和CMD规范来定义模块。
  3. 不符合上述条件,则认为没有处于模块化环境下。这时当前文件会把输出结果直接作为this的属性输出出去,如在浏览器环境下时,返回值会被添加到window对象上(在浏览器环境下,this就指向window)。

现在无论是在CommonJS、AMD还是CMD规范下运行该代码,都可以输出符合所用模块规范的模块,从而实现了规范的兼容。

注意,UMD没有对ES6的模块规范进行处理,因为ES6模块规范是语言层面的规范,未来会得到浏览器的原生支持。一旦ES6规范得到原生支持,UMD规范以及另外三个规范都可能被取代。

2. webpack模块规范

webpack本身并没有自己的模块规范,但在webpack环境下,你可以使用CommonJS和ES6标准模块规范。

对于CommonJS规范,webpack本身就运行于Nodejs环境下,它的编译运行全部都依赖Nodejs。而Nodejs默认的模块规范就是CommonJS,所以你当然可以用CommonJS规范来导入导出模块。

对于ES6的模块规范,由于高版本的Nodejs提供了对ES6规范的原生支持,所以webpack自然也支持ES6模块规范。

另外,require方法在webpack环境下功能更加强大。除了导入js模块,它还可以用于导入任何其他类型的资源,如css、图片、PDF等,webpack将借助对应的loader来处理这些资源。

总结

本文侧重于讲解四种模块规范的实现原理,而没有深究它们的各种语法细节。我相信只要理解了这些原理,它们的语法很快就可以掌握了。

关于ES6的模块规范,如果有想了解更多细节的同学,可以参考阮一峰 Module 的语法

发布了49 篇原创文章 · 获赞 110 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/qq_41694291/article/details/105180077