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

js模块化编程不完全指北(Commonjs、AMD、CMD、ES6 modules) #3

Open
xuexueq opened this issue Sep 2, 2018 · 0 comments
Labels
前端工程化&&性能优化 前端工程化&&性能优化

Comments

@xuexueq
Copy link
Owner

xuexueq commented Sep 2, 2018

js模块化编程不完全指北(Commonjs、AMD、CMD、ES6 modules)

称职的作家会把他的书分章节和段落;好的程序员会把他的代码分成模块。

模块化可以使你的代码低耦合,功能模块直接不相互影响。对可维护性、命名空间、可复用性上起了极大的作用。

一个模块就是实现特定功能的文件,有了模块,我们就可以更方便地使用别人的代码,想要什么功能,就加载什么模块。模块开发需要遵循一定的规范,否则就都乱套了。

一、模块模式(Module模式)

早年间,JS 还只是委身于 HTML <script> 标签中的内联代码;顶不济也就是被封装到专门的脚本文件中调用,也还是得与其他脚本共享一个全局作用域。这样很容易会造成某个脚本中的变量可能会在无意之间被全局中或者其他脚本中的变量覆盖(全局变量污染)。

模块模式一般用来模拟类的概念,这样我们就能把公有和私有方法还有变量存储在一个对象中。这样我们就能在公开调用API的同时,仍然在一个闭包范围内封装私有变量和方法。

方式:匿名闭包函数、全局引入、对象接口等。

二、CommonJS & AMD & UMD & CMD

模块模式(Module模式)都是通过使用单个全局变量来把所有的代码包含在一个函数内,由此来创建私有的命名空间和闭包作用域。这样也会存在一个问题,模块和模块之间也可能会出现命名冲突,那么存在不使用全局作用域来实现的模块方法么?哈那就是社区中广受欢迎的 CommonJS 和 AMD规范了。

1. CommonJS(同步加载模块)

CommonJS就是一个JavaScript模块化的规范,node.js的模块系统,就是参照CommonJS规范实现的。该规范最初是用在服务器端的node的,前端的webpack也是对CommonJS原生支持的。

根据CommonJS规范,一个单独的文件就是一个模块。每一个模块都是一个单独的作用域,也就是说,在该模块内部定义的变量,无法被其他模块读取,除非定义为global对象的属性。

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

CommonJS的核心思想就是通过 require 方法来同步加载所要依赖的其他模块,然后通过 exports 或者 module.exports 来导出需要暴露的接口。

CommonJS定义的模块分为:{模块引用(require)}、 {模块定义(exports)} 、{模块标识(module)}。
require()用来引入外部模块;exports对象用于导出当前模块的方法或变量,唯一的导出口;module对象就代表模块本身。

浏览器不兼容CommonJS的根本原因:在于缺少四个Node.js环境的变量(module、exports、require、global)。只要能够提供这四个变量,浏览器就能加载 CommonJS 模块。例如:

let module = {
	exports: {}
}
(function (module, exports) {
	exports.fun = function() {};
})(module, module.exports);
  • require()方法

在CommonJS中,有一个全局性方法require(),用于加载模块。正是由于CommonJS 使用的require方式的推动,才有了后面的AMD、CMD 也采用的require方式来引用模块的风格。

require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象。如果没有发现指定模块,会报错。

  • module.exports属性

module.exports属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取module.exports变量。

  • exports变量

为了方便,Node为每个模块提供一个exports变量,指向module.exports。这等同在每个模块头部,有一行这样的命令。

var exports = module.exports;

这样,在对外输出模块接口时,可以向exports对象添加方法。

每个js文件一创建,都有一个var exports = module.exports = {}, 使exports和module.exports都指向一个空对象。module.exports和exports所指向的内存地址相同

注意,不能直接将exports变量指向一个值(或者指向一个函数,注意不是添加一个方法),因为这样等于切断了exports与module.exports的联系。例如:

exports = function() {}; // exports不再指向module.exports了。

// 下面 hello 函数是无法对外输出的,因为 module.exports 被重新赋值了。
exports.hello = function() {
  return 'hello';
};

module.exports = 'Hello world';

当然如果觉得,exports与module.exports之间的区别很难分清,一个简单的处理方法,就是放弃使用exports,只使用module.exports。

  • 几个特点 !!!
    • 对于基本数据类型,属于复制。即会被模块缓存。同时,在另一个模块可以对该模块输出的变量重新赋值;对于复杂数据类型,属于浅拷贝。由于两个模块引用的对象指向同一个内存空间,因此对该模块的值做修改时会影响另一个模块。
    • 当使用require命令加载某个模块时,就会运行整个模块的代码。(运行时加载
    • 当使用require命令加载同一个模块时,不会再执行该模块,而是取到缓存之中的值。也就是说,CommonJS模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。
    • 循环加载时,属于加载时执行。即脚本代码在require的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。

下面举例说明:

(1):

// b.js
let count = 1
let plusCount = () => {
  count++
}
setTimeout(() => {
  console.log('b.js-1', count)
}, 1000)
module.exports = {
  count,
  plusCount
}

// a.js
let mod = require('./b.js');
console.log('a.js-1', mod.count); // a.js-1 1
mod.plusCount();
console.log('a.js-2', mod.count); // a.js-2 1
setTimeout(() => {
    mod.count = 3;
    console.log('a.js-3', mod.count); // 可以修改变量 不会报错
}, 2000);

在终端输入 node a.js
a.js-1 1
a.js-2 1
b.js-1 2  // 1秒后
a.js-3 3  // 2秒后

以上代码可以看出,b模块export的count变量,是一个复制行为。在plusCount方法调用之后,a模块中的count不受影响。同时,可以在b模块中更改a模块中的值(基本类型进行的是复制操作,所以不会影响另一个模块)。如果希望能够同步代码,可以export出去一个getter。

// 其他代码相同
module.exports = {
  get count () {
    return count; // 这里相当于闭包,引用着外部变量的值;而不是对变量的复制了
  },
  plusCount
}

node a.js
a.js-1 1
a.js-2 1
b.js-1 2  // 1秒后
a.js-3 2  // 2秒后, 变成 2 了而不是 3 。由于没有定义setter,因此无法对值进行设置。所以还是返回2

对于复杂数据类型,属于浅拷贝。若两个模块引用的对象指向同一个内存空间,因此对该模块的值做修改时会影响另一个模块。

类似于:

var obj = {x : 1};
function foo(o) {
    o = 100; // 不是指向同一个地址了, 所以这里形参便是实参的一个副本,不会影响实参值
}
foo(obj);
console.log(obj.x); // 仍然是1, obj并未被修改为100.

下面的代码在a模块修改count的值为3,此时在b模块的值也为3。这时修改的便是用一个引用的原因了.

// b.js
let obj = {
  count: 1
}
let plusCount = () => {
  obj.count++
}
setTimeout(() => {
  console.log('b.js-1', obj.count)
}, 1000)
setTimeout(() => {
  console.log('b.js-2', obj.count)
}, 3000)
module.exports = {
  obj,
  plusCount
}

// a.js
var mod = require('./b.js')
console.log('a.js-1', mod.obj.count)
mod.plusCount()
console.log('a.js-2', mod.obj.count)
setTimeout(() => {
  mod.obj.count = 3
  console.log('a.js-3', mod.obj.count)
}, 2000)

node a.js
a.js-1 1
a.js-2 2
b.js-1 2
a.js-3 3
b.js-2 3

(2): 当使用require命令加载某个模块时,就会运行整个模块的代码。(不是按需加载的)。
(3):当使用require命令加载同一个模块时,不会再执行该模块,而是取到缓存之中的值。
(4):循环加载时,属于加载时执行。即脚本代码在require的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。

同一个例子可说明:

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

$ node main.js
b.js  a1
a.js  b2
main.js  a2
main.js  b2

模块的循环加载,即A加载B,B又加载A,则B将加载A的不完整版本。

修改main.js,再次加载a.js和b.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);

$ node main.js
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语句都不会执行了。

参考文章

CommonJS模块和ES6模块的区别
JS中的值是按值传递,还是按引用传递呢?

2. AMD规范

默认情况下,浏览器是同步加载 JavaScript 脚本。如果脚本体积很大或者有异步行为(异步行为会等到空闲的时候执行),下载和执行的时间就会很长,因此造成浏览器堵塞,用户会感觉到浏览器“卡死”了,没有任何响应。即对于浏览器,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间这显然是很不好的体验,所以浏览器允许脚本异步加载。也就是说,浏览器端的模块,不能采用”同步加载”(synchronous),只能采用”异步加载”(asynchronous)。这就是AMD规范诞生的背景。

这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成。

早年HTML 网页中,浏览器通过<script>标签加载 JavaScript 脚本。<script>标签打开defer或async属性,脚本就会异步加载

CommonJS规范加载模块是同步的,使得CommonJS规范不适用于浏览器环境。

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

AMD规范使用define方法定义模块。

/**
* id:字符串,模块名称(可选)
* dependencies: 是我们要载入的依赖模块(可选),使用相对路径。注意是数组格式
* factory: 工厂方法,返回一个模块函数
*/
define(id?, dependencies?, factory);

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

/**
* 第一个参数[module],是一个数组,里面的成员就是要加载的模块;
* 第二个参数callback,则是加载成功之后的回调函数。
*/
require([module], callback);

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

  • 优缺点

优点:可以看出,AMD规范适合在浏览器环境中异步加载模块。并且可以并行加载多个模块。除了异步加载以外,AMD的另一个优点是你可以在模块里使用对象、函数、构造函数、字符串、JSON或者别的数据类型,而CommonJS只支持对象。

缺点:提高了开发成本,并且不能按需加载,而是必须提前加载所有的依赖。

3. UMD规范

在一些同时需要 AMD 和 CommonJS 功能的项目中,你需要使用另一种规范:Universal Module Definition(通用模块定义规范)。

UMD创造了一种同时使用两种规范的方法,并且也支持全局变量定义。所以UMD的模块可以同时在客户端和服务端使用。下面是一个解释其功能的例子:

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
      // AMD
    define(['myModule', 'myOtherModule'], factory);
  } else if (typeof exports === 'object') {
      // CommonJS
    module.exports = factory(require('myModule'), require('myOtherModule'));
  } else {
    // Browser globals (Note: root is window)
    root.returnExports = factory(root.myModule, root.myOtherModule);
  }
}(this, function (myModule, myOtherModule) {
  // Methods
  function notHelloOrGoodbye(){}; // A private method
  function hello(){}; // A public method because it's returned (see below)
  function goodbye(){}; // A public method because it's returned (see below)

  // Exposed public methods
  return {
      hello: hello,
      goodbye: goodbye
  }
}));
4. CMD

CMD规范(Common Module Definition)是阿里的玉伯提出来的,实现js库为sea.js。 它和requirejs非常类似,即一个js文件就是一个模块,但是CMD的加载方式更加优秀,是通过按需加载的方式,而不是必须在模块开始就加载所有的依赖。

CMD与AMD一样,也是采用特定的define()函数来定义,用require方式来引用模块

AMD和CMD的区别: 二者皆为异步加载模块,AMD依赖前置,js可以方便知道依赖模块是谁,立即加载;而CMD就近依赖,需要使用把模块变为字符串解析一遍才知道依赖了那些模块,这也是很多人诟病CMD的一点,牺牲性能来带来开发的便利性,实际上解析模块用的时间短到可以忽略。

三、ES6 module(目前浏览器无法识别,需要使用 babel 转换为 commonjs规范)

你可能注意到了,上述的这几种方法都不是JS原生支持的。要么是通过模块模式来模拟,要么是使用CommonJS或AMD。

幸运的是在JS的最新规范ECMAScript 6 (ES6)中,引入了模块功能。

ES6 的模块功能汲取了CommonJS 和 AMD 的优点,拥有简洁的语法并支持异步加载,并且还有其他诸多更好的支持(ep: 导入是实时只读的)。

ES6标准发布后,module成为标准,标准使用是以 export 指令导出接口,以 import 引入模块,但是在我们一贯的 node 模块中,我们依然采用的是 CommonJS 规范(commonjs规范最初就是用于node服务端的),使用require 引入模块,使用 module.exports 导出接口。

ES6 module 较之于前几个方案更为强大,也是我们所推崇的,但是由于ES6目前无法在浏览器中执行,所以,我们只能通过babel将不被支持的import编译为当前受到广泛支持的 require。

1. export 导出模块

export语法声明用于导出函数、对象、指定文件(或模块)的原始值。

注意:在node中使用的是 exports ,不要混淆了, 他只能导出对象。

export有两种模块导出方式:命名式导出(名称导出)和默认导出(定义式导出),命名式导出每个模块可以多个,而默认导出每个模块仅一个。

// 错误演示
export 1; // 绝对不可以
var a = 100;
export a;

export在导出接口的时候,必须与模块内部的变量具有一一对应的关系。直接导出1没有任何意义,也不可能在import的时候有一个变量与之对应。export a虽然看上去成立,但是a的值是一个数字,根本无法完成解构,因此必须写成export {a}的形式。即使a被赋值为一个function,也是不允许的。而且,大部分风格都建议,模块中最好在末尾用一个export导出所有的接口,例如:

export {fun as default,a,b,c};
2. import 引入模块

import语法声明用于从已导出的模块、脚本中导入函数、对象、指定文件(或模块)的原始值。

import模块导入与export模块导出功能相对应,也存在两种模块导入方式:命名式导入(名称导入)和默认导入(定义式导入)。

import 的语法跟 require 不同,而且 import 必须放在文件的最开始,且前面不允许有其他逻辑代码,这和其他所有编程语言风格一致。

require的使用非常简单,它相当于module.exports的传送门,module.exports后面的内容是什么,require的结果就是什么,对象、数字、字符串、函数……再把require的结果赋值给某个变量,相当于把require和module.exports进行平行空间的位置重叠。

而且require理论上可以运用在代码的任何地方,甚至不需要赋值给某个变量之后再使用,比如:

require('./a')(); // a模块是一个函数,立即执行a模块函数
var data = require('./a').data; // a模块导出的是一个对象
var a = require('./a')[0]; // a模块导出的是一个数组

你在使用时,完全可以忽略模块化这个概念来使用require,仅仅把它当做一个node内置的全局函数,它的参数甚至可以是表达式:

require(process.cwd() + '/a');

但是import则不同,它是编译时的(require是运行时的),它必须放在文件开头,而且使用格式也是确定的,不容置疑。它不会将整个模块运行后赋值给某个变量,而是只选择import的接口进行编译,这样在性能上比require好很多。

从理解上,require是赋值过程,import是解构过程,当然,require也可以将结果解构赋值给一组变量,但是import在遇到default时,和require则完全不同:var $ = require('jquery');import $ from 'jquery'是完全不同的两种概念。

参考文章

彻底搞清楚javascript中的require、import和export
Node中没搞明白require和import,你会被坑的很惨

四、ES6 module 与 CommonJS 模块的差异

写法上的差异:

  • require/exports 的用法只有以下三种简单的写法:
const fs = require('fs')
exports.fs = fs
module.exports = fs
  • 而 import/export 的写法就多种多样:
import fs from 'fs'
import {default as fs} from 'fs'
import * as fs from 'fs'
import {readFile} from 'fs'
import {readFile as read} from 'fs'
import fs, {readFile} from 'fs'

export default fs
export const fs
export function readFile
export {readFile, read}
export * from 'fs'

两个重大差异:

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。(ES6 Module 中导入模块的属性或者方法是强绑定的,包括基础类型;而 CommonJS 则是普通的值传递或者引用传递。)
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

举例说明:

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

$ node main.js
3
4

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

ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量(export 出去的变量)绑定其所在的模块

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

下面代码中,m1.js的变量foo,在刚加载时等于bar,过了 500 毫秒,又变为等于xql。

让我们看看,m2.js能否正确读取这个变化。

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

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

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

由于 ES6 输入的模块变量,只是一个“符号连接”(不像 commonjs 是一个值的拷贝/副本, 相当于把被引入的模块代码全部复制了一份放过来),所以这个变量是只读的,对它进行重新赋值会报错。

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

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

obj.prop = 123; // OK
obj = {}; // TypeError可以对obj添加属性,但是重新赋值就会报错。因为变量obj指向的地址是只读的,不能重新赋值

还有,export通过接口,输出的是同一个值。不同的脚本加载这个接口,引用的都是同一个实例(编译时就 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();

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

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

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

$ babel-node main.js
1 // 输出不是 0 而是 1 说明引用的是同一个实例,有一处改变了模块内部的代码,其他处引用该模块时也会跟着变化

所以,在es6 modules 中,引入模块时,尽量不要改变模块内的变量。

  • 最后,看一下 es6 module 的循环加载。我们知道 CommonJS 一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。这是因为 CommonJS 是同步加载导致的。ES6模块是动态引用。只要两个模块之间存在某个引用,代码就能够执行。
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';

$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined

首先,执行a.mjs以后,引擎发现它加载了b.mjs,因此会优先执行b.mjs,然后再执行a.mjs。接着,执行b.mjs的时候,已知它从a.mjs输入了foo接口,这时不会去执行a.mjs,而是认为这个接口已经存在了,继续往下执行。执行到第三行console.log(foo)的时候,才发现这个接口根本没定义,因此报错。

解决这个问题的方法,就是让b.mjs运行的时候,foo已经有定义了。这可以通过将foo写成函数来解决。

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' }
export {foo};

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export {bar};

$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar

这是因为函数具有提升作用,在执行import {bar} from './b'时,函数foo就已经有定义了,所以b.mjs加载的时候不会报错。这也意味着,如果把函数foo改写成函数表达式,也会报错。

References

深入理解JavaScript系列(3):全面解析Module模式
CommonJS规范
Module 的加载实现
CommonJS模块和ES6模块的区别
模块化利器: 一篇文章掌握RequireJS常用知识

@xuexueq xuexueq added the 前端工程化&&性能优化 前端工程化&&性能优化 label Sep 2, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
前端工程化&&性能优化 前端工程化&&性能优化
Projects
None yet
Development

No branches or pull requests

1 participant