从一道面试题开始,让你彻底理解 module.exports 的本质

84 阅读8分钟

问: 当执行 node index.js 时(代码如下),控制台输出什么?

//index.js 文件

console.log(this === module.exports);
exports.a = 1;
module.exports.b = 2;
module.exports = {
  c: 3,
};
this.d = 40;
console.log(this === module.exports);

如果上面的答案你目前还不可以很快的回答上来,那么本文会带你实现一个简易版的 require 函数,让你清晰理解 commonjs 模块化的本质。

一. 体会 commonjs 模块化的思想

  1. 先看一段代码:

    // name.js
    const name="韩振方"
    module.export=name
    
  2. 先不看文件内容本身,就单从这个 module.export 语句来看,一开始我就感到很奇怪。我们都知道,在一个 js 文件中要想使用变量,那么这个变量要么是定义好的,要么是全局对象上的一个属性。什么意思?当我运行下面代码时,控制台肯定是会报错的,理所应当,因为 fancy 这个变量我压根就没声明。

    fancy.export= "韩振方"
    

    image.png

  3. 所以我们就能想到,module 可能会是一个全局对象上的属性,所以我才可以直接使用,于是我们写下了下面的代码,想在控制台看看 module 的真面目到底是什么。结果出乎意料,这个属性竟然是 undefined !

    console.log('module', globalThis.module);
    

    image.png

  4. 但如果我们从逆向思维去思考的话🤔,其实可以很快就否定 module 是全局对象属性这样的猜想。假如 module 真的是全局变量上的一个普通属性 globalThis.module,那么各个模块之间对 module 的赋值,不就相当于一层一层的替换覆盖全局变量吗?但是我们模块化本身期望的是隔离各个文件之间的变量,使其保持相对独立,互不干扰。

  5. 上面的 module 具体是什么,我们暂时按下不表。回顾一下 js 中最创建一个最简单作用域的方法就是一个独立的{},像这样:

    const a =10
    //括号内部的变量是独立的一个作用域
    {
    const a=20
    console.log('括号内部',a)
    }
    
    console.log('扩后外部',a)
    
    // 先后输出 20 10 
    
  6. 这样的做法是做到了变量之间的相互隔离,但是我们也失去了对括号内部变量的访问手段。当我们后续代码逻辑期望获取括号内部变量时,会发现无从下手。

  7. 于是我们继续思考是否还有别的方法🤔,聪明的你会马上就会想到函数,我们可以通过调用函数获取返回值的方式,来访问函数作用域内部的变量。 const a = 10;

            function islolated() {
              const a = 20;
              return a;
            }
            
            const _a = islolated();
    
            console.log('_a', _a);    
            console.log('a', a);
    
            // 先后输出 20 10 ,但我们可以在外部获取独立作用域内部的变量了
    
  8. 此时最核心的问题出现了,文件内部的数据倒是相互隔离了,但别的文件该如何引用这个文件的数据呢?目前我准备了两个文件 name.jsindex.js,在 index 中,我们需要通过某种方式来想办法获取到这个 name.js 中的 name 属性,现在我们解决的就是这个某种方式该如何来实现。

    // name.js
    const name="韩振方"
    
    -----------------文件分割线---------------------
    
    //index.js
    const name = <通过某种方式获取到 name.js 的内容>
    console.log(name)
    
  9. 假设我们现在没有拆分 name.jsindex.js,我们直接把 name.js 的内容复制粘贴到 index.js,然后结合上面函数的作用域概念,可以写出下面的代码:

    function getName() {
    const name = '韩振方';
    return name;
    }
    
    const name = getName();
    console.log('name', name);
    
  10. 那么我们的某种方式就可以这样去设计,假设我现在有这样一个函数,它可以读取一个 js 文件内容,然后帮我执行它,然后获取它的返回值,并且重新返回一个新的返回值,这个返回值的内容就是刚刚读取到的 js 文件的返回值。 function someMethod() { // 1.读取 name.js 文件内容 // 2.执行文件代码 // 3.获取 name.js 返回值信息 // 4.重新返回 }

                        const name = someMethod();
                        console.log('name', name);
    
  11. 这其实就是 commonjs模块化的核心思想,它其实是将每个 js 文件放到了一个函数的作用域中,从而实现了各个文件中的变量声明相互隔离。为了验证这一点,我们可以在在 name.js 增加了一条打印语句去打印 arguments 对象,可以看到这个变量竟然有值。

    //name.js
    console.log(arguments) //直接打印 arguments 对象
    

    image.png 你可要知道这个变量是一个关键字,且只会出现在函数环境里。 image.png

  12. 看到这里还是有点不解?没关系,接下来我们会在 index.js 文件里实现这个 someMethod 函数,届时上面所有的问题都将揭晓。

二. someMethod 函数的实现

  1. 我们假设已经拿到了 name.js 的代码信息。(内容比较简单,简化通过 fs 获取过程)定义一个变量 codename.js 的内容作为字符串保存,然后使用 eval 函数去执行这段代码。 function someMethod() { // 1.读取 name.js 文件内容 // 2.执行文件代码 // 3.获取 name.js 返回值信息 // 4.重新返回

          //这是 name.js 的代码,我们将里面的代码整体视为字符串
          const code = `
          const name='韩振方';
          console.log('name',name);
          `;
        
          const result = eval(code);
          console.log('result',result) // 输出 name.js 代码的返回结果
          return result;
        }
    

    image.png 可以看到控制台正确输出了 name,但是 result 却为 undefined,这是因为name.js 目前是没有返回值的,我们还需要进一步改进。

  2. 整个 commonjs 最核心的思想就是下面这一步,首先定义一个中间变量 result,然后我们定一个协议:

    如果你这个函数需要提供给别的文件一些数据,那么你需要按照约定,使用我提前给你准备好的变量,去修改它的属性。且变量名不能做任何修改,约定的是什么就是什么。

    image.png

    第一步,我们在外部提前定义了一个变量 result,然后在 name.js 的内部,我们为 result 增加了一个属性值 namename.js 内部是按照约定知道外部一定会给我提供一个 result 这样一个变量,所以才可以大胆的去修改。此时控制台的输出,就可以正确获取到 result的值。

    image.png

  3. 假设我们将 result 换成 module.exports,是否感觉有点熟悉了呢?这其实就是 require 函数的简易版本,在 commonjs 中,module 其实就是和上面 result 一样,就是一个普通对象而已,只不过在这个对象上有很多其它属性,来为模块化提供更多的细节(我们暂且不分析,只关心它的 exports 属性)。下一节我们进一步分析 require 真正的实现思路。

三. require 函数的实现

  1. 我们看一下 require 本身的使用方式

    //name.js
    const name='韩振方'
    module.exports = name // 按照约定,我们修改 module.expoert 变量的属性
    
    -------------------文件分割线---------------
    
    index.js
    const moduleName = require('./name'); //然后获取 require 函数的返回值,得到 name.js 的内容
    
    
  2. 接下来我们实现一个简易版的 myRequire 函数,届时你会完全理解面试题的所有问题,第一步我们需要借助 node 本身读取文件的能力,引入 pathfs 内置模块,去获取 name.js 的文件内容。

    //index.js
    const path = require('path');
    const fs = require('fs');
    
    function myRequire(filePath) {
      const abPath = path.resolve(__dirname, filePath);
      const code = fs.readFileSync(abPath, 'utf-8');
      console.log('code', code); // 打印 name.js 的代码内容
    }
    
    const moduleName = myRequire('./name.js'); // 打印 name.js 的输出结果
    console.log('moduleName', moduleName);
    

    moduleName目前是 undefined 是因为我们还没有传递约定值给模块 name.js image.png

  3. 在这里我们定一个了一个内部函数 _run,这个函数有三个参数,一个是 exports,一个是 myRequiere 函数本身,因为 name.js 有可能引用别的模块,最后一个是 module 变量。 image.png

    特别注意⚠️: 在这里你需要理解 commonjs 模块中的 moduleexports 就是一个普普通通的变量而已,不要因为他们是关键字就对它们有误解,它们真的就是一个普通的 obj 对象而已。

    由于在 name.js 内部按照提前的约定,我们修改了 module.exports 的变量属性, 此时控制台的输出,可以看到,我们在 index.js 中已经获取到了 name.js 使用 exports 传递的属性。 image.png

  4. 最后一步,将使用 call 来将 this 指向 exports 这个变量,再加上我们常用的 __dirname__filename,这两个变量本身就是 node 环境下可以直接访问的变量。于是一个简易的 require 函数就实现了。

    const path = require('path');
    const fs = require('fs');
    
    function myRequire(filePath) {
      const abPath = path.resolve(__dirname, filePath);
      const code = fs.readFileSync(abPath, 'utf-8');
    
      const module = {
        exports: {},
      };
      const exports = module.exports;
      
      function _run(exports, require, module, __dirname, __filename) {
        eval(code);
      }
      _run.call(exports, exports, myRequire, module,__dirname, __filename); //使用 call 将 this 指向 exports
      
      return module.exports; //返回 module.exports ,并不是 exports(!!! 注意,这里返回的是 module.exports,这一点非常关键)
    }
    
    const moduleName = myRequire('./name.js');
    console.log('moduleName', moduleName);
    
    

四. 解答面试题

```js
console.log(this === module.exports);
//最开始 this === exports === module.exports 三者都指向同一个变量 为 true

exports.a = 1;
// 为 exports 增加 a 属性,此时 this.a === exports.a === module.exports.a 为 {a:1}

module.exports.b = 2;
// 和上面为 a 赋值一样的操作,此时 module.exports === exports = this 为 {a:1,b:2}

module.exports = {
  c: 3,
};
//!!!注意:经过上面的操作, module.exports 已经指向了一块新的内存空间,已经不再是我们通过参数传递进来的那个内存空间对应的变量了。  

this.d = 40;
//但是这个 this 修改的是还是原来通过参数传递的那块内存空间,此时 this === exports 为{a:1,b:2,d:40}

console.log(this === module.exports)
//两块内存空间,自然不相等  false

```

五. 思考题

上面的代码通过 require 函数引用后,得到的 result 结果为?

const result = require("./思考题.js")
console.log(result) // 结果为?