美文网首页
require()、import、import()加载模块详解(

require()、import、import()加载模块详解(

作者: AizawaSayo | 来源:发表于2021-05-13 22:56 被阅读0次

    静态编译:JavaScript 是先编译后执行的。可以理解成 JS 解释器先“翻译”你写的代码,在转译过程中如果没有发现错误,就按你写的意思去执行。

    CommonJS 的 require()

    在运行时按顺序加载模块,即以同步的方式检索模块的导出。
    可以从 node_modules 引入库模块。 或者使用相对路径(例如 ././foo./bar/baz../foo)引入本地模块或 JSON 文件,路径会根据 __dirname 定义的目录名或当前工作目录进行处理。

    CommonJS主要用于服务端,专为同步加载和服务器而设计,简称CJS。NodeJS采用CommonJS作为模块解决方案,是CJS的标准和最佳实现。如果用Node写过后端应用的话应该会很熟悉。CJS 不能在浏览器中工作,必须经过转换和打包。随着ES6 Module (简称ESM) 成为主流,Node也逐步添加了对 ECMAScript 模块 的支持。

    语法:

    导出:module.exportsexports

    module 变量代表当前模块,module.exports 即模块对象下的 exports 属性,它的值是模块对外输出的接口 (默认值是一个空对象)。加载某个模块,就是加载该模块的 module.exports 属性。

    // 添加/修改 module.exports 对象的属性
    module.exports.name1 = any
    // 也可以直接给 module.exports 赋值
    module.exports = any
    

    exports 是对 module.exports 对象的引用,为了便利提供的快捷方法。
    使用方法是:exports.name1 = 'anyType',效果和module.exports.name1 = 'anyType'是一样的。
    可以通过修改/添加 exports 上面的属性将一些值 (简单类型、对象、函数、类都可) 挂到模块导出对象 (module.exports) 的根部。但不能直接给 exports 重新赋值,因为这样它就不再指向 module.exports 的引用地址了,失去和模块导出对象的关联了。

    模拟下 require() 获取 module.exports 接口导出值的过程:

    function require(/* ... */) {
      // 初始化一个包含 exports 导出对象的 module 变量
      const module = { exports: {} };
      ((module, exports) => { 
        // 此时传进来的 module 和 exports 都是复制了引用地址。  
        // 先执行模块代码。假设这个模块定义了一个函数。
        function someFunc() {}
        // 错误做法演示:
        exports = someFunc; // 本来指向 module.exports 的引用地址,现在变成 someFunc 的地址了。
        // 此时,exports 不再是一个 module.exports 的快捷方式,和导出对象失去关联。
        // 因此模块没有受影响,导出值依然是一个空的默认对象。
        module.exports = someFunc; // 直接让模块导出指向 someFunc 的地址
        // 此时,该模块导出 someFunc,而不是默认对象。
      })(module, module.exports); // 函数参数是值传递,简单类型复制值,引用类型传递地址
      return module.exports; // 最后当前模块拿到了导出对象,someFunc 的引用地址
    }
    
    导入:require(模块的名称或路径)

    指向当前模块的 module.require 命令,返回值是导入模块的浅拷贝副本。

    常见于引入一些库或核心模块的方法,简单示例:

    // ------ lib.js ------
    const sqrt = Math.sqrt;
    function square(x) {
      return x * x;
    }
    function diag(x, y) {
      return sqrt(square(x) + square(y));
    }
    module.exports = {
      sqrt: sqrt,
      square: square,
      diag: diag,
    };
    
    // ------ main.js ------
    const square = require('lib').square;
    const diag = require('lib').diag;
    console.log(square(11)); // 121
    console.log(diag(4, 3)); // 5
    

    require 的原理是先执行一遍模块的代码,拿到 module.exports 的值。如果 module.exports 是引用类型 (一般都是一个对象,因为导出一个简单数据没有什么意义),那么我们得到就是 module.exports 的引用地址。如果 module.exports 是个对象,那么对里面的属性同样是浅拷贝。
    因此如果我们在当前模块修改了导入的某些属性,很可能会影响被导入模块的数据,具体根据属性的类型和修改的方式。所以一般不会试图修改引入模块的数据,模块化的目的本就是获取其他模块的方法和数据为主。

    // ------ lib.js ------
    let counter = 3
    let obj = { count: 5 }
    function incCounter() {
      counter++
      console.log('lib-fun', counter) // 4
    }
    console.log('lib1', counter, obj.count) // 3 5
    setTimeout(function () {
      console.log('lib2', counter, obj.count) // 4 10
    })
    
    module.exports = {
      counter, 
      incCounter,
      obj
    }
    
    // ------ main1.js ------
    var counter = require('./lib').counter; 
    var incCounter = require('./lib').incCounter;
    var obj = require('./b').obj;
    // 执行这三句 require 的结果是:
    var counter = 3
    var incCounter = lib 的 incCounter 函数的引用地址
    var obj = lib 的 obj 对象的引用地址
    
    // 如果复制的属性是简单数据类型,现在只是和 lib 模块无关联的一个副本
    console.log(counter); // 3
    incCounter(); // 执行 lib 模块的 incCounter 方法,此时 lib 的 counter 变成4了
    console.log(counter); // 3
    
    // 导入的副本可以修改,简单数据类型是值拷贝,不会影响被导入模块的数据
    // 而引用数据类型会影响被导入模块
    counter = 8
    obj.count = 10
    console.log(`a.js-` + counter, obj.count) // 8 10
    
    // ------ main2.js ------
    var lib = require('./lib')
    // 执行上面这句的结果是:
    var lib = {
      counter: 3,  // 简单数据类型,复制值本身
      incCounter, // 指向 lib 模块的 incCounter 方法的地址
      obj // 复制了 lib 模块的 obj 的引用地址
    }
    
    // lib.counter 是简单数据类型,它的值是和 lib 模块无关联的一个副本
    console.log(lib.counter) // 3
    lib.incCounter() // 执行 lib 对象的 incCounter 方法, 此时 lib 模块的 counter 已经变成4
    console.log(lib.counter) // 3
    
    // 在当前模块直接修改简单数据类型的属性,不会影响导入模块
    lib.counter = 8
    // 修改复杂数据类型的属性的内容,会影响导入模块的数据
    lib.obj.count = 10
    console.log(`a.js-` + lib.counter, lib.obj.count) // 8 10
    

    main1.js 和 main2.js 执行的效果完全是一样的。

    循环依赖

    模块在第一次加载后会运行一次,然后将运行结果缓存到内存里。 这意味多次调用require(‘foo’)只会执行一次 foo 模块的代码,后面都是直接读取缓存的值。 这是一个重要的特性,借助它可以返回“部分完成”的对象,从而允许加载依赖的依赖。

    module.require 的源码涉及的知识点较多,webpack 打包模块时对 require 的处理能简单地体现缓存原理,我们来参考一下:

    // The require function
    function __webpack_require__(moduleId) {
      // 根据 moduleId 查找该模块是否存在于installedModules 中,
      if(installedModules[moduleId]) {
        // 如果存在直接读取模块的导出值,不会再初始化
        return installedModules[moduleId].exports;
      }
      // 初始化一个 module 对象并放入 installedModules中,并进行缓存 (cache)
      var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
      };
      // 执行模块
      modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
      // 做个标记表示该 module 已经加载过了
      module.l = true;
      // 返回模块对象的 exports
      return module.exports;
    }
    

    require 工作原理最核心的部分类似于这个webpack_require函数,执行 require() 的大致过程如下 (注意⚠️:补充结合了 module.require 的部分):

    1. 先检测传入的 moduleId 是否有效,必须是非空字符串。
    2. 如果无误,则调用主要负责加载新模块和管理模块缓存的 Module._load 方法,而 require 本身就是对该方法的一个封装。
    3. _load 方法内部,先调用 Module._resolveFilename 去获取文件地址。
    4. 判断是否存在该模块的缓存,如果有返回缓存模块的 exports 。
    5. 如果没有缓存,且不是核心模块,就创建一个新的 module/模块,并在 Module._cache 中缓存该模块对象。
    6. 尝试执行该模块的代码,如果报错,会清除该模块的缓存。
    7. 最后返回模块的输出对象 exports。

    根据这个机制,我们看下面两个互相依赖的模块 a 和 b:

    // 1. 加载 a 模块,会生成一个新 module 并放入缓存。
    // a.js
    let b = require('./b'); // 2. 运行 a.js:第一步加载 b 模块,新建一个 b module 放入缓存,并执行 b 的代码。
    // 6. b 得到值 { b: 'bbb' }
    module.exports = { // 8. 导出一个 module.exports  对象,指向全新的地址
      a: 'aaa'
    };
    // 7. 把 a 模块定时器代码放到任务队列
    setTimeout(() => {
      console.log(`a.js-${b.b}`); // 10. 输出 a.js-bbb
    });
    
    // b.js
    let a = require('./a'); // 3. 执行 b 马上要去加载 a,形成循环依赖。读取 a 模块缓存,返回值是初始空对象。
    module.exports = { // 5. 导出一个 module.exports  对象,指向全新的地址
      b: 'bbb'
    }
    setTimeout(() => { // 4. 把 b 模块定时器代码放到任务队列
      console.log(`b.js-${a.a}`); // 9. a为空对象,a.a 输出 b.js-undefined
    });
    
    console 输出
    1. 执行node a.js,a 模块相当于第一次加载,会生成一个新 module 并放入缓存。
    2. 首先去加载模块 b,新建一个 b module 放入缓存,并执行 b 的代码。
    3. 在执行 b.js 第一行时发现又要去导入 a,这里形成了循环依赖。然而 a 模块已经存在一个缓存了,就直接去取缓存的 a 模块的 module.exports 值。但 a 模块的代码并没有执行完全,所以 module.exports 还是个空对象 (初始值)。a 变量得到的就是一个空对象地址。
    4. b 模块继续执行。把 setTimeout 里的代码放到任务队列,等所有同步代码执行完毕后再执行。然后正常导出一个 { b: 'bbb' }
    5. 执行权回到 a,a 拿到了 b 导出的这个对象并赋值给 b 变量。跟着继续执行 a.js 的第二句,就是给 module.exports 赋值一个新对象 { a: 'aaa' }
    6. 最后按顺序执行定时器任务队列里的两个 console。b 模块拿到的 a 变量是空对象,这一点后面不会改变,于是 a.a 就是 undefined。而 a = { b: 'bbb' } 就没什么疑问了。

    但是这一输出的值没有达到我们的预期,因为用 module.exports 导出会让它指向一个全新的地址,也就是循环依赖拥有属性 a 的对象,跟 b.js 中拿到的对象并不是同一个。其实只要用 exports 挂载导出属性,而不是直接 module.exports = { ... },就能改变这一结果。接着看:
    步骤就直接写在注释里了,和上面相同的会简略些或跳过。

    // 1. 加载 a,缓存 a 
    // a.js
    let b = require('./b') // 2. 加载 b,缓存 b
    // 6. b 变量拿到 b 模块 exports 值 { b: 'bbb' },并且指向 b 模块 module.exports 同一引用地址
    console.log(`a1.js-${b.b}`) // 7. 输出 a1.js-bbb
    exports.a = 'aaa' // 8. 给 a 模块的导出对象添加一个 a 属性,值为 'aaa',此时 a 模块 module.exports 内容为 { a: 'aaa' }
    setTimeout(() => {
      console.log(`a2.js-${b.b}`) // 10. 读取 b 模块 module.exports 引用地址,输出 a2.js-bbb
    })
    
    // b.js
    let a = require('./a') // 3. 读取缓存 a 的 exports,a = {},指向 a 模块 module.exports 同一引用地址
    console.log(`b1.js-${a.a}`) // 4. 输出 b1.js-undefined
    exports.b = 'bbb' // 5. 给 b 模块的导出对象添加一个 b 属性,值为 'bbb'
    setTimeout(() => {
      console.log(`b2.js-${a.a}`) // 9. 此时读取 a 模块 module.exports 引用地址,已经有了a属性,输出 b2.js-aaa
    })
    

    由此可见,要正确地处理模块循环依赖关系,保证模块导出对象地址始终如一,只能用 exports.name1 = anything 这种形式。

    CJS如果在浏览器中运行,会有一个重大的局限:
    const math = require('math');
    math.add(2, 3);
    

    因为 require 是同步加载,第二行代码必须等第一行运行完才能执行。而加载math模块又得先把它里面的内容执行一遍。这对服务端不是一个问题,因为所有的模块都存放在本地硬盘。但浏览器端加载模块要向服务器去请求,等待时长取决于网速的快慢,那么这会是个很大的问题。

    下篇:require()、import、import()加载模块详解(二)

    参考:CommonJS 模块

    相关文章

      网友评论

          本文标题:require()、import、import()加载模块详解(

          本文链接:https://www.haomeiwen.com/subject/jenrdltx.html