一道Commonjs的笔试题,让我彻底搞懂require机制!

279 阅读5分钟

前言

Hello~大家好。我是秋天的一阵风

最近在复习nodejs相关知识的时候碰到一道面试题让我虎躯一震。对于commonjs,以前只是零零散散记得它的一些特点,比如:

  • CommonJs有缓存,每次require才去加载,已经加载过的不再加载,
  • CommonJs是同步加载的,AMD和CMD是异步加载的.
  • module.exports = value; 一般是整个对象一起赋值: module.exports = {},
  • exports.xxx =value 一般是赋值单个变量:exports.name = name
  • exports.xxx = value 是一种简便写法,不能够直接赋值一个对象

但是对于commonJs的原理并没有去深入的理解,比如为什么commonJs会有缓存,为什么可以有两种导出方式?使用exports时为什么不能直接赋值一个对象呢?

其实这几个问题是非常值得我们花时间去探究的,现在刚好遇到这道非常经典的面试题,借这个契机,让我们来彻底弄懂其中的答案,好了,话不多说,我们来看看这道面试题。

先留给大家两分钟时间,请同学们开始思考下在node环境下,index.js文件中会打印什么呢?

// 1.js
this.a = 1;

exports.b = 2;

exports = { c: 3 };

module.exports = { d: 4 };

exports.e = 5;

this.f = 6;

// index.js

const r = require("./1.js");
console.log(r);

要想知道最终答案,其实关键在于这个require函数,只要知道require函数内部做了什么,答案就清晰了。

我们来简单看下require函数的实现。

一、require函数伪代码

function require(modulePath) {
  //1.根据传递的模块路径,得到模块完整的绝对路径
  var moduleId = getModuleId(modulePath);
  //2. 判断缓存
  if (cache[moduleId]) {
    return cache[moduleId];
  }
  //3.真正运行模块代码的辅助函数
  function _require(exports, require, module, __filename, __dirname) {
    //目标模块的代码在这里
  }

  // 4. 准备并运行辅助函数
  var module = {
    exports: {},
  };

  var exports = module.exports;

  // 得到模块文件的绝对路径
  var __filename = moduleId;
  // 得到模块所在目录的绝对路径
  var __dirname = getDirName(__filename);
  _require.call(exports, exports, require, module, __filename, __dirname);

  // 5. 缓存 module.exports
  cache[moduleId] = module.exports;
  // 6. 返回 module.exports
  return module.exports;
}

二、运行步骤分析

  1. 首先require函数会接受一个modulePath参数,也就是我们传入的文件路径"./1.js"。根据这个文件路径得到一个唯一的模块ID,这个ID是什么呢?其实就是这个模块的完整绝对路径。

  2. 根据模块ID判断cache中是否已经存在,如果缓存中已经有了,就直接返回缓存结果。这也就解释了文章开头我们提出的第一个问题:为什么commonjs是有缓存的。

  3. 接下来就是运行函数了,怎么运行呢?其实就是将1.js文件中全部代码放到_require函数里面去执行,也就是下面的形式:

  function _require(exports, require, module, __filename, __dirname) {
    //目标模块的代码在这里
        this.a = 1;

        exports.b = 2;

        exports = { c: 3 };

        module.exports = { d: 4 };

        exports.e = 5;

        this.f = 6;
  }

好奇的同学可能会提问:怎么证明1.js文件里的代码是在函数里面运行的呢?

我们可以在1.js里面打印 argumentsarguments只有在函数内部才有值。

// 1.js
console.log(arguments)
this.a = 1;

exports.b = 2;

exports = { c: 3 };

module.exports = { d: 4 };

exports.e = 5;

this.f = 6;

打印结果:

image.png

  1. 好了,我们继续探究,这个_require函数在执行的时候还有传入5个函数分别是:exports, require, module, __filename, __dirname

你可以打印arguments的长度来验证:

// 1.js
console.log("arguments.length: ",arguments.length) //arguments.length:  5
this.a = 1;

exports.b = 2;

exports = { c: 3 };

module.exports = { d: 4 };

exports.e = 5;

this.f = 6
  1. 第一个参数exports是不是很熟悉呢? 这也就是为什么我们在js文件中可以直接使用exports的原因,比如:
// 1.js

exports.b = 2;

exports = { c: 3 };

exports.e = 5;

  1. 第二个参数require大家应该也会熟悉,这也就是为什么我们在js文件中可以直接使用require进行导入的原因,比如:
// index.js
const r = require("./1.js");
console.log(r);

至于剩下的三个参数:module, __filename, __dirname,我们就不再赘述了。

三、函数执行

我们继续探究,到了最关键的一步,也就是运行辅助函数,它会传入一个module对象,这个module对象有一个属性exports,值也是一个对象。除此之外,还会将这个exports单独提出来,并且获取模块文件的绝对路径__filename和模块所在目录的绝对路径__dirname,最后通过call方法指定exports对象为this来执行。

  // 4. 准备并运行辅助函数
  var module = {
    exports: {},
  };

  var exports = module.exports;

  // 得到模块文件的绝对路径
  var __filename = moduleId;
  // 得到模块所在目录的绝对路径
  var __dirname = getDirName(__filename);
  _require.call(exports, exports, require, module, __filename, __dirname);

  // 5. 缓存 module.exports
  cache[moduleId] = module.exports;
  // 6. 返回 module.exports
  return module.exports;

我们来在1.js文件中打印一些log进行验证:



console.log("module.exports: ",module.exports) // {}

console.log("exports === module.exports: ",exports === module.exports) // true
console.log("this === exports: ",this === exports);                    // true
console.log("this === module.exports: ",this === module.exports)       // true

this.a = 1;

exports.b = 2;

exports = { c: 3 };

module.exports = { d: 4 };

exports.e = 5;

this.f = 6;

image.png

可以看到,module.exports在一开始是个空对象,而this指向跟exportsmodule.exports是完全相等的。

四: 回到笔试题中

好了,搞清楚了具体的执行逻辑,我们现在重新回到笔试题中来尝试寻找答案。

this.a = 1;

exports.b = 2;

exports = { c: 3 };

module.exports = { d: 4 };

exports.e = 5;

this.f = 6;

一开始的时候,thisexportsmodule.exports都是空对象 this = exports = module.exports = {}

  1. this.a = 1;后变成:
this =  exports =  module.exports = { a:1 }

2. exports.b = 2;;后变成:

this =  exports =  module.exports = { a:1, b:2 }

3. exports = { c: 3 };,关键的来了,这里是给exports对象重新赋值了

this =  module.exports = { a:1, b:2 }

exports =  { c: 3 }

这个时候exportsthismodule.exports已经不相等了。

  1. module.exports = { d: 4 };,同样的,module.exports也被重新赋值了
this = { a:1, b:2 }

exports =  { c: 3 }

module.exports = { d: 4 }

5. exports.e = 5; 后变成:

this = { a:1, b:2 }

exports =  { c: 3, e:5 }

module.exports = { d: 4 }

6. this.f = 6; 后变成:

this = { a:1, b:2, f:6}

exports =  { c: 3, e:5 }

module.exports = { d: 4 }

我们可以打印一些结果验证:

image.png

  1. 最后的最后,一定要注意最后一句导出的代码:
  // 伪代码
  // ..
  // 6. 返回 module.exports
  return module.exports;

返回的不是this,也不是exports,而是 module.exports

所以最终的答案就显而易见了:

const r = require("./1.js");
console.log(r);// { d: 4 }