前端模块化——CommonJS、AMD、CMD、ES6规范

前言

       随着技术的发展,各种前端库层出不穷,前端代码日益膨胀。如果不对前端代码加以模块化规范去管理,维护将变得异常困难。本文的主要内容是理解什么是模块化、为什么要模块化、模块化的优缺点以及当下最流行的AMD、CMD、CommonJS、ES6规范。本文旨在用通俗易懂的语言介绍抽象的概念,希望对大家有所帮助。

什么是模块化?

什么是模块?

       对于一个复杂的程序,将其按照一定的规范封装成几个文件块,每一块向外暴露一些接口,块的内部数据是私有的,块与块之间通过接口通信。这个过程称为模块化,而文件块称为模块。

模块化的前世今生
  • 模仿命名空间进行简单对象封装
           这种方法减少了全局变量的污染,减少了命名冲突。但与此同时,这种方法的问题是,由于是使用简单对象封装,对象外部能直接修改对象的属性,数据安全性不好。
let namespace={
    data:'123456',
    foo(){
      console.log(`ns foo ${data}`);
    },
    bar(){
      console.log(`ns bar ${data}`);
    }
}
namespace.data='789456';
namespace.foo(); //ns foo 789456
namespace.bar(); //ns bar 789456
  • 通过闭包进行封装
           这种方法在减少全局变量冲突的同时还保证了私有数据的安全性,闭包外部只能通过暴露的接口操作私有数据。
           那么如果当前模块依赖另一个模块怎么办?答案是将被依赖的模块作为参数传入。这种方法是实现现代模块化的基础。
// module.js文件
(function(window, $) {
    let data = '123456';
    function foo() {
      console.log(`mo foo ${data}`);
      $('body').css('background', 'black');
    }
    function bar() {
      console.log(`mo bar ${data}`);
      other(); //内部调用
    }
    function other() {
      console.log(`mo other ${data}`);
    }
    //暴露行为
    window.mo = { foo, bar };
})(window, jQuery);
// index.html文件
<script type='text/javascript' src='jquery'></script>
<script type='text/javascript' src='module.js'></script>
<!-- 注意这里引入依赖必须严格注意顺序,因为module是依赖于jquery的,所以jquery必须先于module引入 -->
<script type='text/javascript'>
    mo.foo();
</script>
模块化的好处
  • 减少全局变量污染
  • 提高了可复用性
  • 代码更易维护
  • 模块分离可以实现按需加载
  • 一定程度上减少了http请求的数量

       模块化有着如此独特的好处,那么我们没有理由不对我们的代码进行模块化管理,下面我们来看一下四种主流的模块化规范,分别是CommonJS、AMD、CMD和ES6规范。

四种主流的模块化规范

CommonJS

1.概述
       采用CommonJS模块规范的应用,每个文件就是一个模块,具有自己的私有作用域,不会污染全局作用域。模块的加载是同步的而且可以加载多次,但在第一次加载后就会被缓存,后面再次被加载时会直接从缓存中读取。CommonJS主要用于服务器Node.js编程。
2.module对象
       每个模块内部都有一个module对象代表当前模块。module模块具有以下几个属性:

  • module.id:模块的标识符,通常是绝对路径的模块文件名。
  • module.filename:模块的文件名。
  • module.loaded:一个布尔值,表示模块是否已经完成加载。
  • module.parent:一个数组,表示依赖该模块的模块。
  • module.children:一个数组,表示该模块依赖的模块。
  • module.exports:一个对象,表示模块向外暴露的内容。

3.module.exports属性
       我们已经知道,module.exports属性是模块暴露的接口,加载某个模块时就是加载该模块的module.exports属性。所以module.exports属性是CommonJS模块化规范的核心。
4.exports变量
       每个module都拥有一个exports变量,它指向module.exports属性,是module.exports的引用,设置这个变量是为了能够给module.exports添加属性,使用该变量添加的属性对调用该模块的模块可见。

const exports=module.exports; // CommonJS规范在每个module前隐式做了这个赋值

5.基本语法

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

6.实战

// module_exports_example.js
let x = 5;
let func = function () {
  console.log(func);
};
module.exports.x = x;
module.exports.func = func;
// 等价于module.exports={x,func};
// require_example.js
let example=require(./module_exports_example);// 后缀名默认为.js
console.log(example.x);// 5
example.func();// func

       上面的例子给出了最简单CommonJS规范的例子,通过module.exports向外暴露模块,通过require在一个模块中引入以来的模块。下面我们来看一下exports变量的具体应用场景。

// module_exports_example.js
let x = 5;
let y = 5;
let func = function () {
  console.log(func);
};
module.exports={x,func};
// 此时仍然想要向输出的对向添加属性可以这样做
exports.y = y;// 其实就是简单的为exports对象添加新的属性
// require_example.js
let example=require(./module_exports_example);// 后缀名默认为.js
console.log(example.x);// 5
console.log(example.y);// 5
example.func();// func

       上述例子使用了exports变量给module.exports对象添加属性,其效果等价于:

// module_exports_example.js
let x = 5;
let y = 5;
let func = function () {
  console.log(func);
};
module.exports={x,y,func};// 直接使用module.exports
// 也等价于module.exports.x=x; module.exports.y=y; module.exports.func=func;

       突然一个鬼魅的操作涌上心头,如果两个模块循环引用会出现什么情况呢?如果模块的加载是简单的同步加载,那循环引用就会引起死循环,我们来试一下。

// a.js
exports.x = 'a1';
console.log('a.js ', require('./b.js').x);
exports.x = 'a2';

// b.js
exports.x = 'b1';
console.log('b.js ', require('./a.js').x);
exports.x = 'b2';

// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);

       执行main.js后输出结果为:

b.js  a1
a.js  b2
main.js  a2
main.js  b2

       上述代码中a加载了b,b加载了a,但我们发现,并没有发生我们担心的循环引用的问题,这是因为CommonJS会在发生循环的位置剪断循环。具体的执行过程是这样的:
       执行第一句输出,但由于require是同步加载,会先转去加载a.js,在加载完成前不会输出。在加载a.js的过程中转去加载b.js,这时发现b,js加载了a.js,发生了循环引用,CommonJS在发生循环的点,也就是a.js的第二句话处切断循环,也就是说b.js加载a.js的时候不会执行exports.x='a2'。此时b.js加载a.js完毕,加载的a.x值是a1,所以先输出b.js a1,b.js文件继续向下执行,将其暴露的属性b.x修改为b2,b.js文件执行完毕,显然此时a.js加载的b.x为b2,所以输出第二行a.js b2,最后输出得到main.js a2main.js b2
       我们将上面的main.js文件稍微改一下:

// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);

       执行一下,结果如下:

b.js  a1
a.js  b2
main.js  a2
main.js  b2
main.js  a2
main.js  b2

       似乎跟我们的预期不太一样,为什么第二次加载的时候不会执行a.js和b.js文件里的输出了呢?我们刚刚说过,模块可以多次加载,在第一次加载以后再加载模块时,会直接从缓存中取值而不会再次加载文件,所以第二次加载的时候会直接从缓存中取出exports属性,所以a.js和b.js文件的console.log语句不会执行了。
       另外还需要注意的一点是,模块内部的变化不会影响加载后的值,也就是说模块内部的属性和输出的属性不是响应式变化的,我们看一个例子:

// example.js
let num=0;
function inc(){
  num++;
}
module.exports={num,inc};
let num=require(./example).num;
let inc=require(./example).inc;
console.log(num);// 0
inc();
console.log(num);// 0

       例子很简单就不做过多解释了。

AMD

1.概述
       AMD全称为Asynchronous Module Definition,是异步加载模块的,允许指定回调函数。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不需要异步加载,所以CommonJS规范比较适用。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用异步模式,因此浏览器端一般采用AMD规范。
2.基本语法

  • 定义模块
           AMD规范使用define来定义模块,define函数的定义是define(id?,dependencies?,factory)。id为字符串类型唯一用来标识模块(可以省略),dependencies是一个数组字面量,用来表示当前定义的模块所依赖的模块(默认后缀名是.js),当没有dependencies时,表示该模块是一个独立模块,不依赖任何模块。factory是一个需要实例化的函数,函数的参数与依赖的模块一一对应,函数需要有返回值,函数的返回值表示当前模块暴露的内容
  • 调用模块
           AMD规范使用require来调用模块,require函数的定义是require(dependencies,factory)。dependencies是一个数组字面量,表示调用的模块,factory需要传入一个回调函数,用来说明模块异步加载完成后执行的操作。

3.配置require对象
       require函数本身也是一个对象,它带有一个config函数用来配置require函数的运行参数,config函数接受一个对象作为参数。config的参数对象有以下几个属性:

  • baseUrl
           baseUrl参数指定本地模块位置的基准目录,即本地模块的路径是相对于哪个目录的。
  • paths
           paths参数指定各个模块的位置。这个位置可以是服务器上的相对位置,也可以是外部源。可以为每个模块定义多个位置,如果第一个位置加载失败,则加载第二个位置,后面我们将看到具体例子。
  • shim
           有些库不是AMD兼容的,这时就需要指定shim属性的值。shim是用来帮助require.js加载非AMD规范的库。

4.实战
       跟前面一样,还是先给出一个最基本的符合AMD规范模块化的例子。

// module1.js
// 定义一个没有依赖的模块
define(function() {
  let msg = '123456';
  function get() {
    return msg;
  }
  return { get }; // 暴露模块
})
// module2.js
// 定义一个依赖module1的模块
define(['module1'],function(module1) {
  let _msg_ = '7890';
  function con() {
    return module1.get()+_msg_;// 将两个字符串连接起来
  }
  return { con }; // 暴露模块
})
// main.js
function(){
  require.config({
    baseUrl:'./modules/',
    paths:{
      module1:'module1',
      module2:'module2'
    },
  });
  require([module2],function(module2){
    console.log(module2.con());
  });
}();// 1234567890
// index.html
<!DOCTYPE html>
<html>
  <head>
    <title>AMD</title>
  </head>
  <body>
    <!-- 引入require.js并指定js主文件的入口 -->
    <script data-main="js/main" src="js/libs/require.js"></script>
  </body>
</html>

       此外,如何在项目中引入第三方库呢?只需要在上述代码中作少许修改:

// module2.js
// 定义一个依赖module1的模块
define(['module1','jquery'],function(module1,$) {
  let _msg_ = '7890';
  function con() {
    return module1.get()+_msg_;// 将两个字符串连接起来
  }
  $('body');// 使用第三方库
  return { con }; // 暴露模块
})
// main.js
function(){
  require.config({
    baseUrl:'./modules/',
    paths:{
      module1:'module1',
      module2:'module2',
      jquery:'xxxx'
    },
  });
  require([module2],function(module2){
    console.log(module2.con());
  });
}();// 1234567890

       在module.js文件中引入第三方库,main.js文件中也要有相应的路径配置。此外,我们还可以给一个库指定多个地址以作备用地址,在前面的地址失效的时候使用备用地址,例:

require.config({
    paths: {
        jquery: [
        // 路径定义为数组,数组的每一项为一个地址定义
            '//cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js',
            'lib/jquery'
        ]
    }
});
CMD

1.概述
       CMD全称是Common Module Definition,它整合了CommonJS和AMD规范的特点,专门用于浏览器端,异步加载模块。该规范明确了模块的书写格式和基本交互规则
2.基本语法
       CMD规范通过define关键字来定义模块,基本语法为define(factory),factory的取值可以是函数或者任何合法的值(对象、数组、字符串等)。当factory是函数时,表示是该模块的构造函数,这个函数具有三个参数————“require、exports、module”。require参数是一个方法,它接受模块唯一标识作为参数,用来引入依赖。exports参数用来暴露模块,module参数指向当前模块。
3.实战

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>CMD Demo</title>
    <!--引入Sea.js库-->
    <script type="text/javascript" src="../../sea-modules/seajs/seajs/2.2.0/sea.js"></script>
    <script type="text/javascript">
      seajs.use("../../static/cmd/main");// 使用模块
    </script>
  </head>
</html>
// module1.js
// factory为对象
define({foo:foo});
// module2.js
// factory为字符串
define(foo:"123456");
// module3.js
// factory为数字
define(foo:666);

       上面是factory为普通值时候的例子,下面我们来看一个当factory为函数的时,怎样使用:

// main.js
define(function(require) {
  //通过riquire引用模块
  var module1=require('./module1');
  var module2=require('./module2');
  var module3=require('./module3');
  console.log(module1.foo);// foo
  console.log(module2.foo);// 123456
  console.log(module3.foo);// 666
});
// module4.js
// 定义一个没有依赖的模块
define(function(require,exports,module){
   function CmdDefine(){
      console.log("CMD Demo");
   }
   CmdDefine.prototype.say=function(){
      console.log("CMD Demo!");
   }
   module.exports=CmdDefine;// 对外发布接口
});
// main.js
define(function(require) {
   var CmdDefine=require('./cmdDefine');
   var tmp = new CmdDefine();// 创建CmdDefine实例
   tmp.say();// 调用say方法
});

       例子同样很容易理解,不做过多解释。
4.判断当前页面是否有CMD模块加载器

if(tepyof define === "function" && define.cmd){
  // 有Sea.js等CMD模块加载器存在
}
ES6

1.概述
       ES6模块规范的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系。而CommonJS和CMD,都只能在运行时确定依赖。
2.基本语法

  • 暴露接口
           export命令用于规定模块的对外接口,基本语法为export xxx,暴露的接口可以是对象、函数、基本类型变量。另外可以使用export default xxx为模块指定默认输出,因为很多时候用户不知道要加载的模块的属性名。
  • 调用模块
           import命令用于输入其他模块提供的功能,基本语法为import xxx from xxx,其中第一个xxx为引入的模块的属性名,第二个xxx为模块的位置。如果不理解没关系,后面我们将看到具体的案例。

3.实战

// module1.js
let s='';
function proc(){
  s='123456';
  return s;
}
export {num,inc};// 暴露接口
// main.js
import {s,proc} from './module1';// 引入依赖
console.log(s);// 
console.log(proc);// 123456

       需要注意的一点是,在使用CommonJS规范时,输出的是值的拷贝,也就是说输出之后,模块内部的变化不会影响输出。但在ES6中是恰好相反的,ES6规范中输出的是值的引用,也就是说模块内部变化会影响输出。从上例可以看到,第一次输出的s为空字符串,调用proc后s被修改,再次输出后s变为处理后的字符串。下面我们再来看一个例子:

//module2.js
export let num=0;
export function inc(){
  num++;
}
//main.js
import {num,inc} from './module2';
console.log(num);// 0
inc();
console.log(num);// 1

       我们可以看到,在输出之后内部的变化仍然会反映到输出上。这是因为CommonJS加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而ES6模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成,所以ES6模块是动态引用,不会缓存值,模块里面的变量绑定其所在的模块
       从上面两个例子可以看到,使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。

// module3.js
export default function(){
  console.log('foo');
}
// main.js
import iWant from './module3';
iWant();// foo

       在引入具有默认输出的模块时,如果该输出是一个匿名函数,import可以给匿名函数重命名。那么我们如何引入第三方的模块呢?

// 在引入之前要先安装第三方库
// main.js
import $ from 'jquery';// 引入第三方库
$('body').css(xxx);

总结

  • CommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD、CMD解决方案。
  • AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
  • CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。但是依赖SPM打包,模块的加载逻辑偏重。
  • ES6在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代CommonJS和 CMD规范,成为浏览器和服务器通用的模块化解决方案。

参考文献

       https://github.com/ljianshu/Blog/issues/48

  • 10
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值