最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 深入学习CommonJS和ES6模块化规范

    正文概述 掘金(谢小飞)   2021-01-23   683

      前端模块化是前端工程化的第一步也是重要的一步;不管你是使用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变量指向一个值,因为这样等于切断了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");
    

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

      在上面代码中,逻辑看似很复杂,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}

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

    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的模块自动采用严格模式

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

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


    起源地下载网 » 深入学习CommonJS和ES6模块化规范

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元