深入学习CommonJS和ES6模块化规范
前端模块化是前端工程化的第一步也是重要的一步;不管你是使用React,还是Vue,亦或是Nodejs,都离不开模块化。模块化的规范有很多,而现在用的最多的就是CommonJS和ES6规范,因此我们来深入了解这两个规范以及两者之间的区别。
CommonJS
CommonJS规范是一种同步加载模块的方式,也就是说,只有当模块加载完成后,才能执行后面的操作。由于Nodejs主要用于服务器端编程,而模块文件一般都已经存在于本地硬盘,加载起来比较快,因此同步加载模块的CommonJS规范就比较适用。
概述
CommonJS规范规定,每一个JS文件就是一个模块,有自己的作用域;在一个模块中定义的变量、函数等都是私有变量,对其他文件不可见。
1 |
|
在上面的number.js中,变量num和函数add就是当前文件私有的,其他文件不能访问。同时CommonJS规定了,每个模块内部都有一个module
变量,代表当前模块;这个变量是一个对象,它的exports
属性(即module.exports
)提供对外导出模块的接口。
1 |
|
这样我们定义的私有变量就能提供对外访问;加载某一个模块,就是加载这个模块的module.exports
属性。
module
上面说到,module
变量代表当前模块,我们来打印看一下它里面有哪些信息:
1 |
|
我们发现它有以下属性:
- 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 |
|
在对外输出模块时,可以向exports对象添加属性。
1 |
|
需要注意的是,不能直接将exports
变量指向一个值,因为这样等于切断了exports
和module.exports
之间的联系
1 |
|
虽然我们通过exports
导出了字符串,但是由于切断了exports = module.exports
之间的联系,而module.exports
实际上还是指向了空对象,最终导出的结果也是空对象。
require
require的基本功能是读取并执行JS文件,并返回模块导出的module.exports
对象:
1 |
|
如果模块导出的是一个函数,就不能定义在exports
对象上:
1 |
|
require除了能够作为函数调用加载模块以外,它本身作为一个对象还有以下属性:
- resolve:需要解析的模块路径。
- main:
Module
对象,表示当进程启动时加载的入口脚本。 - extensions:如何处理文件扩展名。
- cache:被引入的模块将被缓存在这个对象中。
模块缓存
当我们在一个项目中多次require
同一个模块时,CommonJS并不会多次执行该模块文件;而是在第一次加载时,将模块缓存;以后再加载该模块时,就直接从缓存中读取该模块:
1 |
|
我们多次require加载number模块,但是内部只有一次打印输出;第二次加载时还改变了内部变量的值,第三次加载时内部变量的值还是上一次的赋值,这就证明了后面的require
读取的是缓存。
在上面require
中,我们介绍了它下面所有的属性,发现有一个cache属性,就是用来缓存模块的,我们先打印看一下:
1 |
|
cache按照路径的形式将模块进行缓存,我们可以通过delete require.cache[modulePath]
将缓存的模块删除;我们把上面的代码改写一下:
1 |
|
很明显的发现,number模块运行了两遍,第二次加载模块我们又把模块的缓存给清除了,因此第三次读取的num值也是最新的;我们也可以通过Object.keys
循环来删除所有模块的缓存:
1 |
|
加载机制
CommonJS的加载机制是,模块输出的是一个值的复制拷贝;对于基本数据类型的输出,属于复制,对于复杂数据类型,属于浅拷贝,我们来看一个例子:
1 |
|
由于CommonJS是值的复制,一旦模块输出了值,模块内部的变化就影响不到这个值;因此main.js中的number
变量本身和number.js
没有任何指向关系了,虽然我们调用模块内部的add
函数来改变值,但也影响不到这个值了;反而我们在输出后可以对这个值进行任意的编辑。
针对require
这个特性,我们也可以理解为它将模块放到自执行函数中执行:
1 |
|
而对于复杂数据类型,由于CommonJS进行了浅拷贝,因此如果两个脚本同时引用了同一个模块,对该模块的修改会影响另一个模块:
1 |
|
上面代码中我们通过a.js、b.js两个脚本同时引用一个模块进行修改和读取;需要注意的是由于缓存,因此b.js加载时其实已经是从缓存中读取的模块。
我们上面说过require
加载时,会执行模块中的代码,然后将模块的module.exports
属性作为返回值进行返回;我们发现这个加载过程发生在代码的运行阶段,而在模块被执行前,没有办法确定模块的依赖关系,这种加载加载方式称为运行时加载
;由于CommonJS运行时加载模块,我们甚至能够通过判断语句,动态的选择去加载某个模块:
1 |
|
但也正是由于这种动态加载,导致没有办法在编译时做静态优化。
循环加载
由于缓存机制的存在,CommonJS的模块之间可以进行循环加载,而不用担心引起死循环:
1 |
|
在上面代码中,逻辑看似很复杂,a.js加载了b.js,而b.js加载了a.js;但是我们逐一来进行分析,就会发现其实很简单。
- 加载main.js,发现加载了a模块;读取并存入缓存
- 执行a模块,导出了{a:1};发现加载了b模块去,读取并存入缓存
- 执行b模块,导出了{b:11};又加载了a模块,读取缓存,此时a模块只导出了{a:1}
- b模块执行完毕,导出了{b:22}
- 回到a模块,执行完毕,导出{a:2}
- 回到main.js,又加载了b模块,读取缓存
因此最后打印的结果:
1 |
|
尤其需要注意的是第一个b模块中的console,由于此时a模块虽然已经加载在缓存中,但是并没有执行完成,a模块只导出了第一个{a:1}
。
我们发现循环加载,属于加载时执行;一旦某个模块被循环加载,就只输出已经执行的部分,还未执行的部分不会输出。
ES6
与CommonJS规范动态加载不同,ES6模块化的设计思想是尽量的静态化,使得在编译时就能够确定模块之间的依赖关系。我们在Webpack配置全解析(优化篇)就聊到,利用ES6模块静态化加载方案,就可以实现Tree Shaking
来优化代码。
export
和CommonJS相同,ES6规范也定义了一个JS文件就是一个独立模块,模块内部的变量都是私有化的,其他模块无法访问;不过ES6通过export
关键词来导出变量、函数或者类:
1 |
|
或者我们也可以直接导出一个对象,这两种方式是等价的:
1 |
|
在导出对象时,我们还可以使用as
关键词重命名导出的变量:
1 |
|
通过as
重名了,我们将变量进行了多次的导出。需要注意的是,export
规定,导出的是对外的接口,必须与模块内部的变量建立一一对应的关系。下面两种是错误的写法:
1 |
|
import
使用export
导出模块对外接口后,其他模块文件可以通过import
命令加载这个接口:
1 |
|
上面代码从number.js模块中加载了变量,import命令接受一对大括号,里面指定了从模块导入变量名,导入的变量名必须与被导入模块对外接口的变量名称相同。
和export命令一样,我们可以使用as
关键字,将导入的变量名进行重命名:
1 |
|
除了加载模块中指定变量接口,我们还可以使用整体加载,通过(*)指定一个对象,所有的输出值都加载在这个对象上:
1 |
|
import命令具有提升效果,会提升到整个模块的头部,首先执行:
1 |
|
上面代码不会报错,因为import会优先执行;和CommonJS规范的require不同的是,import是静态执行,因此import不能位于块级作用域内,也不能使用表达式和变量,这些都是只有在运行时才能得到结果的语法结构:
1 |
|
export default
在上面代码中import导入export对外接口时,都需要知道对外接口的准确名称,才能拿到对应的值,这样比较麻烦,有时我们只有一个接口需要导出;为此ES6规范提供了export default
来默认导出:
1 |
|
由于export default
是默认导出,因此,这个命令在一个模块中只能使用一次,而export
导出接口是可以多次导出的:
1 |
|
export default
其实是语法糖,本质上是将后面的值赋值给default
变量,所以可以将一个值写在export default
之后;但是正是由于它是输出了一个default
变量,因此它后面不能再跟变量声明语句:
1 |
|
既然export default
本质上是导出了一个default变量的语法糖,因此我们也可以通过export
来进行改写:
1 |
|
上面两个代码是等效的;而我们在import导入时,也是把default
变量重命名为我们想要的名字,因此下面两个导入代码也是等效的:
1 |
|
在一个模块中,export
可以有多个,export default
只能有一个,但是他们两者可以同时存在:
1 |
|
加载机制
在CommonJS中我们说了,模块的输出是值的复制拷贝;而ES6输出的则是对外接口,我们将上面CommonJS中的代码进行改写来理解两者的区别:
1 |
|
我们发现和CommonJS中运行出来结果完全不一样,调用模块中的函数影响了模块中的变量值;正是由于ES6模块只是输出了一个对外的接口,我们可以把这个接口理解为一个引用
,实际的值还是在模块中;而且这个引用
还是一个只读引用
,不论是基本数据类型还是复杂数据类型:
1 |
|
import也会对导入的模块进行缓存,重复import导入同一个模块,只会执行一次,这里就不进行代码演示。
循环引用
ES6模块之间也存在着循环引用,我们还是将CommonJS中的代码来进行改造看一下:
1 |
|
刚开始我们肯定会想当然的以为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的模块自动采用严格模式
本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。