深入学习CommonJS和ES6模块化规范

  前端模块化是前端工程化的第一步也是重要的一步;不管你是使用React,还是Vue,亦或是Nodejs,都离不开模块化。模块化的规范有很多,而现在用的最多的就是CommonJS和ES6规范,因此我们来深入了解这两个规范以及两者之间的区别。

CommonJS

  CommonJS规范是一种同步加载模块的方式,也就是说,只有当模块加载完成后,才能执行后面的操作。由于Nodejs主要用于服务器端编程,而模块文件一般都已经存在于本地硬盘,加载起来比较快,因此同步加载模块的CommonJS规范就比较适用。

概述

  CommonJS规范规定,每一个JS文件就是一个模块,有自己的作用域;在一个模块中定义的变量、函数等都是私有变量,对其他文件不可见。

1
2
3
4
5
// number.js
let num = 1
function add(x) {
return num + x
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  在上面的number.js中,变量num和函数add就是当前文件私有的,其他文件不能访问。同时CommonJS规定了,每个模块内部都有一个module变量,代表当前模块;这个变量是一个对象,它的exports属性(即module.exports)提供对外导出模块的接口。

1
2
3
4
5
6
7
// number.js
let num = 1
function add(x) {
return num + x
}
module.exports.num = num
module.exports.add = add

  这样我们定义的私有变量就能提供对外访问;加载某一个模块,就是加载这个模块的module.exports属性。

module

  上面说到,module变量代表当前模块,我们来打印看一下它里面有哪些信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//temp.js
require('./a.js')
console.log(module)

Module {
id: '.',
path: 'D:\\demo',
exports: {},
parent: null,
filename: 'D:\\demo\\temp.js',
loaded: false,
children: [{
Module {
id: 'D:\\demo\\a.js',
path: 'D:\\demo',
exports: {},
parent: [Circular],
filename: 'D:\\demo\\a.js',
loaded: true,
children: [],
paths: [Array]
}
}],
paths: [
'D:\\demo\\node_modules',
'D:\\projects\\mynodejs\\node_modules',
'D:\\projects\\node_modules',
'D:\\node_modules'
]
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们发现它有以下属性:

  • id:模块的识别符,通常是带有绝对路径的模块文件名
  • filename:模块的文件名,带有绝对路径。
  • loaded:返回一个布尔值,表示模块是否已经完成加载。
  • parent:返回一个对象,表示调用该模块的模块。
  • children:回一个数组,表示该模块要用到的其他模块。
  • exports:模块对外输出的对象。
  • path:模块的目录名称。
  • 谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  • paths:模块的搜索路径。

  如果我们通过命令行调用某个模块,比如node temp.js,那么这个模块就是顶级模块,它的module.parent就是null;如果是在其他模块中被调用,比如require('temp.js'),那么它的module.parent就是调用它的模块。

  但是在最新的Nodejs 14.6版本中module.parent被弃用了,官方推荐使用require.main或者module.children代替,我们来看一下弃用的原因:

module.parent值为通过required引用的这个模块的值。如果为当前运行进程的入口,值为null。如果这个模块被非commonJS格式引入,如REPL,或者import导入,值为undefined

exports

  为了导出模块方便,我们还可以通过exports变量,它指向module.exports,因此这就相当于在每个模块隐性的添加了这样一行代码:

1
var exports = module.exports;

  在对外输出模块时,可以向exports对象添加属性。

1
2
3
4
5
6
7
// number.js
let num = 1
function add(x) {
return num + x
}
exports.num = num
exports.add = add

  需要注意的是,不能直接将exports变量指向一个值,因为这样等于切断了exportsmodule.exports之间的联系

1
2
3
4
5
6
// a.js
exports = 'a'
// main.js
var a = require('./a')
console.log(a)
// {}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  虽然我们通过exports导出了字符串,但是由于切断了exports = module.exports之间的联系,而module.exports实际上还是指向了空对象,最终导出的结果也是空对象。

require

  require的基本功能是读取并执行JS文件,并返回模块导出的module.exports对象:

1
2
3
const number = require("./number.js")
console.log(number.num)
number.add()

  如果模块导出的是一个函数,就不能定义在exports对象上:

1
2
3
4
5
6
7
// number.js
module.exports = function () {
console.log("number")
}

// main.js
require("./number.js")()

  require除了能够作为函数调用加载模块以外,它本身作为一个对象还有以下属性:

  • resolve:需要解析的模块路径。
  • main:Module对象,表示当进程启动时加载的入口脚本。
  • extensions:如何处理文件扩展名。
  • cache:被引入的模块将被缓存在这个对象中。

模块缓存

  当我们在一个项目中多次require同一个模块时,CommonJS并不会多次执行该模块文件;而是在第一次加载时,将模块缓存;以后再加载该模块时,就直接从缓存中读取该模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//number.js
console.log('run number.js')
module.exports = {
num: 1
}

//main.js
let number1 = require("./number");

let number2 = require("./number");
number2.num = 2

let number3 = require("./number");
console.log(number3)

// run number.js
// { num: 2 }

  我们多次require加载number模块,但是内部只有一次打印输出;第二次加载时还改变了内部变量的值,第三次加载时内部变量的值还是上一次的赋值,这就证明了后面的require读取的是缓存。

  在上面require中,我们介绍了它下面所有的属性,发现有一个cache属性,就是用来缓存模块的,我们先打印看一下:

1
2
3
4
{
'D:\\demo\\main.js': Module {},
'D:\\demo\\number.js': Module {}
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  cache按照路径的形式将模块进行缓存,我们可以通过delete require.cache[modulePath]将缓存的模块删除;我们把上面的代码改写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//number.js
console.log('run number.js')
module.exports = {
num: 1
}

//main.js
let number1 = require("./number");

let number2 = require("./number");
number2.num = 2

//删除缓存
delete require.cache['D:\\demo\\number.js']

let number3 = require("./number");
console.log(number3)

// run number.js
// run number.js
// { num: 1 }

  很明显的发现,number模块运行了两遍,第二次加载模块我们又把模块的缓存给清除了,因此第三次读取的num值也是最新的;我们也可以通过Object.keys循环来删除所有模块的缓存:

1
2
3
Object.keys(require.cache).forEach(function(key) {
delete require.cache[key];
})

加载机制

  CommonJS的加载机制是,模块输出的是一个值的复制拷贝;对于基本数据类型的输出,属于复制,对于复杂数据类型,属于浅拷贝,我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// number.js
let num = 1
function add() {
num++
}
module.exports.num = num
module.exports.add = add

// main.js
var number = require('./number')
//1
console.log(number.num)

number.add()
//1
console.log(number.num)

number.num = 3
//3
console.log(number.num)

  由于CommonJS是值的复制,一旦模块输出了值,模块内部的变化就影响不到这个值;因此main.js中的number变量本身和number.js没有任何指向关系了,虽然我们调用模块内部的add函数来改变值,但也影响不到这个值了;反而我们在输出后可以对这个值进行任意的编辑。

  针对require这个特性,我们也可以理解为它将模块放到自执行函数中执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var number = (function(){
let num = 1
function add() {
num++
}
return {
num,
add,
}
})()
//1
console.log(number.num)

number.add()
//1
console.log(number.num)

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  而对于复杂数据类型,由于CommonJS进行了浅拷贝,因此如果两个脚本同时引用了同一个模块,对该模块的修改会影响另一个模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// obj.js
var obj = {
color: {
list: ['red', 'yellow','blue']
}
}
module.exports = obj

//a.js
var obj = require('./obj')
obj.color.list.push('green')
//{ color: { list: [ 'red', 'yellow', 'blue', 'green' ] } }
console.log(obj)

//b.js
var obj = require('./obj')
//{ color: { list: [ 'red', 'yellow', 'blue', 'green' ] } }
console.log(obj)

//main.js
require('./a')
require('./b')

  上面代码中我们通过a.js、b.js两个脚本同时引用一个模块进行修改和读取;需要注意的是由于缓存,因此b.js加载时其实已经是从缓存中读取的模块。

  我们上面说过require加载时,会执行模块中的代码,然后将模块的module.exports属性作为返回值进行返回;我们发现这个加载过程发生在代码的运行阶段,而在模块被执行前,没有办法确定模块的依赖关系,这种加载加载方式称为运行时加载;由于CommonJS运行时加载模块,我们甚至能够通过判断语句,动态的选择去加载某个模块:

1
2
3
4
5
6
7
8
9
10
11
let num = 10;

if (num > 2) {
var a = require("./a");
} else {
var b = require("./b");
}

var moduleName = 'number.js'

var number = require(`./${moduleName}`)

  但也正是由于这种动态加载,导致没有办法在编译时做静态优化。

循环加载

  由于缓存机制的存在,CommonJS的模块之间可以进行循环加载,而不用担心引起死循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//a.js
exports.a = 1;
var b = require("./b");
console.log(b, "a.js");
exports.a = 2;


//b.js
exports.b = 11;
var a = require("./a");
console.log(a, "b.js");
exports.b = 22;


//main.js
const a = require("./a");
const b = require("./b");

console.log(a, "main a");
console.log(b, "main b");

circle.png

  在上面代码中,逻辑看似很复杂,a.js加载了b.js,而b.js加载了a.js;但是我们逐一来进行分析,就会发现其实很简单。

    谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  1. 加载main.js,发现加载了a模块;读取并存入缓存
  2. 执行a模块,导出了{a:1};发现加载了b模块去,读取并存入缓存
  3. 执行b模块,导出了{b:11};又加载了a模块,读取缓存,此时a模块只导出了{a:1}
  4. b模块执行完毕,导出了{b:22}
  5. 回到a模块,执行完毕,导出{a:2}
  6. 回到main.js,又加载了b模块,读取缓存

  因此最后打印的结果:

1
2
3
4
{ a: 1 } b.js   
{ b: 22 } a.js
{ a: 2 } main a
{ b: 22 } main b

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  尤其需要注意的是第一个b模块中的console,由于此时a模块虽然已经加载在缓存中,但是并没有执行完成,a模块只导出了第一个{a:1}

  我们发现循环加载,属于加载时执行;一旦某个模块被循环加载,就只输出已经执行的部分,还未执行的部分不会输出。

ES6

  与CommonJS规范动态加载不同,ES6模块化的设计思想是尽量的静态化,使得在编译时就能够确定模块之间的依赖关系。我们在Webpack配置全解析(优化篇)就聊到,利用ES6模块静态化加载方案,就可以实现Tree Shaking来优化代码。

export

  和CommonJS相同,ES6规范也定义了一个JS文件就是一个独立模块,模块内部的变量都是私有化的,其他模块无法访问;不过ES6通过export关键词来导出变量、函数或者类:

1
2
3
4
5
export let num = 1
export function add(x) {
return num + x
}
export class Person {}

  或者我们也可以直接导出一个对象,这两种方式是等价的:

1
2
3
4
5
6
7
let num = 1
function add(x) {
return num + x
}

class Person {}
export { num, add, Person }

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  在导出对象时,我们还可以使用as关键词重命名导出的变量:

1
2
3
4
5
6
7
8
9
10
11
let num = 1
function add(x) {
return num + x
}

export {
num as number,
num as counter,
add as addCount,
add as addFunction
}

  通过as重名了,我们将变量进行了多次的导出。需要注意的是,export规定,导出的是对外的接口,必须与模块内部的变量建立一一对应的关系。下面两种是错误的写法:

1
2
3
4
5
6
// 报错,是个值,没有提供接口
export 1;

// 报错,需要放在大括号中
var m = 1;
export m;

import

  使用export导出模块对外接口后,其他模块文件可以通过import命令加载这个接口:

1
2
3
4
5
6
import {
number,
counter,
addCount,
addFunction
} from "./number.js"

  上面代码从number.js模块中加载了变量,import命令接受一对大括号,里面指定了从模块导入变量名,导入的变量名必须与被导入模块对外接口的变量名称相同。

  和export命令一样,我们可以使用as关键字,将导入的变量名进行重命名:

1
2
3
4
import {
number as num,
} from "./number.js"
console.log(num)

  除了加载模块中指定变量接口,我们还可以使用整体加载,通过(*)指定一个对象,所有的输出值都加载在这个对象上:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
import * as number from "./number.js"

  import命令具有提升效果,会提升到整个模块的头部,首先执行:

1
2
3
4
console.log(num)
import {
number as num,
} from "./number.js"

  上面代码不会报错,因为import会优先执行;和CommonJS规范的require不同的是,import是静态执行,因此import不能位于块级作用域内,也不能使用表达式和变量,这些都是只有在运行时才能得到结果的语法结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
//报错
let moduleName = './num'
import { num, add } from moduleName;


//报错
//SyntaxError: 'import' and 'export' may only appear at the top level
let num = 10;
if (num > 2) {
import a from "./a";
} else {
import b from "./b";
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

export default

  在上面代码中import导入export对外接口时,都需要知道对外接口的准确名称,才能拿到对应的值,这样比较麻烦,有时我们只有一个接口需要导出;为此ES6规范提供了export default来默认导出:

1
2
3
4
5
6
7
//add.js
export default function (x, y) {
return x + y;
};
//main.js
import add from './add'
console.log(add(2, 4))

  由于export default是默认导出,因此,这个命令在一个模块中只能使用一次,而export导出接口是可以多次导出的:

1
2
3
4
5
6
7
8
9
//报错
//SyntaxError: Only one default export allowed per module.
//add.js
export default function (x, y) {
return x + y;
};
export default function (x, y) {
return x + y + 1;
};

  export default其实是语法糖,本质上是将后面的值赋值给default变量,所以可以将一个值写在export default之后;但是正是由于它是输出了一个default变量,因此它后面不能再跟变量声明语句:

1
2
3
4
5
6
7
8
9
//正确
export default 10

//正确
let num = 10
export default num

//报错
export default let num = 10

  既然export default本质上是导出了一个default变量的语法糖,因此我们也可以通过export来进行改写:

1
2
3
//num.js
let num = 10;
export { num as default };

  上面两个代码是等效的;而我们在import导入时,也是把default变量重命名为我们想要的名字,因此下面两个导入代码也是等效的:

1
2
3
import num from './num'
//等效
import { default as num } from './num'

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  在一个模块中,export可以有多个,export default只能有一个,但是他们两者可以同时存在:

1
2
3
4
5
6
7
8
9
10
11
//num.js
export let num1 = 1
export let num2 = 2
let defaultNum = 3
export default defaultNum

//main.js
import defaultNum, {
num1,
num2
} from './num'

加载机制

  在CommonJS中我们说了,模块的输出是值的复制拷贝;而ES6输出的则是对外接口,我们将上面CommonJS中的代码进行改写来理解两者的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//number.js
let num = 1

function add() {
num++
}

export { num, add }

//main.js
import { num, add } from './number.js'

//1
console.log(num)
add()
//2
console.log(num)

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们发现和CommonJS中运行出来结果完全不一样,调用模块中的函数影响了模块中的变量值;正是由于ES6模块只是输出了一个对外的接口,我们可以把这个接口理解为一个引用,实际的值还是在模块中;而且这个引用还是一个只读引用,不论是基本数据类型还是复杂数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
//obj.js
let num = 1
let list = [1,2]

export { num, list }

//main.js
import { num, list } from './obj.js'
//Error: "num" is read-only.
num = 3
//Error: "list" is read-only.
list = [3, 4]

  import也会对导入的模块进行缓存,重复import导入同一个模块,只会执行一次,这里就不进行代码演示。

循环引用

  ES6模块之间也存在着循环引用,我们还是将CommonJS中的代码来进行改造看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//a.js
export let a1 = 1;
import { b1, b2 } from "./b";
console.log(b1, b2, "a.js");
export let a2 = 11;

//b.js
export let b1 = 2;
import { a1, a2 } from "./a";
console.log(a1, a2, "b.js");
export let b2 = 22;

//main.js
import { a1, a2 } from "./a";
import { b1, b2 } from "./b";

  刚开始我们肯定会想当然的以为b.js中打印的是1和undefined,因为a.js只加载了第一个export;但是打印结果后,b.js中两个都是undefined,这是因为import有提升效果。

区别总结

  通过上面我们对CommonJS规范和ES6规范的比较,我们总结一下两者的区别:

    谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  • CommonJS模块是运行时加载,ES6模块是编译时输出接口
  • CommonJS模块输出的是一个值的复制,ES6模块输出的是值的引用
  • CommonJS加载的是整个模块,即将所有的方法全部加载进来,ES6可以单独加载其中的某个方法
  • CommonJS中this指向当前模块,ES6中this指向undefined
  • CommonJS默认非严格模式,ES6的模块自动采用严格模式

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。