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

您所在的位置:网站首页 es6和7的区别 深入学习CommonJS和ES6模块化规范

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

2023-07-25 07:07| 来源: 网络整理| 查看: 265

  前端模块化是前端工程化的第一步也是重要的一步;不管你是使用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代替,我们来看一下弃用的原因:

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

circle.png

  在上面代码中,逻辑看似很复杂,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指向当前模块module.exports,ES6中this指向undefined CommonJS默认非严格模式,ES6的模块自动采用严格模式

更多前端资料请关注公众号【前端壹读】。

如果觉得写得还不错,请关注我的掘金主页。更多文章请访问谢小飞的博客



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3