js模块化规范

218 阅读9分钟

什么是模块化?

模块化是组织代码的一种方式。将所有的js业务逻辑代码写在一个文件里面,不仅导致文件庞大,而且难以管理和维护。

比如:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>js</title>
</head>
<body>
    <script>
   	...// 一些业务逻辑
    </script>
</body>
</html>

为了方便维护,可以通过外部引入的方式:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>js</title>
</head>
<body>
    ...
<script type="javascript" src="...(file path)"></script>
</body>
</html>

这种方式个人看来也是一种模块化的方式,只不过这种方式存在许多弊端。

  • 文件必须顺序引入。在大型项目开发中,由于引入的文件较多,文件之间的依赖关系也较为复杂,文件引入顺序难以明确。
// test.js是基于jQuery.js开发的,也就是说test.js是依赖于jQuery.js的,所以jQuery必须先于test.js引入。
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>js</title>
</head>
<body>
    ...
<script type="javascript" src="jQuery.js"></script>
<script type="javascript" src="test.js"></script>
</body>
</html>
  • 命名空间污染问题。
// a.js
var a=1;

// b.js
var a=2;

//test.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <script src="a.js"></script>
    <script src="b.js"></script>
    <title>js</title>
</head>
<body>
    <script>
        alert('a的值为'+a);
    </script>
</body>
</html>

为了解决这些问题,一些模块化的规范就出现了。

模块的发展历程

  先想一想,为什么模块很重要?

  因为有了模块,我们就可以更方便地使用别人的代码,想要什么功能,就加载什么模块。

  但是,这样做有一个前提,那就是大家必须以同样的方式编写模块,否则你有你的写法,我有我的写法,岂不是乱了套!考虑到 Javascript 模块现在还没有官方规范,这一点就更重要了。

命名空间

通过库名.类别名.方法名进行访问。

var NamaSpace = {}
NameSpace.type = NameSpace.type || {} // 避免命名空间被覆盖
NameSpace.type.method = function (){}

  • js 不像其他高级语言有模块系统,标准库较少和更缺乏包管理系统

  • js 起初只有全局对象的形式,通过一个个小函数来实现不同的模块功能

    function m1(){
      //...
    }
    function m2(){
      //...
    } 
    
  • 渐渐发展,通过构建对象的形式,来武装不同的功能

    var module1 = new Object ({
      _count : 0,
      m1 : function (){
        //...
      },
      m2 : function (){
        //...
      }
    }); 
    

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

    module1.m1();

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

    module1._count = 5; 
    
  • 继续发展,通过立即执行函数和闭包的形式来分离一个又一个的小组件

    三、立即执行函数写法

      使用"立即执行函数"(Immediately-Invoked Function Expression,IIFE),可以达到不暴露私有成员的目的。

    var module1 = (function(){
      var _count = 0;
      var m1 = function(){
        //...
      };
      var m2 = function(){
        //...
      };
      return {
      m1 : m1,
      m2 : m2
      };
    })(); 
    

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

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

      module1就是Javascript模块的基本写法。

     下面,再对这种写法进行加工。

    放大模式( augmentation)

      如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用"放大模式"(augmentation)。

    var module1 = (function (mod){
      mod.m3 = function () {
        //...
      };
      return mod;
    })(module1); 
    

      上面的代码为 module1 模块添加了一个新方法 m3(),然后返回新的 module1 模块。

    宽放大模式(Loose augmentation)

      在浏览器环境中,模块的各个部分通常都是从网上获取的,有时无法知道哪个部分会先加载。如果采用上一节的写法,第一个执行的部分有可能加载一个不存在的空对象,这时就要采用"宽放大模式"。

    var module1 = ( function (mod){
      //...
      return mod;
    })(window.module1); 
    

      与"放大模式"相比,"宽放大模式"就是"立即执行函数"的参数可以是空对象。

  • 当对象多起来的时候,又开始通过命名空间,来实现分级管理

    输入全局变量

  独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。

  为了在模块内部调用全局变量,必须显式地将其他变量输入模块。

var module1 = (function ($, YAHOO) {
  //...
})(jQuery, YAHOO); 

  上面的 module1 模块需要使用 jQuery 库和 YUI 库,就把这两个库(其实是两个模块)当作参数输入 module1。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显

  • 最终,历经十多年,社区渐渐发展壮大,commonJS规范的提出成了javascript 历史上最重要的里程碑。
  • Best Wishes : hope javascript can run everywhere!

主流的模块化的规范有:

  • commonJs 规范
  • AMD 规范(Asynchronous Module Definition) (异步模块定义)
  • CMD 规范
  • ES6 module

四大主流规范

CommonJS规范

CommonJS规范的使用

Node.jscommonJS规范的主要实践者,它有四个重要的环境变量为模块化的实现提供支持:moduleexportsrequireglobal。实际使用时,用module.exports定义当前模块对外输出的接口(不推荐直接用exports),用require加载模块。

// 定义模块math.js
var basicNum = 0;
function add(a, b) {
  return a + b;
}
module.exports = { //在这里写上需要向外暴露的函数、变量
  add: add,
  basicNum: basicNum
}

// 引用自定义的模块时,参数包含路径,可省略.js
var math = require('./math');
math.add(2, 5);

// 引用核心模块时,不需要带路径
var http = require('http');
http.createService(...).listen(3000);

commonJS用**同步**的方式加载模块。在服务端,模块文件都存在本地磁盘,读取非常快,所以这样做不会有问题。但是在浏览器端,限于网络原因,更合理的方案是使用异步加载。

commomJS的实现原理

commonJS简化版源码

function Module(id, parent){
    this.id = id;
    this.exports = {};
    this.parent = parent;
    this.filename = null;
    this.loaded = false;
    this.children = []
}

Module.prototype.require = function(path){
  return Module._load(path, this)  
}
//由此可知,require 并不是全局命令,而是每个模块提供的一个内部方法,也就是说,只有在模块内部才能使用require命令,(唯一的例外是REPL 环境)。另外,require 其实内部调用 Module._load 方法。

Module._load = function(request, parent, isMain) {

  //  计算绝对路径
  var filename = Module._resolveFilename(request, parent);

  //  第一步:如果有缓存,取出缓存
  var cachedModule = Module._cache[filename];
  if (cachedModule) {
    return cachedModule.exports;
  }
  
  // 第二步:是否为内置模块
  if (NativeModule.exists(filename)) {
    return NativeModule.require(filename);
  }

  // 第三步:生成模块实例,存入缓存
  var module = new Module(filename, parent);
  Module._cache[filename] = module;

  // 第四步:加载模块
  try {
    module.load(filename);
    hadException = false;
  } finally {
    if (hadException) {
      delete Module._cache[filename];
    }
  }

  // 第五步:输出模块的exports属性
  return module.exports;
};
module.exports = Module;
  • 每个文件就是一个模块,每个模块都是Module类的一个实例。

  • 从上面图中,可以知道moduleglobal全局对象的一个属性。

  • 可以观察到在global对象也有一个全局函数require(),global对象的require()是对module.require()函数的进一步抽象和封装。

AMD规范

AMD是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。


为什么会有AMD规范

有了服务器端模块以后,很自然地,大家就想要客户端模块。而且最好两者能够兼容,一个模块不用修改,在服务器和浏览器都可以运行。

但是,由于一个重大的局限,使得CommonJS规范不适用于浏览器环境。还是上一节的代码,如果在浏览器中运行,会有一个很大的问题。

var math = require('math');
math.add(2, 3);

第二行math.add(2, 3),在第一行require('math')之后运行,因此必须等math.js加载完成。也就是说,如果加载时间很长,整个应用就会停在那里等。

这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于"假死"状态。

因此,浏览器端的模块,不能采用"同步加载"(synchronous),只能采用"异步加载"(asynchronous)。这就是AMD规范诞生的背景。

AMD规范的使用

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

require([module], callback);

第一个参数[module],是一个数组,里面的成员就是要加载的模块;第二个参数callback,则是加载成功之后的回调函数。如果将前面的代码改写成AMD形式,就是下面这样:

require(['math'], function (math) {
    math.add(2, 3);
});

math.add()math模块加载不是同步的,浏览器不会发生假死。所以很显然,AMD比较适合浏览器环境。

目前,主要有两个Javascript库实现了AMD规范:require.jscurl.js

AMD规范的写法

require.js 加载的模块,采用 AMD 规范。也就是说,模块必须按照 AMD 的规定来写。 具体来说,就是模块必须采用特定的 define() 函数来定义。如果一个模块不依赖其他模块。那么可以直接定义在 define() 函数之中。 假定现在有一个 math.js 文件,它定义了一个math模块。那么,math.js 就要这样写:

// math.js
define(function (){
 var add = function (x,y){
  return x+y;
 };
 return {
  add: add
 };
});

加载方法如下:

// main.js
require(['math'], function (math){
 alert(math.add(1,1));
});

如果这个模块还依赖其他模块,那么 define() 函数的第一个参数,必须是一个数组,指明该模块的依赖性。

define(['myLib'], function(myLib){
 function foo(){
  myLib.doSomething();
 }
 return {
  foo : foo
 };
});

require() 函数加载上面这个模块的时候,就会先加载myLib.js文件。

加载非规范的模块

理论上,require.js 加载的模块,必须是按照 AMD 规范、用 define() 函数定义的模块。但是实际上,虽然已经有一部分流行的函数库(比如jQuery)符合 AMD 规范,更多的库并不符合。那么,require.js 是否能够加载非规范的模块呢? 回答是可以的。 这样的模块在用 require() 加载之前,要先用 require.config() 方法,定义它们的一些特征。 举例来说,underscorebackbone 这两个库,都没有采用 AMD 规范编写。如果要加载它们的话,必须先定义它们的特征。

require.config({
 shim: {
  'underscore': {
   exports: '_'
  },
  'backbone': {
   deps: ['underscore', 'jquery'],
   exports: 'Backbone'
  }
 }
});

require.config() 接受一个配置对象,这个对象除了有前面说过的 paths 属性之外,还有一个 shim 属性,专门用来配置不兼容的模块。具体来说,每个模块要定义: (1)exports 值(输出的变量名),表明这个模块外部调用时的名称; (2)deps 数组,表明该模块的依赖性。 比如,jQuery 的插件可以这样定义:

shim: { 
    'jquery.scroll': {  
        deps: ['jquery'], 
        exports: 'jQuery.fn.scroll'
    }
}

CMD 规范

CMDCommon Module definition的缩写,即通用模块定义。CMD规范是国内发展出来的,就像AMD有个requireJS,CMD有个浏览器的实现SeaJS,SeaJS要解决的问题和requireJS一样,只不过在模块定义方式和模块加载(可以说运行、解析)时机上有所不同

CMD规范的写法

  • 全局函数define,用来定义模块。
  • 参数 factory 可以是一个函数,也可以为对象或者字符串。
  • 当 factory 为对象、字符串时,表示模块的接口就是该对象、字符串。

CMD中,一个模块就是一个文件,格式为:

define( factory );
  1. 定义JSON数据模块:
define({ "foo": "bar" });

2.通过字符串定义模板模块:

define('this is `data`.');
  1. factory 为函数的时候,表示模块的构造方法,执行构造方法便可以得到模块向外提供的接口。

    • require 是一个方法,接受模块标识作为唯一参数,用来获取其他模块提供的接口:require(id)


      define(function( require, exports ){ 
          var a = require('./a');
          a.doSomething(); 
      });
      

      require是同步往下执行的,需要的异步加载模块可以使用 require.async 来进行加载:

      define( function(require, exports, module) { 
          require.async('.a', function(a){ 
              a.doSomething(); 
          }); 
      });
      

      require.resolve( id )可以使用模块内部的路径机制来返回模块路径,不会加载模块。


    • exports 是一个对象,用来向外提供模块接口

    • module 是一个对象,上面存储了与当前模块相关联的一些属性和方法

    define( function(require, exports, module) { 
     // 模块代码 
    });
    

示例:

// math.js

define(function(require,exports,module) {
  exports.add = function() {
    var sum = 0,i = 0,args = arguments,l = args.length;
    while (i < l) {
      sum += args[i++];
    }
    return sum;
  };
});
// increment.js

define(function(require,exports,module) {
  var add = require('math').add;
  exports.increment = function(val) {
    return add(val,1);
  };
});
// program.js

define(function(require ,exports,module) {
  var inc = require('increment').increment;
  var a = 1;
  inc(a); // 2

  module.id == "program";
});

AMD规范和CMD规范的比较

  1. 对于依赖的模块,AMD 是提前执行,CMD 是延迟执行。CMD 推崇 as lazy as possible
  2. CMD 推崇依赖就近,AMD 推崇依赖前置。
// CMD
define(function(require, exports, module) { 
    var a = require('./a') 
    a.doSomething() 
    // 此处略去 100 行 
    var b = require('./b') // 依赖可以就近书写 
    b.doSomething() 
    // ... 
});

// AMD 默认推荐的是
define(['./a', './b'], function(a, b) { // 依赖必须一开始就写好 
    a.doSomething() 
    // 此处略去 100 行 
    b.doSomething() 
    // ... 
})
  1. 两者的使用加载机制不同,也就导致了AMD(requirejs)模块会提前执行**,用户体验好,**而CMD(seajs)性能好,因为只有在需要时候才执行。

ES6模块规范

ES6的语言规格中引入了模块化功能,也就很好的取代了之前的commonjsAMD规范,成为了浏览器和服务器的通用的模块解决方案,在现今(vuejs,ReactJS)等框架大行其道中,都引入了ES6中的模块化(Module)机制。

Es6中模块导出的基本语法

模块的导出,export关键字用于暴露数据,暴露给其他模块

使用方式是,可以将export放在任何变量,函数或类声明的前面,从而将他们从模块导出,而import用于引入数据,例如如下所示:

将下面这些js存储到exportExample.js中,分别导出的是数据,函数,类:

exportExample.js

// 1. 导出数据,变量前面加上export关键字
export var  name = "随笔川迹"; // 导出暴露name变量
export let  weChatPublic = "itclanCoder"; // 暴露weChatPublic
export const time = 2018; // 暴露time


// 2. 导出函数,函数前面加上export关键字
export function sum(num1,num2){
      return num1+num2;
 }
/*
*
* 以上等价于
* function sum(num1,num2){
*    return num1+num2;
* }
* export sum;
* 也可以这样:在定义它时没有马上导出它,由于不必总是导出声明,可以导出引用,因此下面这段代码也是可以运行的
 */

// 3. 导出类,类前面加上export关键字
export class People{
      constructor(name,age){
         this.name = name;
         this.age = age;
      }
      info(){
         return `${this.name}${this.age}岁了`;
      }
 }

注意:一个模块就是一个独立的文件,该文件内部的所有变量,外部无法获取,同样,任何未显示导出的变量,函数或类都是模块私有的,若没有用export对外暴露,是无法从模块外部访问的 例如:

function countResult(num1,num2){
       return num1-num2;
 }
// 没有通过export关键字导出,在外部是无法访问该模块的变量或者函数的

对应在另一个模块中通过import导入如下所示,模块命名为importExample.js

import { name, weChatPublic,time,sum,People} from "../modelTest1/exportExampleEs5.js"

var people = new People("小美",18); // 实例化perople对象
console.log(name);
console.log(weChatPublic);
console.log(time);
console.log(sum(1,2));
console.log(people.info());

注意:在上面的示例中,除了export关键字外,每一个声明与脚本中的一模一样,因为导出的函数和类声明需要有一个名称,所以代码中的每一个函数或类也确实有这个名称,除非用default关键字,否则不能用这个语法导出匿名函数或类。

Es6中模块导入的基本语法

如果想从一个文件(模块)访问另一个文件(模块)的功能,则需要通过import关键字在另一个模块中引入数据,import语句的两个部分组成分别是**:要导入的标识符标识符应当从那个模块导入,**另外,导入的标识符的顺序可以是任意位置,但是导入的标识符(也就是大括号里面的变量)与export暴露出的变量名应该是一致的。具体的写法如下:

import {identifer1,indentifer2}  from "./example.js"  // import {标识符1,标识符2} from "本地

1. 导入单个绑定

// 只导入一个
 import {sum} from "./example.js"
  console.log(sum(1,2));  // 3
  sum = 1; // 抛出一个错误,是不能对导入的绑定变量对象进行改写操作的

2. 导入多个绑定

如果想从示例模块中导入多个绑定,与单个绑定相似,多个绑定值之间用逗号隔开即可:

// 导入多个
import {sum,multiply,time} from "./exportExample.js"
console.log(sum(1,2)); // 3
console.log(multiply(1,2)); // 3
console.log(time);  // 2018

在这段代码中,从exportExample.js模块导入3个绑定,sum,multiplytime之后使用它们,就像使用本地定义的一样 等价于下面这个: **不管在import语句中把一个模块写了多少次,该模块将只执行一次,导入模块的代码执行后,实例化过的模块被保存在内存中,**只要另一个import语句使用它就可以重复使用它.

import {sum} from "./exportExample.js"
import {multiply} from "./exportExample.js"
import {time} from "./exportExample.js

3. Es6中导入整个模块

特殊情况下,可以导入整个模块作为一个单一的对象,然后所有的导出都可以作为对象的属性使用,例如:

// 导入一整个模块
import * as example from "./exportExample.js"
console.log(example.sum(1,example.time));
consoole.log(example.multiply(1,2));// multiply与sum函数功能一样

在上面这段代码中,从本地模块的exportExample.js中导出的所有绑定被加载到一个被称作为example的对象中,指定的导出sum()函数,multiply()函数和time之后作为example的属性被访问,这种导入格式被称为命名空间导入,因为exportExample.js文件中不存在example对象,所以它被作为exportExample.js中所有导出成员的命名空间对象而被创建

Es6中如何给导入导出时标识符重命名

从一个模块导入变量,函数或者类时,我们可能不希望使用他们的原始名称,就是导入导出时模块内的标识符(变量名,函数,或者类)可以不用一一对应,保持一致**,**可以在导出和导入过程中改变导出变量对象的名称

使用方式①: 使用as关键字来指定变量,函数,或者类在模块外应该被称为什么名称,例如如下一函数:

function sum(num1,num2){
     return num1+num2;
}
export {sum as add} // as后面是重新指定的函数名

如上代码,函数sum是本地名称,add是导出时使用的名称,换句话说,当另一个模块要导入这个函数时,必须使用add这个名称:

若在importExample.js一模块中,则导入的变量对象应是add而不是sum,是由它导出时变量对象决定的

import {add} from "./exportExample.js"

使用方式②: 使用as关键字来指定变量,函数,或者类在主模块内应该被称为什么名称,例如如下一函数:

// exportExample.js
export function sum(num1,num2){
     return num1+num2;
}
// importExample.js
import {sum as add} from "./exportExample.js"
console.log(sum(1,2)); // 3

如上代码导入add函数时使用了一个导入名称来重命名sum函数,注意这种写法与前面导出export时的区别,使用import方式时,重新命名的标识符在前面,as后面是本地名称,但是这种方式,即使导入时改变函数的本地名称,即使模块导入了add函数,在当前模块中也没有add()标识符,如上对add的类型检测就是很好的验证.

ES6匿名方式的导入和导出

如果在不给导出的标识符(变量,函数,类)呢,那么可以通过导出default关键字指定单个变量,函数或者类, 在import的时候, 名字随便写, 因为每一个模块的默认接口就一个。

//a.js 
let sex = "boy"; 
export default sex //(sex不能加大括号) 
//原本直接export sex外部是无法识别的,加上default就可以了.但是一个文件内最多只能有一个export default。 其实此处相当于为sex变量值"boy"起了一个系统默认的变量名default,自然default只能有一个值,所以一个文件内不能有多个export default。
// b.js 
//本质上,a.js文件的export default输出一个叫做default的变量,然后系统允许你为它取任意名字。所以可以为import的模块起任何变量名,且不需要用大括号包含 
import any from "./a.js" 
import any12 from "./a.js" 
console.log(any,any12) // boy,boy

参考链接

javascript模块化编程

前端模块化:CommonJS,AMD,CMD,ES6

深入了解CommonJS的模块实现原理

面试官说:说一说CommonJS的实现原理

详解Node全局变量global模块

JavaScript AMD 与 CMD 规范

AMD,CMD 规范详解

30分钟学会前端模块化开发

分析 Babel 转换 ES6 module 的原理