JavaScript模块化规范(CommonJs AMD CMD UMD ES6)

2,363 阅读15分钟

一、 什么是模块化?

模块化是指将一个复杂的程序分解为多个模块,方便编码

二、为什么要使用模块化?

2.1、 函数写法

function m1(){
    // xxx
}
function m2(){
    // xxx
}

上面的函数m1、m2就相当于一个模块,使用的时候,直接调用就可以了。

但是这种做法缺点也很明显:由于函数是直接挂载在window(全局)对象下,"污染"了全局变量,无法保证不与其他模块发生变量名冲突,而且模块成员之间看不出直接关系。

2.2、 对象写法

既然window对象的可命名属性名就那么多,那我再在window(全局)对象上面声明一个对象,然后把所有的模块成员都放到这个对象里面。

var module = {
    count: 0,
    function m1(){
        // xxx
    }
    function m2(){
        // xxx
    }
}

上面的函数m1()和m2(),都封装在module1对象里。使用的时候,就是调用这个对象的属性。

module.m1();

但是,这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值。

module1._count = 5;

2.3、 立即执行函数

为了防止内部成员被暴露出去,我们用立即执行函数可以实现私有化变量。

const module2 = (function() {
	let _money = 100
	const m1 = () => {
		console.log(123)
	}
	const m2 = () => {
		console.log(456)
	}
	return {
		f1: m1,
		f2: m2
	}
})()

使用上面的写法,外部代码无法读取内部的_count变量。

console.info(module2._count); //undefined

不过虽然这样function内部的变量就对全局隐藏了,达到是封装的目的。但是这样还是有缺陷的,Module2这个变量还是暴漏到全局了,随着模块的增多,全局变量还是会越来越多。

2.4、使用Script来引用JS模块

<script type="text/javascript" src="a.js"></script>
<script type="text/javascript" src="b.js"></script>
<script type="text/javascript" src="c.js"></script>
<script type="text/javascript" src="d.js"></script>

缺点:

(1)加载的时候会停止渲染网页,引入的js文件越多,网页失去响应的时间越长;

(2)会污染全局变量;

(3)js文件之间存在依赖关系,加载是有顺序的,依赖性最大的要放到最后去加载;当项目规模较大时,依赖关系变得错综复杂。

(4)要引入的js文件太多,不美观,代码难以管理。

2.5、总结

使用函数写法会导致全局变量污染,并有可能导致命名冲突

使用命名空间会导致内部属性被暴露,可以导致内部成员被改写

使用立即执行函数可以实现私有化变量,可以达到一定的防御作用。是早期较好的模块化方案

使用Script来引用JS模块会导致文件关系错综复杂,难以管理

三、模块化规范

3.1、CommonJS

2009年,美国程序员Ryan Dahl创造了node.js项目,将javascript语言用于服务器端编程。这标志"Javascript模块化编程"正式诞生。因为老实说,在浏览器环境下,没有模块也不是特别大的问题,毕竟网页程序的复杂性有限;但是在服务器端,一定要有模块,与操作系统和其他应用程序互动,否则根本没法编程。而node.js的模块系统,就是参照CommonJS规范实现的。

3.1.1、CommonJS特点

  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后就直接读取缓存结果。要想让模块再次运行,必须清除缓存
  • 模块是同步加载的,因此模块加载的顺序,按照其在代码中出现的顺序
  • CommonJS采用同步加载不同模块文件,适用于服务器端的。因为模块文件都存放在服务器的各个硬盘上,读取加载时间快,适合服务器端,不适应浏览器。 浏览器不兼容CommonJs,原因是浏览器缺少module、exports、require、global四个环境变量。如要使用需要工具转换。

3.1.2、基本语法

  • 暴露模块:module.exports = valueexports.xxx = value
  • 引入模块:require(xxx),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径。

此处我们有个疑问:CommomJS暴露的模块到底是什么?CommonJS规范规定,每个模块内部,module变量代表当前模块,这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。

// a.js
module.exports = {
    a: 1
}
// or 
exports.a = 1

// b.js
var module = require('./a.js')
module.a // -> log 1

上面的写法很好用,但是 module.exports 和 exports 是咋回事?为啥这几句代码就实现模块化了,让我们来看一下基础的实现

先说 require 吧

var module = require('./a.js')
module.a 
// 这里其实就是包装了一层立即执行函数,这样就不会污染全局变量了,
// 重要的是 module 这里,module 是 Node 独有的一个变量
module.exports = {
    a: 1
}
// 基本实现
var module = {
  id: 'xxxx', // 我总得知道怎么去找到他吧
  exports: {} // exports 就是个空对象
}
// 这个是为什么 exports 和 module.exports 用法相似的原因
var exports = module.exports 
var load = function (module) {
    // 导出的东西
    var a = 1
    module.exports = a
    return module.exports
};
// 然后当我 require 的时候去找到独特的
// id,然后将要使用的东西用立即执行函数包装下,over

再来说说module.exportsexports的区别。

  1. exports是指向的module.exports的引用
  2. module.exports初始值为一个空对象{},所以exports初始值也是{},但是不能对exports直接赋值,不会有任何效果,,看了上面代码的同学肯定明白为什么了。
  3. require()返回的是module.exports而不是exports

3.1.3、模块的加载机制

CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。 这点与ES6模块化有重大差异(下文会介绍),请看下面这个例子:

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

上面代码输出内部变量counter和改写这个变量的内部方法incCounter。

// main.js
var counter = require('./lib').counter;
var incCounter = require('./lib').incCounter;

console.log(counter);  // 3
incCounter();
console.log(counter); // 3

上面代码说明,counter输出以后,lib.js模块内部的变化就影响不到counter了。这是因为counter是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。

CommonJS规范是 Node 独有的,如果浏览器想使用该规范,就需要用到 Browserify 解析了。

3.1.4、Browserify

Browserify 可以让你使用类似于 node 的 require() 的方式来组织浏览器端的 Javascript 代码,通过 预编译 让前端 Javascript 可以直接使用 Node NPM 安装的一些库。 -- 来自百度百科

①下载

  • 全局下载: npm install browserify -g
  • 局部下载: npm install browserify --save-dev

②打包编译 将需要打包编译的JS文件通过 运行代码browserify app.js -o bundle.js 将路径下的app.js文件编译output到bundle.js文件中

③页面使用引入 最后重新在页面文件中引入bundle.js文件<script type="text/javascript" src="./bundle.js"></script>

3.1.5、总结

  1. CommonJS采用同步加载不同模块文件,适用于服务器端的,不适合在浏览器环境中,同步意味着阻塞加载,浏览器资源是异步加载的
  2. 代码无法直接运行在浏览器环境下,必须通过工具转换成标准的 ES5;

3.2、AMD (Asynchronous Module Definition)

见名知意,就是异步模块定义。上面已经介绍过,CommonJS是服务器端模块的规范,主要是为了JS在后端的表现制定的,不太适合前端。而AMD就是要为前端JS的表现制定规范。由于不是JavaScript原生支持,使用AMD规范进行页面开发需要用到对应的库函数,也就是require.js(还有个js库:curl.js)。实际上AMD 是 require.js在推广过程中对模块定义的规范化的产出。 AMD采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

3.2.1、模块的定义和使用

// 定义一个模块
define('module', ['dep'], function (dep) {
  return exports;
});

// id  可选参数,用来定义模块的标识,如果没有提供该参数,默认脚本文件名(去掉拓展名)

// dependencies 是一个当前模块用来的模块名称数组,(所依赖模块的数组)

// factory 工厂方法,模块初始化要执行的函数或对象,如果为函数,它应该只被执行一次,如果是对象,此对象应该为模块的输出值。


require.js也采用require()语句加载模块,但是不同于CommonJS,它要求二个参数

//导入和使用模块
require([module], callback);
 
// 第二个参数[module],是一个数组,里面的成员就是要加载的模块;

// 第二个参数callback,则是加载成功之后的回调函数

// 等到前面的module加载完成之后,这个回调函数才被调用。
// 加载的模块会以参数形式传入该函数,从而在回调函数内部就可以使用这些模块

3.2.2、看个例子

// demo.html
<body>
    //引入所依赖文件和主入口文件
    <script src="./require.js" data-main = './demo.js'></script>
</body>

// modules/m1.js
define(function(){
    var name = 'm1-amd';
    function getName(){
        return name;
    }
    return {getName} //暴露出的模块
})

// modules/m2.js
//在m2模块中,引用了m1模块
define(['m1'],function(m1){
    var msg = 'm2-amd';
    function show(){
        console.log(msg,m1.getName());
    }
    return {show}   //暴露的模块
})

//demo.js
(function(){
    //配置每个变量对应的模块路径
    require.config({
        paths: {
            m1: './modules/m1',
            m2: './modules/m2',
        }
    })
    require(['m2'],function(m2){
        m2.show(); //结果:m2-amd m1-amd
    })
})()

默认情况下,require.js假定这加载的模块与main.js在同一个目录,然后自动加载。如果不在同一目录,我们可以使用require.config()方法对模块的加载行为进行自定义

在上面的例子中也可以引用第三方库,只需在上面代码的基础稍作修改:

//demo.js
(function(){
    //配置每个变量对应的模块路径
    require.config({
        paths: {
            m1: './modules/m1',
            m2: './modules/m2',
            jquery:'./jquery-3.3.1'
        }
    })
    require(['m2','jquery'],function(m2,$){
        m2.show(); //结果:m2-amd m1-amd
        $('body').css('backgroundColor','#000');
    })
})()

不过需要注意的是:jquery对模块化做了各种不同的规范,对每个不同模块都有暴露出的接口名字,对AMD暴露出的接口名字是小写jquery,因此不能把jquery写成大写的jQuery,这样会报错

//jquery 3.3.1.js
if(typeof define === 'function' && define.amd){
    define('jquery',[],function(){
        return jQuery;
    })
}

3.2.3、AMD特点:

依赖前置:必须等到所有依赖的模块加载完成之后才会执行回调,即使在回调里根本没用到该模块。(在定义模块的时候就要声明其依赖的模块),不过目前在AMD2.0也可以动态加载模块了

requireJS优缺点

优点:

1、适合在浏览器环境中异步加载模块

2、可以并行加载多个模块

缺点:

1、提高了开发成本,代码的阅读和书写比较困难,模块定义方式的语义不顺畅

2、不符合通用的模块化思维方式,是一种妥协的实现

3.3、CMD (Common Module Definition)

即通用模块定义,对应SeaJS,是阿里玉伯团队首先提出的概念和设计。跟requireJS解决同样问题,只是运行机制不同。

3.3.1、CMD与AMD的不同的在于

(1)AMD推崇依赖前置;CMD推崇依赖就近,只有在用到某个模块的时候再去require:

通俗来说:

AMD在加载完成定义(define)好的模块就会立即执行,所有执行完成后,遇到require才会执行主逻辑。(提前加载)

CMD在加载完成定义(define)好的模块,仅仅是下载不执行,在遇到require才会执行对应的模块。(按需加载)

AMD用户体验好,因为没有延迟,CMD性能好,因为只有用户需要的时候才执行。

CMD为什么会出现,因为对node.js的书写者友好,因为符合写法习惯,就像为何vue会受人欢迎的一个道理。

3.3.2、CMD语法

Sea.js 推崇一个模块一个文件,遵循统一的写法。

define(id?, deps?, factory)

因为CMD推崇一个文件一个模块,所以经常就用文件名作为模块id CMD推崇依赖就近,所以一般不在define的参数中写依赖,在factory中写 factory有三个参数

function(require, exports, module)

  • require 是一个方法,接受 模块表示 作为唯一参数,用来获取其他模块提供的接口
  • exports 是一个对象,用来向外提供模块接口
  • module 是一个对象,上面存储了与当前模块相关联的一些属性和方法

定义暴露模块

//定义没有依赖的模块
define(function(require, exports, module){
  exports.xxx = value
  module.exports = value
})

//定义有依赖的模块
define(function(require, exports, module){
  //引入依赖模块(同步)
  var module2 = require('./module2')
  //引入依赖模块(异步)
    require.async('./module3', function (m3) {
    })
  //暴露模块
  exports.xxx = value
})

引入使用模块

define(function (require) {
  var m1 = require('./module1')
  var m4 = require('./module4')
  m1.show()
  m4.show()
})

sea.js 简单使用教程

①下载sea.js, 并引入

官网: seajs.org/

github : github.com/seajs/seajs

然后将sea.js导入项目: js/libs/sea.js

②创建项目结构

  |-modules
    |-m1.js
    |-m2.js
    |-m3.js
    |-m4.js
|-index.html
|-main.js
|-sea.js

③定义sea.js的模块代码

// index.html
<body>
    <script src="./sea.js"></script>    //引入依赖文件
    <script>
        seajs.use('./main.js');         //设置主入口文件
    </script>
</body>

// modules/m1.js
define(function(require,exports,module){
    var msg = 'm1';
    function foo(){
        console.log(msg);
    }
    module.exports = {  //暴露的接口
        foo:foo
    }
});

// modules/m2.js
define(function(require,exports,module){
    var msg = 'm2';
    function bar(){
        console.log(msg);
    }
    module.exports = bar;   /暴露的接口
});

// modules/m3.js
define(function(require,exports,module){
    var msg = 'm3';
    function foo(){
        console.log(msg);
    }
    exports.m3 = { foo:foo} /暴露的接口
});

// modules/m4.js
define(function(require,exports,module){
    var msg = 'm4';
    // 同步引入
    var m2 = require('./m2');
    m2();
    // 异步引入
    require.async('./m3',function(m3){
        m3.m3.foo();
    });
    function fun(){
        console.log(msg);
    }
    exports.m4 = fun;   /暴露的接口
})


//main.js
define(function(require,exports,module){
    var m1 = require('./modules/m1');
    m1.foo();
    var m4 = require('./modules/m4');
    m4.m4();
})

最后得到结果如下

3.3.3、CMD优缺点

优点: 同样实现了浏览器端的模块化加载。 可以按需加载,依赖就近。

缺点: 依赖SPM打包,模块的加载逻辑偏重

3.4、ES6 Module

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

3.4.1 语法

在 ES6 中,使用export关键字来导出模块,使用import关键字引用模块。但是浏览器还没有完全兼容,需要使用babel转换成ES5的require。

// 导出
export function hello() { };
export default {
  // ...
};
// 导入
import { readFile } from 'fs';
import React from 'react';

使用import导入模块时,需要知道要加载的变量名或函数名。

在ES6中还提供了export default,为模块指定默认输出.对应导入模块import时,不需要使用大括号。

//math.js
var num = 0;
var add = function (a, b) {
  return a + b;
};
export { num, add };

//导入
import { num, add } from './math';
function test(ele) {
  ele.textContent = add(1 + num);
}

3.4.2、ES6与CommonJS的区别

CommonJS

  • 对于基本数据类型,属于复制。即会被模块缓存。同时,在另一个模块可以对该模块输出的变量重新赋值。

  • 对于复杂数据类型,属于浅拷贝。由于两个模块引用的对象指向同一个内存空间,因此对该模块的值做修改时会影响另一个模块。

  • 当使用require命令加载某个模块时,就会运行整个模块的代码。

  • 当使用require命令加载同一个模块时,不会再执行该模块,而是取到缓存之中的值。也就是说,CommonJS模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。

  • 循环加载时,属于加载时执行。即脚本代码在require的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。

ES6模块

  • ES6模块中的值属于【动态只读引用】。

  • 对于只读来说,即不允许修改引入变量的值,import的变量是只读的,不论是基本数据类型还是复杂数据类型。当模块遇到import命令时,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。

  • 对于动态来说,原始值发生变化,import加载的值也会发生变化。不论是基本数据类型还是复杂数据类型。

  • 循环加载时,ES6模块是动态引用。只要两个模块之间存在某个引用,代码就能够执行。

3.4.3、优缺点

优点:

1、容易进行静态分析

2、面向未来的 EcmaScript 标准

缺点:

1、浏览器还没有完全兼容,必须通过工具转换成标准的 ES5 后才能正常运行。

2、全新的命令字,新版的 Node.js才支持

四、总结


  • CommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案。

  • AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。

  • CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。不过,依赖SPM 打包,模块的加载逻辑偏重

  • ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。