JavaScript 模块化方案:Common.js, AMD, CMD, ES6

694 阅读7分钟

前言

浏览器加载

传统的web应用中,常用script标签来加载不同的js文件,例如:

<scritp type="application/javascript" src='/path/to/1.js'></script>
<scritp type="application/javascript" src='/path/to/2.js'></script>
<scritp type="application/javascript" src='/path/to/3.js'></script>

而这种方法存在一定的弊端,例如:默认情况下,浏览器是同步加载js文件,渲染引擎遇到script标签就会停下来,下载和执行完js文件后再继续向下渲染。因此假如js文件很大,那么就会造成下载和执行的时间过长,造成浏览器堵塞。为解决这类问题,浏览器允许异步加载脚本,包括async和defer两种方法,二者的区别如下:

<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>

async等到脚本下载完直接中断浏览器的渲染过程,直接执行脚本,而defer对等到浏览器渲染完成后再执行相应的脚本。如果有多个脚本,defer按照先后顺序执行,而async是不能保证先后顺序的。

此外,浏览器加载也不能保证文件的按需加载。

Common.js

CommonJS是目标是为JavaScript在网页浏览器之外创建模块约定。创建这个项目的主要原因是当时缺乏普遍可接受形式的JavaScript脚本模块单元,模块在与运行JavaScript脚本的常规网页浏览器所提供的不同的环境下可以重复使用。(来自维基百科)。

commonjs中几个重要的模块

  • require,require是一个函数,require函数接受一个模块标识符,require返回外部模块的导出的API。如果要求的模块不能被返回则require必须throw一个错误。在模块内,有一个自由变量require,它满足上述定义。
  • exports, 在模块内,有一个自由变量叫做exports,它是一个对象,模块在执行时可以向其增加模块的API。模块必须使用exports对象作为唯一的导出方式。
  • module,在模块中,必须有一个自由变量module,它是一个对象。module对象必须有一个id属性,它是这个模块的顶层id。id属性必须是这样的,require(module.id)会从源出module.id的那个模块返回exports对象。

node是common.js规范的主要实践和应用。我们先来看下commonjs的写法:

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

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

// program.js
var inc = require('increment').increment;
var a = 1;
inc(a); // 2
module.id == "program";

commonjs的几个特点:

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

在服务端,模块会存储于硬盘当中,在执行require时,会从硬盘当中同步加载各个模块,那么在浏览器环境中同步加载可能会存在一些问题,造成浏览器假死,用户等待时间过长。因此产生了AMD异步加载规范。

AMD

AMD全称:Asynchronous Module Definition,意为异步模块定义,它也采用require进行模块加载,写法如下:

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

异步加载可以避免浏览器等待时间过长造成假死的现象。require.js便遵循AMD规范

CMD

CMD为玉伯提出的模块化方案。他的sea.js便遵循这个规范。它于AMD的不同之处在于玉伯的回答

CMD写法:

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推崇依赖就近、延迟执行。 下面是两种写法的对比:

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

// AMD
require(['a', 'b', 'c'], function (a, b, c) {
    a.test();
});

ES6

ES6的模块化方案见我在知乎的总结:zhuanlan.zhihu.com/p/102488457… 需要注意的是:

  • es6的模块加载输出的是值的引用,而CommonJS则输出的时值的拷贝
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。 第二个点是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

那么我们重点来看第一点。

对于commonjs

var a = 1;
var increment = function() {
    a++;
}
module.exports = {
  a: a,
  inc: increment,
};

// main.js
var a = require('./a.js');
console.log(a.a) // 1
a.inc();
console.log(a.a) // 1

可见,commonjs输出的是值的拷贝,之后对模块里的内容修改不会作用到模块当中。也就是说:一旦输出一个值,模块内部的变化就影响不到这个值

而对于es6:

var a = 1;
var increment = function() {
    a++;
}
export {
  a,
  inc
};

// main.js
import {a, inc} from './a.js'
console.log(a) // 1
inc();
console.log(a) // 2

证明es模块能够影响模块内部的变化。

同样的:

// 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();

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

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

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

node main.js之后输出 1;

证明export通过接口,输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。

循环加载

(以下出自阮一峰老师的博客

commonjs的循环加载

// a.js
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');

// b.js
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');

node a.js输出:

在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕

当a.js执行到require b时,就去执行b.js, 那么b对a又有引入,此时a还没有执行完,因此只能返回当前的结果,也就是done = false。执行完b之后再执行a剩下的代码,b.done=true;

另外:

// main.js
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);

node main.js输出:

在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true

以上代码说明了在b.js之中,a.js没有执行完毕,只执行了第一行,并且main.js执行到第二行时,不会再次执行b.js,而是输出缓存的b.js的执行结果,即它的第四行:

exports.done = true;

es6的循环加载

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

node m2.js的输出结果为:

bar
baz

上面代码表明,ES6模块不会缓存运行结果,而是动态地去被加载的模块取值,以及变量总是绑定其所在的模块。

这导致ES6处理"循环加载"与CommonJS有本质的不同。ES6根本不会关心是否发生了"循环加载",只是生成一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

// a.js
import {bar} from './b.js';
export function foo() {
  bar();  
  console.log('执行完毕');
}
foo();

// b.js
import {foo} from './a.js';
export function bar() {  
  if (Math.random() > 0.5) {
    foo();
  }
}

按照CommonJS规范,上面的代码是没法执行的。a先加载b,然后b又加载a,这时a还没有任何执行结果,所以输出结果为null,即对于b.js来说,变量foo的值等于null,后面的foo()就会报错。

但是,ES6可以执行上面的代码。 a.js之所以能够执行,原因就在于ES6加载的变量,都是动态引用其所在的模块。只要引用是存在的,代码就能执行。

// even.js
import { odd } from './odd'
export var counter = 0;
export function even(n) {
  counter++;
  return n == 0 || odd(n - 1);
}

// odd.js
import { even } from './even';
export function odd(n) {
  return n != 0 && even(n - 1);
}
import * as m from './even.js';
m.even(10); // true
m.counter; // 6
m.even(20) //true
m.counter // 17

参数n从10变为0的过程中,foo()一共会执行6次,所以变量counter等于6。第二次调用even()时,参数n从20变为0,foo()一共会执行11次,加上前面的6次,所以变量counter等于17。

上面代码改成commonjs的写法就会报错。