详细理解 amd,commonjs,es6module

我心飞翔 分类:javascript

在开始讲解前,先了解下CommonJS、AMD、ES6模块的侧重点,请仔细阅读此章节。

模块的侧重点

前后端 JavaScript 分别搁置在 HTTP 的两端(后端 JS 指 Node),它们扮演的角色并不同。

浏览器的 JavaScript 需要经历从同一个服务器(Web服务器)端分发到多个客户端执行,而服务器端的 JavaScript 则是相同的代码需要多次执行。前者的瓶颈在于带宽,后者的瓶颈在于 CPU 和内存等资源。前者需要通过网络加载代码,后者从磁盘中加载,两者的加载速度不在一个数量级上。

**纵观 Node 的模块引入过程(CommonJS规范require),几乎全都是同步的。**尽管与 Node 强调异步的行为有些相反,但它是合理的。前端 JavaScript 与 ui 渲染共用一个线程,如果前端模块也采用同步的方式来引入,将会在用户体验上造成很大问题。UI 在初始化过程中需要话费很多时间来等待脚本加载完成。

鉴于网络原因,CommonJS 为后端 JavaScript 制定的规范并不完全适合前端的应用场景。经过一段争执之后,AMD 规范最终在前端应用场景中胜出。它的全称是 Asynchronous Module Definition,即“异步模块定义”。详见 github.com/amdjs/amdjs… 。除此之外,还有玉伯定义的 CMD 规范

所以:

对后端来说,同步加载没有问题,因为:

  1. 模块都在本地,等待时间就是硬盘读取时间。
  2. 一旦启动之后一般不会关,可靠性比启动时间重要。

对前端来说:

  1. 模块都在服务器上,需要通过网络请求,太慢。
  2. 同步 xhr 会堵塞浏览器,假死的话用户体验很差,首屏时间很重要。

CommonJS 规范主要应用在 Node 模块的实现,虽然 Node 并非完全按照其规范实现,但是大体相同。

为了实现前端 JavaScript 的异步加载应使用 AMD 规范。

ES2015 发布之后又出现的新的模块引入方式 ES6 module。**ES6 加载模块并没有指定同步或异步,如何加载 ES6 定义的模块很可能取决于代码的运行环境。**它可成为浏览器和服务器通用的模块解决方案。

AMD 规范

AMD 规范是 CommonJS 模块规范的一个延伸,它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

它的模块定义如下:

define(id?, dependencies?, factory);
 

它的模块 id 和依赖是可选的,与Node模块相似的地方在于factory的内容就是实际代码的内容。下面的代码定义了一个简单的模块:

define(function(){
    var exports = {};
    exports.sayHello = function(){
        console.log('Hello from module: ' + module.id);
    };
    return exports;
});
 

与 CommonJS 不同之处在于 AMD 模块需要用 define 来明确定义一个模块,而在 Node 实现中是隐式包装的,它们的目的是进行作用域隔离,仅在需要的时候被引入,避免掉过去那种通过全局变量或全局命名空间的方式,一面变量污染和不小心被修改。另一个区别则是内容需要通过返回的方式实现导出。

ES6 模块与 CommonJS 模块的差异

ES6 模块CommonJS 模块 有三个重大差异:

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
  • CommonJS 模块顶层的this指向当前模块,ES6 模块顶层的this指向undefined

注意⚠️,ES6 模块的顶级(默认)导出 export default 的是值的拷贝,与次级导出的行为不一致。

第二个差异是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

下面重点解释第一个差异。

CommonJS

CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。

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

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

ES6 模块的运行机制与 CommonJS 不一样。

JS 引擎对脚本静态分析的时候,遇到模块加载命令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 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错。

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

// main.js
import { obj } from './lib';

obj.prop = 123; // OK
obj = {}; // TypeError
 

上面代码中,main.jslib.js输入变量obj,可以对obj添加属性,但是重新赋值就会报错。因为变量obj指向的地址是只读的,不能重新赋值,这就好比main.js创造了一个名为objconst变量。

**即使将 es6 模块通过 babel 等工具编译为 commonjs 模块,那么通过 nodejs 运行后依旧为值的拷贝,并不会表现出 es6 模块本身的特性。 ** 这可能由于浏览器的 js 引擎与 node js引擎存在着差异。


最后看这个例子。

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.jsy.js加载的都是C的同一个实例。

依据 ES6 模块 这个原理,所以我们可以轻松实现 发布订阅的事件池。

参考

  1. ES2015标准入门(第3版)— 阮一峰

  2. 深入浅出NodeJS - 朴灵

  3. stackoverflow.com/questions/3…

回复

我来回复
  • 暂无回复内容