欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-06-13 22:05:53
...

前言

       随着技术的发展,各种前端库层出不穷,前端代码日益膨胀。如果不对前端代码加以模块化规范去管理,维护将变得异常困难。本文的主要内容是理解什么是模块化、为什么要模块化、模块化的优缺点以及当下最流行的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);
// 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
});

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

// 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