2

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

 3 years ago
source link: https://xieyufei.com/2021/01/08/CommonJS-ES6.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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代替,我们来看一下弃用的原因:

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

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变量指向一个值,因为这样等于切断了exportsmodule.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");

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模块,读取缓存

  因此最后打印的结果:

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

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

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

  与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的模块自动采用严格模式
更多前端资料请关注作者公众号``【前端壹读】``。
PS:公众号接入了图灵机器人小壹,欢迎各位老铁来撩。

follow.png

本文地址: http://xieyufei.com/2021/01/08/CommonJS-ES6.html

@谢小飞的网站

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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK