Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Javascript模块化编程(一):CommonJS和AMD规范、CMD规范、UMD、ES6 #34

Open
LightXJ opened this issue Apr 27, 2020 · 0 comments

Comments

@LightXJ
Copy link
Owner

LightXJ commented Apr 27, 2020

前言:
随着前端js代码复杂度的提高,js模块化是必然趋势,不仅好维护,同时依赖很明确,不会全局污染,今天就整理一下模块化的几个规范。
模块化的发展情况如下:
无模块化-->CommonJS规范-->AMD规范-->CMD规范-->ES6模块化

一、无模块化时期

script标签引入js文件,相互罗列,但是被依赖的放在前面,否则使用就会报错。如下:

<script src="jquery.js"></script>   
<script src="jquery_scroller.js"></script>   
<script src="main.js"></script>   
<script src="other1.js"></script>   
<script src="other2.js"></script>   
<script src="other3.js"></script>

即简单的将所有的js文件统统放在一起。但是这些文件的顺序还不能出错,比如jquery需要先引入,才能引入jquery插件,才能在其他的文件中使用jquery。缺点很明显:

  • 污染全局作用域
  • 维护成本高
  • 依赖关系不明显

这里插一个知识,如果script标签中什么都不写,是按顺序执行的,即使先加载完也按顺序执行。

<script type="text/javascript" src="https://developers.google.com/_static/c614b31167/js/script_foot_closure__zh_cn.js?hl=zh-cn"></script>
<script type="text/javascript" src="https://ss1.bdstatic.com/5eN1bjq8AAUYm2zgoY3K/r/www/cache/static/protocol/https/jquery/jquery-1.10.2.min_65682a2.js"></script>

执行顺序
执行顺序

二、模块化探索-模块的写法

原始写法

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

缺点:“污染”了全局变量,无法保证不与其他模块名冲突,模块间看不出关系。

对象写法

 var module1 = new Object({
    _count : 0,

    m1 : function (){
      //...
    },


    m2 : function (){
      //...
    }

  });

使用的时候,调用这个对象的属性:

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
    };

  })();

这里的module1就是模块的基本写法。
如果一个模块很大,必须分成几部分,或者一个模块需要继承其他模块,就有必要采用“放大模式”:

 var module1 = (function (mod){

    mod.m3 = function () {
      //...
    };

    return mod;
  })(module1);

上面的代码为module1添加了一个新方法m3.

在浏览器中,模块的各个部分通常都是从网上获取的,有时候无法知道哪个部分先下载,如果采用上面的写法,module1可能加载一个不存在的空对象,这时就要采用“宽放大模式”

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

它和“放大模式”相比就是参数可以是个空对象。

独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。
为了在模块内部调用全局变量,必须显式地将其他变量输入模块。

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

三、CommonJS规范--同步

参考地址:http://wiki.commonjs.org/wiki/Modules/1.1
node.js的模块系统,就是参照CommonJS规范实现的。 node对模块规范进行了一定的取舍,同时也增加了少许自身需要的特性。
用法
在CommonJS中,用module.exports定义当前模块对外输出的接口(不推荐直接用exports)

function add(a,b){
    return a+b;
}
module.exports = {add};

用require加载模块(同步)。假定有一个数学模块math.js,就可以像下面这样加载。

var math = require('math');

然后,就可以调用模块提供的方法:

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

特点:

  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。

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

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

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

代码输出了内部变量counter和改写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 是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。
那么如果是导出一个对象呢?结果是还会影响。所以是一个浅拷贝。

// lib.js
var counterObj = {
  counter: 1,
  flag: true
}
function incCounter(){
    counterObj.counter++;
}


module.exports = {
    counterObj,
    incCounter: incCounter,
}

// main.js
var counterObj = require('./lib').counterObj;
var incCounter = require('./lib').incCounter;
console.log(counterObj.counter); // 1
incCounter();
console.log(counterObj.counter); // 2

但是,这种规范在浏览器上会有很大的问题,在上文例子中第二行代码math.add(2,3)执行必须等math.js加载完成,也就是说,如果加载时间很长,整个应用就会卡在那里等。
这个问题在服务器不存在,因为所有的模块都在本地硬盘上,可以同步加载完成,等待时间就是硬盘读取时间。但是浏览器模块都放在服务端,加载速度取决于网速,可能要等很长时间。
所以,浏览器端的模块,不能采用“同步加载”( synchronous),只能采用“异步加载”(asynchronous)。这就是AMD规范诞生的背景。
扩展:如果想将CommonJs用在浏览器端需要其他工具配合,例如browserify,提前编译打包处理。
https://www.cnblogs.com/xiaohuochai/p/6850977.html

三、AMD规范( Asynchronous[/ei'siŋkrənəs/] Module Definition)
AMD规范则是非同步加载模块,允许指定回调函数,AMD是RequireJS 在推广过程中对模块定义的规范化产出。
AMD标准中,定义了下面三个API:

  1. require([module], callback)
  2. define(id, [depends], callback)
  3. require.config()

即通过define来定义一个模块,然后使用require来加载一个模块, 使用require.config()指定引用路径。
先到require.js官网下载最新版本,然后引入到页面,如下:
<script data-main="./index.js" src="https://requirejs.org/docs/release/2.1.11/minified/require.js"></script>
data-main属性的作用是,指定网页程序的主模块。在上例中,就是js目录下面的index.js,这个文件会第一个被require.js加载。由于require.js默认的文件后缀名是js,所以可以把index.js简写成index。
index.js里依赖别的文件。例如依赖alert.js,可以这么写

require(['alert'], function(alert){
    alert.alertName('Maria');
    alert.alertAge(15);
});

被依赖的模块 alert.js,按照规范来定义模块

define(function(){
    var alertName = function(str){
        alert("I am"+str);
    }
    var alertAge = function(age){
        alert('I am'+age+' years old');
    }
    return {
        alertName: alertName,
        alertAge: alertAge
    }
})

引用模块的时候,我们将模块名放在[]中作为reqiure()的第一参数;如果我们定义的模块本身也依赖其他模块,那就需要将它们放在[]中作为define()的第一参数。
在使用require.js的时候,我们必须要提前加载所有的依赖,然后才可以使用,而不是需要使用时再加载。
优点:适合在浏览器环境中异步加载模块、并行加载多个模块
缺点:不能按需加载、开发成本大

四、CMD规范(Common Module Definition)

CMD规范是国内发展出来的,就像AMD有个requireJS,CMD有个浏览器的实现SeaJS,SeaJS要解决的问题和requireJS一样,只不过在模块定义方式和模块加载(可以说运行、解析)时机上有所不同。
AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。

cmd

五、ES6模块化(ESM)

在ES6中,我们可以使用 import 关键字引入模块,通过 export 关键字导出模块,功能较之于前几个方案更为强大,也是我们所推崇的,但是由于ES6目前无法在浏览器中执行,所以,我们只能通过babel将不被支持的import编译为当前受到广泛支持的 require[]。 (转换后的代码是遵循commonJS规范的,而这个规范是浏览器不能识别的,所以要借助webpack打包工具)

定义模块 math.js

var basicNum = 0;
function add(a, b){
    return a+b;
}
export { basicNum, add }

引用模块

import {basicNum, add} from './math';
var result = add(99, basicNum);
console.log(result);

es6在导出的时候有一个默认导出,export default,使用它导出后,在import的时候,不需要加上{},模块名字可以随意起。该名字实际上就是个对象,包含导出模块里面的函数或者变量。
定义

export default { basicNum, add }

引用

import math from './math’;
var result = math.add(99, basicNum);

但是一个模块只能有一个export default。

六、umd模块

(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
    typeof define === 'function' && define.amd ? define(factory) :
    (global.libName = factory());
}(this, (function () { 'use strict';})));

如果你在js文件头部看到这样的代码,那么这个文件使用的就是 UMD 规范
实际上就是 amd + commonjs + 全局变量 这三种风格的结合
这段代码就是对当前运行环境的判断,如果是 Node 环境 就是使用 CommonJs 规范, 如果不是就判断是否为 AMD 环境, 最后导出全局变量
有了 UMD 后我们的代码可同时运行在 Node 和 浏览器上
所以现在前端大多数的库最后打包都使用的是 UMD 规范

七、扩展

1、CommonJs规范中的exports和module.exports的区别

exports 是对 module.exports 的引用。比如我们可以认为在一个模块的顶部有这句代码: exports = module.exports所以,我们不能直接给exports赋值,比如number、function等。
注意:因为module.exports本身就是一个对象,所以,我们在导出时可以使用

module.exports = {foo: 'bar'}  //true
module.exports.foo = 'bar'  //true

但是, exports 是 module.exports 的一个引用,或者理解为exports是一个指针,exports指向module.exports,这样,我们就只能使用 exports.foo = 'bar' 的方式
而不能使用exports = {foo: 'bar'} //error 这种方式是错误的,相当于重新定义了exports, 赋值之后exports失去了 对module.exports的引用,成为了一个模块内的局部变量

2、AMD和CMD的区别
最明显的区别就是在模块定义时对依赖的处理不同

  • AMD推崇依赖前置,在定义模块的时候就要声明其依赖的模块
  • CMD推崇就近依赖,只有在用到某个模块的时候再去require
    这种区别各有优劣,只是语法上的差距,而且requireJS和SeaJS都支持对方的写法
    AMD和CMD最大的区别是对依赖模块的执行时机处理不同,注意不是加载的时机或者方式不同

3、CommonJs和ES6模块的区别

  • CommonJS 模块输出的是一个值的拷贝(浅拷贝),ES6 模块输出的是值的引用。

CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
ES6模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
【详细解释:引用自阮一峰ECMAScript 6 入门】
CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。请看下面这个模块文件lib.js的例子。

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

上面代码输出内部变量counter和改写这个变量的内部方法incCounter。然后,在main.js里面加载这个模块。

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


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

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

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

上面代码中,输出的counter属性实际上是一个取值器函数。现在再执行main.js,就可以正确读取内部变量counter的变动了。

$ node main.js
3
4

ES6 模块的运行机制与 CommonJS 不一样。**JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。**原始值变了,import加载的值也会跟着变。因此,es6模块是动态引用,并不会缓存值,模块里面的变量绑定其所在的模块。
还是举上面的例子。

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}

// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

上面代码说明,ES6 模块输入的变量counter是活的,完全反应其所在模块lib.js内部的变化。
再举一个例子。

// m1.js
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);


// m2.js
import {foo} from './m1.js';
console.log(foo);
setTimeout(() => console.log(foo), 500);

上面代码中,m1.js的变量foo,在刚加载时等于bar,过了 500 毫秒,又变为等于baz。
让我们看看,m2.js能否正确读取这个变化。

$ babel-node m2.js

bar
baz

上面代码表明,ES6 模块不会缓存运行结果,而是动态地去被加载的模块取值,并且变量总是绑定其所在的模块。
由于 ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错。

// lib.js
export let obj = {};

// main.js
import { obj } from './lib';
obj.prop = 123; // OK
obj = {}; // TypeError

上面代码中,main.js从lib.js输入变量obj,可以对obj添加属性,但是重新赋值就会报错。因为变量obj指向的地址是只读的,不能重新赋值,这就好比main.js创造了一个名为obj的const变量。
最后,export通过接口,输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。

// mod.js
function C() {
  this.sum = 0;
  this.add = function () {
    this.sum += 1;
  };
  this.show = function () {
    console.log(this.sum);
  };
}
export let c = new C();

上面的脚本mod.js,输出的是一个C的实例。不同的脚本加载这个模块,得到的都是同一个实例。

// x.js
import {c} from './mod';
c.add();


// y.js
import {c} from './mod';
c.show();


// main.js
import './x';
import './y';

现在执行main.js,输出的是1。

$ babel-node main.js
1

这就证明了x.js和y.js加载的都是C的同一个实例。

  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
    运行时加载: CommonJS 加载的是一个对象(即module.exports属性);该对象只有在脚本运行完才会生成,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
    编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。
    详细解释:【引用自阮一峰ECMAScript 6 入门】
    ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
// CommonJS模块
let { stat, exists, readFile } = require('fs');


// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

上面代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。
ES6 模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入。

// ES6模块
import { stat, exists, readFile } from 'fs';

上面代码的实质是从fs模块加载3个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。
由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。
除了静态加载带来的各种好处,ES6 模块还有以下好处。

  • 不再需要UMD模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。目前,通过各种工具库,其实已经做到了这一点。
  • 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。
  • 不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。

参考

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant