前端模块化是前端工程化的第一步也是重要的一步;不管你是使用React,还是Vue,亦或是Nodejs,都离不开模块化。模块化的规范有很多,而现在用的最多的就是CommonJS和ES6规范,因此我们来深入了解这两个规范以及两者之间的区别。
我正在参加掘金2020年度人气作者打榜,期待您宝贵的一票
CommonJS
CommonJS规范是一种同步加载模块的方式,也就是说,只有当模块加载完成后,才能执行后面的操作。由于Nodejs主要用于服务器端编程,而模块文件一般都已经存在于本地硬盘,加载起来比较快,因此同步加载模块的CommonJS规范就比较适用。
概述
CommonJS规范规定,每一个JS文件就是一个模块,有自己的作用域;在一个模块中定义的变量、函数等都是私有变量,对其他文件不可见。
// number.js
let num = 1
function add(x) {
return num + x
}
在上面的number.js中,变量num和函数add就是当前文件私有的,其他文件不能访问。同时CommonJS规定了,每个模块内部都有一个module
变量,代表当前模块;这个变量是一个对象,它的exports
属性(即module.exports
)提供对外导出模块的接口。
// number.js
let num = 1
function add(x) {
return num + x
}
module.exports.num = num
module.exports.add = add
这样我们定义的私有变量就能提供对外访问;加载某一个模块,就是加载这个模块的module.exports
属性。
module
上面说到,module
变量代表当前模块,我们来打印看一下它里面有哪些信息:
//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
代替,我们来看一下弃用的原因:
exports
为了导出模块方便,我们还可以通过exports
变量,它指向module.exports
,因此这就相当于在每个模块隐性的添加了这样一行代码:
var exports = module.exports;
在对外输出模块时,可以向exports对象添加属性。
// number.js
let num = 1
function add(x) {
return num + x
}
exports.num = num
exports.add = add
需要注意的是,不能直接将exports
变量指向一个值,因为这样等于切断了exports
和module.exports
之间的联系
// a.js
exports = 'a'
// main.js
var a = require('./a')
console.log(a)
// {}
虽然我们通过exports
导出了字符串,但是由于切断了exports = module.exports
之间的联系,而module.exports
实际上还是指向了空对象,最终导出的结果也是空对象。
require
require的基本功能是读取并执行JS文件,并返回模块导出的module.exports
对象:
const number = require("./number.js")
console.log(number.num)
number.add()
如果模块导出的是一个函数,就不能定义在exports
对象上:
// number.js
module.exports = function () {
console.log("number")
}
// main.js
require("./number.js")()
require除了能够作为函数调用加载模块以外,它本身作为一个对象还有以下属性:
- resolve:需要解析的模块路径。
- main:
Module
对象,表示当进程启动时加载的入口脚本。 - extensions:如何处理文件扩展名。
- cache:被引入的模块将被缓存在这个对象中。
模块缓存
当我们在一个项目中多次require
同一个模块时,CommonJS并不会多次执行该模块文件;而是在第一次加载时,将模块缓存;以后再加载该模块时,就直接从缓存中读取该模块:
//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属性,就是用来缓存模块的,我们先打印看一下:
{
'D:\\demo\\main.js': Module {},
'D:\\demo\\number.js': Module {}
}
cache按照路径的形式将模块进行缓存,我们可以通过delete require.cache[modulePath]
将缓存的模块删除;我们把上面的代码改写一下:
//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
循环来删除所有模块的缓存:
Object.keys(require.cache).forEach(function(key) {
delete require.cache[key];
})
加载机制
CommonJS的加载机制是,模块输出的是一个值的复制拷贝;对于基本数据类型的输出,属于复制,对于复杂数据类型,属于浅拷贝,我们来看一个例子:
// 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
这个特性,我们也可以理解为它将模块放到自执行函数中执行:
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进行了浅拷贝,因此如果两个脚本同时引用了同一个模块,对该模块的修改会影响另一个模块:
// 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运行时加载模块,我们甚至能够通过判断语句,动态的选择去加载某个模块:
let num = 10;
if (num > 2) {
var a = require("./a");
} else {
var b = require("./b");
}
var moduleName = 'number.js'
var number = require(`./${moduleName}`)
但也正是由于这种动态加载,导致没有办法在编译时做静态优化。
循环加载
由于缓存机制的存在,CommonJS的模块之间可以进行循环加载,而不用担心引起死循环:
//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");
在上面代码中,逻辑看似很复杂,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模块,读取缓存
因此最后打印的结果:
{ 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
关键词来导出变量、函数或者类:
export let num = 1
export function add(x) {
return num + x
}
export class Person {}
或者我们也可以直接导出一个对象,这两种方式是等价的:
let num = 1
function add(x) {
return num + x
}
class Person {}
export { num, add, Person }
在导出对象时,我们还可以使用as
关键词重命名导出的变量:
let num = 1
function add(x) {
return num + x
}
export {
num as number,
num as counter,
add as addCount,
add as addFunction
}
通过as
重名了,我们将变量进行了多次的导出。需要注意的是,export
规定,导出的是对外的接口,必须与模块内部的变量建立一一对应的关系。下面两种是错误的写法:
// 报错,是个值,没有提供接口
export 1;
// 报错,需要放在大括号中
var m = 1;
export m;
import
使用export
导出模块对外接口后,其他模块文件可以通过import
命令加载这个接口:
import {
number,
counter,
addCount,
addFunction
} from "./number.js"
上面代码从number.js模块中加载了变量,import命令接受一对大括号,里面指定了从模块导入变量名,导入的变量名必须与被导入模块对外接口的变量名称相同。
和export命令一样,我们可以使用as
关键字,将导入的变量名进行重命名:
import {
number as num,
} from "./number.js"
console.log(num)
除了加载模块中指定变量接口,我们还可以使用整体加载,通过(*)指定一个对象,所有的输出值都加载在这个对象上:
import * as number from "./number.js"
import命令具有提升效果,会提升到整个模块的头部,首先执行:
console.log(num)
import {
number as num,
} from "./number.js"
上面代码不会报错,因为import会优先执行;和CommonJS规范的require不同的是,import是静态执行,因此import不能位于块级作用域内,也不能使用表达式和变量,这些都是只有在运行时才能得到结果的语法结构:
//报错
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
来默认导出:
//add.js
export default function (x, y) {
return x + y;
};
//main.js
import add from './add'
console.log(add(2, 4))
由于export default
是默认导出,因此,这个命令在一个模块中只能使用一次,而export
导出接口是可以多次导出的:
//报错
//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
变量,因此它后面不能再跟变量声明语句:
//正确
export default 10
//正确
let num = 10
export default num
//报错
export default let num = 10
既然export default
本质上是导出了一个default变量的语法糖,因此我们也可以通过export
来进行改写:
//num.js
let num = 10;
export { num as default };
上面两个代码是等效的;而我们在import导入时,也是把default
变量重命名为我们想要的名字,因此下面两个导入代码也是等效的:
import num from './num'
//等效
import { default as num } from './num'
在一个模块中,export
可以有多个,export default
只能有一个,但是他们两者可以同时存在:
//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中的代码进行改写来理解两者的区别:
//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模块只是输出了一个对外的接口,我们可以把这个接口理解为一个引用
,实际的值还是在模块中;而且这个引用
还是一个只读引用
,不论是基本数据类型还是复杂数据类型:
//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中的代码来进行改造看一下:
//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的模块自动采用严格模式
更多前端资料请关注公众号【前端壹读】
。
如果觉得写得还不错,请关注我的掘金主页。更多文章请访问谢小飞的博客
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!