CommonJS模块源码解析

时间:2021-2-20 作者:admin

在 CommonJS 规范中,一个文件就可以作为一个独立的模块,有自己的作用域,在这个文件内部定义的变量、函数等,都只属于这个模块,对其他模块是不可见的。如果想要其他模块能使用其内部的变量,就需要使用 module.exports 导出,然后在其他模块中使用 require()导入。

因为 Node 就是 CommonJS 规范的一种具体实现,以下我们主要使用 Node 的 CommonJS 模块来讲解 CommonJS 规范下模块化的思维。


一、常见问题解析

这里我们先抛出一些问题,然后跟着问题,再一步步去解析 Node 的 CommonJS 模块机制:

1. Node中的CommonJS模块是啥,一个模块中包含哪些信息?

2. exports和module.exports有什么联系和区别?

3. 怎么判断当前模块是根模块还是子模块?

4. 多次引入同一个模块时,这个模块内部的代码是否会多次执行?

5. 两个模块循环引用时,是否会造成死循环?

6. 模块的引入是同步的还是异步的?

7. require默认支持导入哪些文件类型?

8. 模块中的exports、require、module、__filename、__dirname从哪儿来,为什么可以不定义就直接用?

9. 为什么说模块内部的变量对其他模块不可见?

1. CommonJS 模块(module)

Node 中,有一个构造函数:Module,我们所用的每一个模块,其实都是由这个构造函数 new 出来的一个module 实例,用变量module表示。Module 构造函数的源码如下:

function Module(id = "", parent) {
  // id通常传入的都是文件的绝对路径
  this.id = id;
  this.path = path.dirname(id);
  this.exports = {};
  // 这个 updateChildren 的作用,就是把创建出来的模块实例,添加到父模块的children列表中
  updateChildren(parent, this, false);
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

另外 Module 构造函数的原型上还有 3 个方法:

// 1. require:用于引入模块的
Module.prototype.require = function(id) {};

// 2. load:用于加载模块的
Module.prototype.load = function(filename) {};

// 3. _compile:用于编译、执行模块代码的
Module.prototype._compile = function(content, filename) {};

稍后我们会详解这 3 个方法。我们可以看到,当调用new Module()时,实例化出来的 module 模块,其结构大致如下:

module = {
  id: ".", // 模块id,通常为文件的绝对路径,根模块会被重置为'.'
  path: "", // 模块所在文件夹的路径
  exports: {}, // 导出的模块值,初始值为{}
  parent: {}, // 父模块(Node文档中说已弃用,不过实际的值还是可以获取到)
  filename: "", // 模块文件的绝对路径(这个属性值是等到加载模块的时候添加上来的)
  loaded: false, // 标识当前模块是否已经加载完毕
  children: [], // 子模块列表
  // paths的值,是从当前文件夹下,依次往上遍历,直到根目录,每级目录下的node_modules目录。
  //(当require(path)传入的是第三方模块的时候会用到,这个属性值也是在等到加载模块的时候添加上来的)
  paths: [
    // 'D:\\WEB_NOTES\\modules\\Commonjs\\node_modules',
    // 'D:\\WEB_NOTES\\modules\\node_modules',
    // 'D:\\WEB_NOTES\\node_modules',
    // 'D:\\node_modules'
  ],
  __proto__: {
    require: function(id) {},
    load: function(filename) {},
    _compile: function(content, filename) {},
  },
};

我们可以看到,在这个实例上,有一个exports属性,初始值为空对象,module.exports的值,就是我们最终导出的模块,然后在其他模块中使用require()导入时,module.exports就会被作为 require 函数的返回值返回出去。如下:

// moduleA.js
// 在此文件中导出模块
module.exports = {
  name: "I am moduleA",
};
// index.js
// 在此文件中引入模块moduleA
const moduleA = require("./moduleA.js");

// 输出模块moduleA的值,我们可以看到moduleA的值就是我们在moduleA.js中导出的对象
console.log("moduleA: ", moduleA); // moduleA:  { name: 'I am moduleA' }

2. module.exports 和 exports

用过 Node 的人,对这 2 个 api 应该都不会陌生,在实际使用场景中,这 2 个 api 的使用方式也是很容易混的,稍不注意就可能用错了,那么这 2 个 api 究竟有什么联系和区别呢?

其实在 Node 的源码中,有几行代码能很好的解释他们的关系:

// 1. 用 = 赋值,使 exports 成为 module.exports 的一份副本
// 其中`this`就是我们的 module 实例
const exports = this.exports; // 等价于 const exports = module.exports;

// 2. require函数的返回值如下(这里有做逻辑上的简化处理)
const require = function(id) {
  // ...
  return module.exports;
};

从上面我们就可以很清晰的看到: exportsmodule.exports都指向同一个地址,他们的初始值为一个空对象,但是要注意最终返回的是module.exports,这也就意味着实际使用场景中会有如下几个需要注意的地方:

  1. 因为 exports 与 module.exports 都是对象,所以我们使用 exports.key = valuemodule.exports.key = value 的形式给导出模块赋值,效果都是等价的,
// moduleA.js
module.exports.name = "I am moduleA";
exports.age = 20;
// index.js
const moduleA = require("./moduleA.js");
console.log("moduleA: ", moduleA); // moduleA:  { name: 'I am moduleA', age: 20 }

我们可以看到,name 和 age 的值都正常导出了。

  1. 因为require函数最终返回的是module.exports的值,所以如果我们使用了module.exports = newValue的形式给其重新赋了值,就会导致module.exportsexports的联系断掉。这时,如果我们还使用了exports.key = value的形式给导出模块赋值,就会导致exports.key的值丢失:
// moduleA.js
exports.age = 20;

module.exports = {
  score: 100,
};
// index.js
const moduleA = require("./moduleA.js");
console.log("moduleA: ", moduleA); // moduleA:  { score: 100 }

所以,如果我们要使用module.exports = newValue的形式导出模块,就不要再使用exports

  1. 不能使用exports = value的形式导出模块,不然也会导致module.exportsexports的联系断掉,致使exports = value的 value 值丢失:
// moduleA.js
exports = 20;

module.exports.score = 100;
// index.js
const moduleA = require("./moduleA.js");
console.log("moduleA: ", moduleA); // moduleA:  { score: 100 }

3. 怎么判断当前模块是根模块还是子模块?

在 require 函数上,有一个main属性,它指向了当前模块引用链上的根模块,通过require.main === module来判断,如果当前模块是根模块则返回 true,子模块返回 false。

4. 多次引用同一个模块(模块缓存)

在模块的引用机制内部,当一个模块成功加载一次之后,就会被写入缓存,具体缓存信息,可以打印require.cache查看;第二次加载的时候,就会直接从缓存读取数据,而不会再次加载、执行模块内部的代码。所以,多次引入同一个模块时,这个模块内部的代码不会多次执行。如下:

//  moduleA.js
module.exports.time = Date.now();
//  index.js
const moduleA1 = require("./moduleA");

console.log("moduleA1 = ", moduleA1);

console.log("require.cache = ", require.cache);

const moduleA2 = require("./moduleA");

console.log("moduleA2 = ", moduleA2);

// 上面的打印结果输出如下:
// console.log("moduleA1 = ", moduleA1);
moduleA1 = { time: 1606017634808 }

// console.log("require.cache = ", require.cache);
require.cache: {
  'E:\\WEB_NOTES\\modules\\Commonjs\\index.js': {
    id: '.',
    path: 'E:\\WEB_NOTES\\modules\\Commonjs',
    exports: {},
    parent: null,
    filename: 'E:\\WEB_NOTES\\modules\\Commonjs\\index.js',
    loaded: false,
    children: [ [Module] ],
    paths: [
      'E:\\WEB_NOTES\\modules\\Commonjs\\node_modules',
      'E:\\WEB_NOTES\\modules\\node_modules',
      'E:\\WEB_NOTES\\node_modules',
      'E:\\node_modules'
    ]
  },
  'E:\\WEB_NOTES\\modules\\Commonjs\\moduleA.js': {
    id: 'E:\\WEB_NOTES\\modules\\Commonjs\\moduleA.js',
    path: 'E:\\WEB_NOTES\\modules\\Commonjs',
    exports: { time: 1606017634808 },
    parent: Module {
      id: '.',
      path: 'E:\\WEB_NOTES\\modules\\Commonjs',
      exports: {},
      parent: null,
      filename: 'E:\\WEB_NOTES\\modules\\Commonjs\\index.js',
      loaded: false,
      children: [Array],
      paths: [Array]
    },
    filename: 'E:\\WEB_NOTES\\modules\\Commonjs\\moduleA.js',
    loaded: true,
    children: [],
    paths: [
      'E:\\WEB_NOTES\\modules\\Commonjs\\node_modules',
      'E:\\WEB_NOTES\\modules\\node_modules',
      'E:\\WEB_NOTES\\node_modules',
      'E:\\node_modules'
    ]
  }
}

// console.log("moduleA2 = ", moduleA2);
moduleA2 = { time: 1606017634808 }

我们可以看到,moduleA 第一次成功加载完成后,得到的 time 值是1606017634808,同时通过打印 require.cache 也可以看到,模块的结果已经添加到缓存中了。第二次再加载 moduleA 的时候,是直接从缓存中拿的数据,而没有重新执行 moduleA 中的代码module.exports.time = Date.now(),所以得到的结果还是1606017634808

5. 循环引用,是否会造成死循环?

不会造成死循环,这主要得益于模块的 2 个缓存优先原则

1. 当缓存中有当前模块的数据时,直接从缓存中拿值,而不会去加载、执行模块内部的代码;

2. 没有缓存时,创建了module实例后,会优先将module添加到缓存,其exports初始值为空对象({}),
   然后再去加载、执行模块内部的代码。

而会循环引用时,又回到需要加载第一个模块的时候,因为这个时候缓存中已经有了第一个模块的缓存值,所以会直接返回其缓存的值,而不会再去执行模块里面的代码,因此不会再造成第二次循环,也就没有死循环了。
我们来看一个例子:

// A.js
module.exports = "A1";
const moduleB = require("./B.js");
console.log("moduleB: ", moduleB);
module.exports = "A2";

// B.js
module.exports = "B1";
const moduleA = require("./A.js");
console.log("moduleA: ", moduleA);
module.exports = "B2";

// 以上 2 个模块,我们假设先加载的模块 A;
// 那么以上两个模块内部的打印,会先后输出:
// "moduleA: A1"
// "moduleB: B2"

6. 模块的引入是同步的还是异步的?

是同步的,模块内部机制是通过 Node 内置的 fs 模块下的 fs.readFileSync() 去同步读取的文件内容,读取完成后,也是同步执行的解析逻辑,所以,模块的引入是同步的,当需要引用一个模块时,会等到这个模块完全加载完成后,才会去执行引用模块后面的逻辑。

7. require 默认支持导入哪些文件类型?

require 默认支持.js/.json/.node的文件类型。

8. 模块中的 exports、require、module、__filename、__dirname 从哪儿来?

在 require 内部的解析逻辑中,通过 fs.readFileSync() 读取文件内容的时候,会把我们每个模块(文件)内部的代码全部转换为字符串,然后再外部用一个包装函数将其包裹,这个包装函数接收exports, require, module, __filename, __dirname作为参数,如下:

function compiledWrapper(exports, require, module, __filename, __dirname) {
  /*
    eval(content)
    // 注意:实际Node中用的并不是eval,而是借助了Node的vm模块,这里的eval只是为了便于理解
    // content就是我们的代码转换为的字符串
  */
}

因为模块代码在这个包装函数内部执行了,所以可以拿到这个函数所有的参数,即exports, require, module, __filename, __dirname等。

9. 为什么说模块内部的变量对其他模块不可见?

同上面一个问题的解释,因为模块内部的代码都是在包装函数内部执行的,因此模块内部变量都属于这个函数作用域,而且每一个模块都有这么一个独立的函数作用域,因此,他们之间的变量是不能互相访问的。

结语:我们在上面依次解答了最开始提出的每个问题,但是这些解答都只是说出了表面现象,如果你还想追本溯源,那么我们就一起跟着 CommonJS 模块的源码去一探究竟吧。


二、源码解读

1. require 的定义

我们在模块中使用require来导入其他模块,可是 require 函数到底是怎么样的呢,下面我就一起来跟着源码追本溯源一番:

// 在源码中对于require的定义,代码精简后,大致是如下这样的:
const require = makeRequireFunction(this, redirects);

// makeRequireFunction 函数的定义
function makeRequireFunction(mod, redirects) {
  let require;

  require = function require(path) {
    return module.require(path); // module就是我们的模块
  };

  // 然后在require函数上,还定义了几个有用的属性:
  // require.resolve用于根据传入的参数值,解析出模块的绝对路径。
  require.resolve = resolve(request, options) {
    return Module._resolveFilename(request, module, false, options);
  };

  // process.mainModule保存的是根模块,这里将require.main也指向了根模块
  require.main = process.mainModule;

  // Enable support to add extra extension types.(翻译:支持添加额外的扩展类型)
  // 这里原本是想要用来扩展require可以支持导入的文件类型,但是Node官方文档解释这个功
  // 能已经弃用了。
  require.extensions = Module._extensions;

  // Module._cache保存的是模块的缓存信息,这里将require.cache也指向了模块的缓存。
  require.cache = Module._cache;

  // 以上require函数上的每一个属性值都很重要,稍后我们会详解其具体作用。

  return require;
}

从上面可以看出,require 函数其实来源于 module.require 函数,第 2.1 节,我们将 module 实例的时候,大概提过,它实际来源于Module.prototype.require

Module.prototype.require = function(id) {
  validateString(id, "id");
  if (id === "") {
    throw new ERR_INVALID_ARG_VALUE("id", id, "must be a non-empty string");
  }
  requireDepth++;
  try {
    // 其他代码我们都可以暂时忽略,主要看这一句,这里又跳转到了Module._load函数
    return Module._load(id, this, /* isMain */ false);
  } finally {
    requireDepth--;
  }
};

2. require 的解析规则

2.1 三大解析规则

  规则 1:尝试读缓存
  规则 2:尝试加载本地模块
  规则 3:使用文件生成新的模块

从上面 require 的定义我们可以了解到,require 解析规则的逻辑主要在Module._load函数内部处理:

Module._load = function(request, parent, isMain) {
  // 源码中对于这个函数的功能做了注释,翻译过来大致如下:
  // 1. 如果模块已在缓存中:返回它的 exports 对象。
  // 2. 如果模块是本地模块:则找到它并返回它的 exports。
  // 3. 否则,为该文件创建一个新模块并将其保存到缓存中。然后加载、执行文件中的内容,最后再返回其 exports 对象。
  // 这三点就是 require 的三大解析规则
};

2.2 规则 1:尝试读缓存

Module._load = function(request, parent, isMain) {
  // 检查缓存
  let relResolveCacheIdentifier;
  if (parent) {
    relResolveCacheIdentifier = `${parent.path}\x00${request}`;
    const filename = relativeResolveCache[relResolveCacheIdentifier];
    if (filename !== undefined) {
      // 这里就是检查模块是否已经缓存,而其缓存的地方就是Module._cache下
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        // 如果有缓存,则将该模块添加到父模块的children列表下
        updateChildren(parent, cachedModule, true);

        // 我们前面讲模块的时候说过,loaded主要用来标识当前模块是否已经加载完成。
        // 在这里,模块既已缓存,但是又未加载完成,只有一种情况,那就是在模块加载的过程中,
        // 引用了另一模块,而另一个模块又引用了当前模块,由此形成了循环引用。
        // 所以,这里就是处理循环引用的。
        if (!cachedModule.loaded) {
          // (重点1)处理循环引用
          return getExportsForCircularRequire(cachedModule);
        }
        // 如果模块已经加载完成,则直接返回模块的exports对象。
        return cachedModule.exports;
      }
      delete relativeResolveCache[relResolveCacheIdentifier];
    }
  }

  // (重点2)解析文件路径:
  // 1. 如果是本地模块,则返回模块的名字
  // 2. 其他情况返回模块所在文件的绝对路径
  const filename = Module._resolveFilename(request, parent, isMain);

  // 再次检查缓存
  const cachedModule = Module._cache[filename];
  if (cachedModule !== undefined) {
    updateChildren(parent, cachedModule, true);
    if (!cachedModule.loaded) {
      const parseCachedModule = cjsParseCache.get(cachedModule);
      if (!parseCachedModule || parseCachedModule.loaded)
        return getExportsForCircularRequire(cachedModule);
      parseCachedModule.loaded = true;
    } else {
      return cachedModule.exports;
    }
  }

  // 如果没有缓存,再执行规则2...
};

这一步,会先尝试去读取 Module._cache 缓存的模块数据,如果有缓存,则返回缓存数据,否则就继续执行第 2 个规则(加载本地模块)。

在这一步中,有 2 个需要关注的点:“处理循环引用”和“解析文件路径”,请分别查看:处理循环引用 和 解析文件路径

2.3 规则 2:尝试加载本地模块

Module._load = function(request, parent, isMain) {
  // const filename = Module._resolveFilename(request, parent, isMain);

  // 如果没有缓存,则加载本地模块(Node内置模块)(重点1)
  const mod = loadNativeModule(filename, request);
  if (mod && mod.canBeRequiredByUsers) {
    return mod.exports;
  }

  // 如果没有本地模块,再执行规则3...
};

在这一步中,有 1 个需要关注的点:“加载本地模块的具体实现”,请查看:加载本地模块的具体实现。

2.4 规则 3:使用文件生成新的模块

Module._load = function(request, parent, isMain) {
  // const filename = Module._resolveFilename(request, parent, isMain);

  // 既没有缓存,也不是本地模块,则为该文件创建一个新模块,并将其添加到缓存中。
  // 关于new Module()的具体实现,我们前面已经讲过。
  const module = cachedModule || new Module(filename, parent);

  if (isMain) {
    // 如果是根模块,则将此模块保存进process.mainModule,
    // 我们前面讲解require的时候,有一句代码:require.main = process.mainModule;
    // 那么这里require.main = process.mainModule= module; 就算是连通了。
    // 回到我们前面提出的问题:怎么判断当前模块是根模块还是子模块?
    // 通过require.main === module判断,根模块返回true,子模块返回false
    process.mainModule = module;

    // 我们前面讲module的时候,说过:模块id通常为文件的绝对路径,根模块会被重置为'.',
    // 其原因就是来自于这里
    module.id = ".";
  }

  // 将module写入缓存 Module._cache 下
  // 我们前面讲解require的时候,有一句代码:require.cache = Module._cache;
  // 这里,就将require.cache 也指向了模块的缓存,这样做的原因是,我们在模块内部,拿不到
  // Module._cache,但是可以拿到require函数,于是,当我们需要手动清除缓存的时候,
  // 就可以使用delete require.cache[filename]的形式删除缓存,而在模块中,
  // 绝对路径filename就可以通过require.resolve(指向要删除模块的相对路径)来获取。
  Module._cache[filename] = module;

  if (parent !== undefined) {
    relativeResolveCache[relResolveCacheIdentifier] = filename;
  }

  let threw = true;
  try {
    // Intercept exceptions that occur during the first tick and rekey them
    // on error instance rather than module instance (which will immediately be
    // garbage collected).
    if (getSourceMapsEnabled()) {
      try {
        module.load(filename);
      } catch (err) {
        rekeySourceMap(Module._cache[filename], err);
        throw err; /* node-do-not-add-exception-line */
      }
    } else {
      // (重点)根据绝对路径加载模块,并执行模块内部代码,module.exports给赋值
      module.load(filename);
    }
    threw = false;
  } finally {
    if (threw) {
      // 这里要注意:
      // 因为我们是先写入了缓存,而后再执行的模块的加载,那么就有可能出现模块加载出错的
      // 情况(比如找不到模块)这时,就需要将之前写入的缓存清除,并从父模块的children列表
      // 中删除当前模块。
      delete Module._cache[filename];
      if (parent !== undefined) {
        delete relativeResolveCache[relResolveCacheIdentifier];
        const children = parent && parent.children;
        if (ArrayIsArray(children)) {
          const index = ArrayPrototypeIndexOf(children, module);
          if (index !== -1) {
            ArrayPrototypeSplice(children, index, 1);
          }
        }
      }
    } else if (
      module.exports &&
      !isProxy(module.exports) &&
      ObjectGetPrototypeOf(module.exports) ===
        CircularRequirePrototypeWarningProxy
    ) {
      ObjectSetPrototypeOf(module.exports, ObjectPrototype);
    }
  }

  // 返回module.exports,作为调用require函数时的返回值。
  return module.exports;
};

在这一步中,会先通过new Module()创建 module 实例,并将其进行缓存,然后去解析、执行文件内部的代码,如果在解析的这个过程中出现了错误,则会抛出错误,并将之前的缓存清除。

如何解析、执行文件内部代码的逻辑,请查看:加载文件模块。

2.5 总结

上面我们看了 require 的解析规则,了解到了 require 解析的三大步:

读缓存、加载本地模块、使用文件生成新的模块。

也解决了我们提出的几个问题:

1. 怎么判断当前模块是根模块还是子模块?
   解答:通过require.main === module判断,根模块返回true,子模块返回false

2. 多次引入同一个模块时,这个模块内部的代码是否会多次执行?
   解答:不会。当一个模块成功加载一次之后,就会被写入缓存,第二次加载的时候,
        就会直接从缓存读取数据,而不会再次加载、执行模块内部的代码。

同时,还留下了 4 个需要重点关注的地方,分别是:

  1. 处理循环引用
  2. 解析文件路径
  3. 加载本地模块的具体实现
  4. 加载文件模块

3. 处理循环引用

处理循环引用的逻辑其实很简单:

function getExportsForCircularRequire(module) {
  /*
    if (module.exports &&
        !isProxy(module.exports) &&
        ObjectGetPrototypeOf(module.exports) === ObjectPrototype &&
        !module.exports.__esModule) {
      ObjectSetPrototypeOf( module.exports, CircularRequirePrototypeWarningProxy);
    }
  */

  // 排除上面if中不重要的代码,其实处理循环引用的就剩下这一句,就是直接返回module.exports
  return module.exports;
}

我们可以看到,在遇到循环引用的时候,Node 是直接返回了模块的 module.exports 值。什么是这样?其实这要得益于模块的 2 个缓存优先原则

1. 当缓存中有当前模块的数据时,直接返回模块的exports值,而不会去加载、执行模块内部的代码;

2. 没有缓存时,创建了module实例后,会优先将其缓存,然后再去加载、执行模块内部的代码。

我们来看一个例子:

// A.js
module.exports = "A1";
const moduleB = require("./B.js");
console.log("moduleB: ", moduleB);
module.exports = "A2";
// B.js
module.exports = "B1";
const moduleA = require("./A.js");
console.log("moduleA: ", moduleA);
module.exports = "B2";

以上 2 个模块,我们假设先加载的模块 A,首次加载 A 时,A 没有缓存,所以根据上面的 2 个优先原则,会直接走到第 2 个原则:

1. 创建模块实例 moduleA,并将其缓存到 Module._cache 中;

3. 再去加载、执行moduleA内部的代码:
  (1)当执行到 module.exports = "A1",会把 "A1" 存入 module.exports 中;
  (2)当执行到 const moduleB = require("./B.js") 时,跳入 require 内部,加载 B.js ;

4. 创建模块实例 moduleB,并将其缓存到 Module._cache 中;

5. 再去加载、执行moduleB内部的代码:
  (1)当执行到 module.exports = "B1", 会把 "B1" 存入 module.exports 中;
  (2)当执行到 const moduleA = require("./A.js") 时,又会跳入require内部,
      去加载 A.js,这个时候会读取到 moduleA 已经存在于缓存中,于是从缓存中
      拿到 moduleA,并返回他的 exports 值,即 "A1",而不会再去加载、执行
      A.js中的代码,也就不会再次形成循环了。

  ** 有人或许会疑问,如果A.js中没有第1行的  module.exports = "A1" ,这个第5步
  会怎么样处理? 其实我们前面讲过,module.exports初始值是空对象({}),
  这里module.exports = "A1"只不过是重新赋了值,所以,有没有这一步,缓存其实
  都是存在了的,只不过就是缓存的值不同而已。

6. 执行完上一步之后,得到moduleA = "A1";

7. 执行 console.log("moduleA: ", moduleA); 输出 "moduleA: A1";

8. 继续给 moduleB 的 module.exports 赋值为 "B2";

9. B.js执行完,代表 moduleB 加载完成,回到 A.js 中,得到 moduleB = "B2";

10. 执行 console.log("moduleB: ", moduleB); 输出 "moduleB: B2";

11. 继续给 moduleA 的 module.exports 赋值为 "A2";

至此,两个模块间的循环引用逻辑都处理完成:从上面我们回答之前提出问题了:两个模块循环引用时,是否会造成死循环?

答案是:不会造成死循环,而会在第二次加载第一个模块的时候,直接返回其缓存的值,而不会再去执行模块里面的代码,因此不会再造成第二次循环,也就没有死循环了

4. 解析文件路径

Module._load函数内部,有一行代码const filename = Module._resolveFilename(request, parent, isMain),这行代码主要是解析文件路径的,下面我们就来看看,它究竟是如何解析我们在调用 require(path)时,传进来的路径的。

Module._resolveFilename = function(request, parent, isMain) {
  // 1. 判断从本地模块中是否可以找到需要的模块,如果可以找到,则直接把request返回
  //    比如在模块中引入path: const http = require('http'),那么这里会直接返回http
  if (NativeModule.canBeRequiredByUsers(request)) {
    return request;
  }

  // 2. 解析出在查找模块的过程中,需要解析的文件夹列表
  let paths = Module._resolveLookupPaths(request, parent);

  // 3. 根据request和paths解析并返回模块的绝对路径
  // Look up the filename first, since that's the cache key.
  const filename = Module._findPath(request, paths, isMain, false);

  // 如果解析出filename有值,说明模块可以找到,就返回该路径
  if (filename) return filename;

  // 到这里,说明没有找到模块,就会报错,以下主要是报错处理
  const requireStack = [];
  for (let cursor = parent; cursor; cursor = moduleParentCache.get(cursor)) {
    ArrayPrototypePush(requireStack, cursor.filename || cursor.id);
  }
  let message = `Cannot find module '${request}'`;
  if (requireStack.length > 0) {
    message =
      message +
      "\nRequire stack:\n- " +
      ArrayPrototypeJoin(requireStack, "\n- ");
  }
  // eslint-disable-next-line no-restricted-syntax
  const err = new Error(message);
  err.code = "MODULE_NOT_FOUND";
  err.requireStack = requireStack;
  throw err;
};

4.1 判断本地模块中是否可以找到需要的模块

NativeModule.canBeRequiredByUsers = function(id) {
  // NativeModule.map是一个数组,里面保存了Node的所有本地模块,
  // 通过get方法传入模块的id,就可以获取到模块
  const mod = NativeModule.map.get(id);
  return mod && mod.canBeRequiredByUsers;
};

4.2 解析 paths(查找模块的过程中,需要查找的文件夹列表)

Module._resolveLookupPaths = function(request, parent) {
  // 1. 这里的if判断,简言之就是如果request不是以'.'开头或者即使以'.'开头但不是相对路径的话,if判断就成立;
  // 这里的“以'.'开头但不是相对路径”,是指的类似于'.gitignore'、'.babelrc'这种文件类型。
  // (换言之,就是如果request不是相对路径,那么这个if判断都成立,比如是第三方模块'axios'、'webpack',
  // 或者绝对路径,如'/home/user'、'.gitignore'、'D:\\WEB_NOTES\\modules\\Commonjs'等)
  if (
    StringPrototypeCharAt(request, 0) !== "." ||
    (request.length > 1 &&
      StringPrototypeCharAt(request, 1) !== "." &&
      StringPrototypeCharAt(request, 1) !== "/" &&
      (!isWindows || StringPrototypeCharAt(request, 1) !== "\\"))
  ) {
    let paths = modulePaths;
    /* modulePaths是类似于下面这样的路径列表,主要用于查找全局安装的模块
       其中 $HOME 是用户的主目录, $PREFIX 是 Node.js 里配置的 node_prefix
      [
        "$HOME\.node_modules", 
        "$HOME\.node_libraries", 
        "$PREFIX\lib\node"
      ]
    */

    /* parent.paths 父模块所在目录,依次查找直到根目录,每级目录下的node_modules目录,
       主要用于查找局部安装的第三方模块
      [
        "E:\WEB_NOTES\modules\Commonjs\node_modules", 
        "E:\WEB_NOTES\modules\node_modules", 
        "E:\WEB_NOTES\node_modules", 
        "E:\node_modules"
      ]
    */
    if (parent != null && parent.paths && parent.paths.length) {
      // 把modulePaths和parent.paths通过数组的concat方法拼接到一起
      paths = ArrayPrototypeConcat(parent.paths, paths);
    }
    return paths.length > 0 ? paths : null;
  }

  // 2. 如果request是相对路径,则利用path.dirname解析出父模块的文件夹路径
  const parentDir = [path.dirname(parent.filename)];
  return parentDir;
};

从上面一段代码,我们就可以看出,解析 paths 的过程主要分为 2 种情况:

1. 如果request不是相对路径,那么就会去 全局模块的安装目录 或者 从父模块所在目录依次查找node_modules直到根目录;

2. 如果request是相对路径,就会解析出父模块所在目录,并返回(之后就会根据父模块目录加上相对路径去查找)。

4.3 解析模块的绝对路径

Module._findPath = function(request, paths, isMain) {
  // 判断request是否是绝对路径
  const absoluteRequest = path.isAbsolute(request);
  if (absoluteRequest) {
    // 如果是绝对路径,则不需要之前解析出来的paths了,因为绝对路径是可以直接用的,
    // 比如:'D:\\WEB_NOTES\\modules\\Commonjs\\moduleA.js'
    paths = [""];
  } else if (!paths || paths.length === 0) {
    return false;
  }
  // "\x00" 是空格:" "
  const cacheKey = request + "\x00" + ArrayPrototypeJoin(paths, "\x00");

  // 先读取缓存,如果路径有缓存,则直接返回,不需要再进行后面的解析工作,节省性能
  const entry = Module._pathCache[cacheKey];
  if (entry) return entry;

  let exts;

  // 判断request是否是以斜杠'/'结尾,用于判断request是否是一个目录
  let trailingSlash =
    request.length > 0 &&
    StringPrototypeCharCodeAt(request, request.length - 1) ===
      CHAR_FORWARD_SLASH;
  if (!trailingSlash) {
    // 判断request是否是以'.', '..', '/.', '/..'这样的样式结尾
    trailingSlash = RegExpPrototypeTest(trailingSlashRegex, request);
  }

  // For each path
  for (let i = 0; i < paths.length; i++) {
    // Don't search further if path doesn't exist
    const curPath = paths[i];
    if (curPath && stat(curPath) < 1) continue;

    if (!absoluteRequest) {
      const exportsResolved = resolveExports(curPath, request);
      if (exportsResolved) return exportsResolved;
    }

    // 将paths中的路径依次和request进行拼接,组成绝对路径
    const basePath = path.resolve(curPath, request);
    let filename;

    const rc = stat(basePath); // 用于判断basePath是否有文件后缀
    if (!trailingSlash) {
      if (rc === 0) {
        // rc === 0,则代表有文件后缀,路径可以直接使用
        // 这里再往深入,就涉及到底层C的代码了,所以不再深入了。
        if (!isMain) {
          if (preserveSymlinks) {
            filename = path.resolve(basePath);
          } else {
            filename = toRealPath(basePath);
          }
        } else if (preserveSymlinksMain) {
          // 这里主要是为了实现的一个向后兼容性
          filename = path.resolve(basePath);
        } else {
          filename = toRealPath(basePath);
        }
      }

      // 如果上面没有找到对应的文件,则会依次给basePath添加.js/.json/.node等扩展,再去查找
      if (!filename) {
        // Try it with each of the extensions
        if (exts === undefined) exts = ObjectKeys(Module._extensions);

        // 添加.js/.json/.node等扩展,再去查找
        filename = tryExtensions(basePath, exts, isMain);
        /* tryExtension 函数
          // Given a path, check if the file exists with any of the set extensions
          function tryExtensions(p, exts, isMain) {
            for (let i = 0; i < exts.length; i++) {
              const filename = tryFile(p + exts[i], isMain);

              if (filename) {
                return filename;
              }
            }
            return false;
          }
        */
      }
    }

    // 如果是目录,则读取目录内部的文件
    /* tryPackage主要做了以下3件事:
      * 1. 如果目录下有package.json文件,则会首先读取这个文件,并读取内部的main属性值;
      * 2. 如果main属性有值,则会使用path.resolve(basePath, main的属性值)拼接路径,
           然后使用拼接后的路径,重复类似于上面的放方法,去检测能否查找到对应的文件。
        3. 如果没有package.json,或者main属性没值,则会依次使用.js/.json/.node等后缀,
           去加载basePath路径下的index文件
    */
    if (!filename && rc === 1) {
      // Directory.
      // try it with each of the extensions at "index"
      if (exts === undefined) exts = ObjectKeys(Module._extensions);
      filename = tryPackage(basePath, exts, isMain, request);
    }

    if (filename) {
      // 将得到的绝对路径写入缓存,供下次直接从缓存读取,而不用多次解析
      Module._pathCache[cacheKey] = filename;
      return filename;
    }
  }

  return false;
};

上面的_findPath方法有点长,总结起来,大概就是干了以下几件事:·

1. 如果 request 是绝对路径,会丢弃 paths 列表

2. 解析request结尾,判断是否能直接断定request是一个目录;

3. 如果不能断定一定是目录,则先按照文件的规则解析:
  (1)使用 paths 内部的路径,拼接request,得到 basePath 作为路径去查找文件;

  (2)查找文件的时候,如果 basePath 路径有后缀,会直接使用basePath去尝试查找文件,
      如果没有找到,则会尝试依次使用 .js/.json/.node 等后缀拼接在其后,再次去尝试查找;

4. 如果断定是一个目录,则使用目录的规则去解析:
   (1)如果目录下有package.json文件,则会首先读取这个文件,并读取内部的main属性值;

   (2)如果main属性有值,则会使用path.resolve(basePath, main的属性值)拼接路径,
       然后使用拼接后的路径,重复类似于上面的放方法,去检测能否查找到对应的文件。

   (3)如果没有package.json,或者main属性没值,则会依次使用 .js/.json/.node 等后缀,
       去加载basePath路径下的index文件

5. 加载本地模块

const mod = loadNativeModule(filename, request);
if (mod && mod.canBeRequiredByUsers) return mod.exports;

// loadNativeModule的定义
function loadNativeModule(filename, request) {
  // NativeModule.map中保存着所有的本地模块
  const mod = NativeModule.map.get(filename);
  if (mod) {
    return mod;
  }
}

从上面的代码,我们可以看到,加载本地模块比较简单,就是根据模块 id,到保存本地模块的NativeModule.map中去获取模块,如果获取到了,说明模块存在,则直接返回。

6. 加载文件模块

6.1 Module.prototype.load

module.load(filename);

// module.load 函数
// Given a file name, pass it to the proper extension handler.
Module.prototype.load = function(filename) {
  // 如果模块已经加载完成了,此时再执行此函数加载模块,则会抛出错误。
  assert(!this.loaded);

  // 给模块赋值filename
  this.filename = filename;

  // 添加paths属性值,供加载第三方模块的时候使用。
  // paths的值,是从当前文件夹下,依次往上遍历,直到根目录,每级目录下的node_modules目录。
  // paths值类似于下面这样:
  /*
    [
      'D:\\WEB_NOTES\\modules\\Commonjs\\node_modules',
      'D:\\WEB_NOTES\\modules\\node_modules',
      'D:\\WEB_NOTES\\node_modules',
      'D:\\node_modules'
    ]
  */
  this.paths = Module._nodeModulePaths(path.dirname(filename));

  // 根据文件的绝对路径解析出文件的扩展名
  const extension = findLongestRegisteredExtension(filename);

  // 根据文件的扩展名,使用对应的方法加载文件
  Module._extensions[extension](this, filename);

  // 模块内部的代码加载执行完毕后,则将loaded设置为true,标识模块已经加载完毕。
  this.loaded = true;
};

上面最终通过Module._extensions[extension]去加载执行对应的模块,Module._extensions 默认有 3 个属性:.js,.json,.node,这 3 个也就是 require 默认支持可以导入的文件类型,这也就回到了我们前面提到的问题:

1. require默认支持导入哪些文件类型?
   解答:.js, .json, .node

6.2 Module._extensions[‘.js’]

1. 同步读取文件内容

// Native extension for .js
Module._extensions[".js"] = function(module, filename) {
  if (StringPrototypeEndsWith(filename, ".js")) {
    const pkg = readPackageScope(filename);
    // Function require shouldn't be used in ES modules.
    if (pkg && pkg.data && pkg.data.type === "module") {
      const parent = moduleParentCache.get(module);
      const parentPath = parent && parent.filename;
      const packageJsonPath = path.resolve(pkg.path, "package.json");
      throw new ERR_REQUIRE_ESM(filename, parentPath, packageJsonPath);
    }
  }

  // 使用fs.readFileSync同步读取文件内容,返回值为文件内容组成的字符串
  content = fs.readFileSync(filename, "utf8");
  // 编译文件内容
  module._compile(content, filename);
};

2. 同步编译、执行文件内容

Module.prototype._compile = function(content, filename) {
  let moduleURL;
  let redirects;
  // ***** 这里很重要:
  // wrapSafe函数返回了一个底层的包装函数,这个包装函数包裹了content,大概类似于下面这样:
  /* 
    function (exports, require, module, __filename, __dirname) { 
      eval(content) 
      // 注意:实际Node中用的并不是eval,而是借助了Node的vm模块,这里的eval只是为了便于理解
    }
  */
  // 这样一来,就使得我们写的模块内部的代码,都在这个包装函数内部执行了,也因此,其他模块
  // 才访问不到这个模块内部的变量
  const compiledWrapper = wrapSafe(filename, content, this);

  // 定义dirname变量
  const dirname = path.dirname(filename);

  // 定义require函数(又回到了 3.1节:require 的定义)
  const require = makeRequireFunction(this, redirects);
  let result;
  // 为module.exports创建副本exports
  const exports = this.exports;
  const thisValue = exports;
  const module = this;
  // 下面这里的调用,等价于 compiledWrapper.apply(thisValue, [ exports, require, ...])
  // 这里的调用很关键:
  // 这里把 exports, require, module, filename, dirname 都传入了 compiledWrapper 函数,
  // 让我们上面得到的compiledWrapper函数大致如下:
  // function (exports, require, module, __filename, __dirname) { eval(content) }
  // 而content就是我们模块内部的代码,于是一一对应,就能明白我们在模块内部写的exports, require,
  // module, __filename, __dirname等变量是从哪里来的了,以及为啥不定义就可以直接使用。
  result = ReflectApply(compiledWrapper, thisValue, [
    exports,
    require,
    module,
    filename,
    dirname,
  ]);
  return result;
};

根据上面 2 步读取文件内容以及执行文件内容可以看出,模块的引入是同步执行的。现在可以回答我们最开始提出的一些问题了:

1. 模块的引入是同步的还是异步的?
   解答:同步

2. 模块中的exports、require、module、__filename、__dirname从哪儿来,为什么可以不定义就直接用?
   解答:来自于执行模块代码的时候,在外层添加的一个包装函数,这个包装函数接收exports、require、
   module、__filename、__dirname这些参数,而模块代码在这个包装函数内部执行,所以可以直接拿到这些值。

3. 为什么说模块内部的变量对其他模块不可见?
   解答:因为模块内部的代码,都是在一个包装函数内部执行的,里面所有的变量都属于这个函数内部的作用域,
   所以其他模块是访问不到的。

6.3 Module._extensions[‘.json’]

// Native extension for .json
Module._extensions[".json"] = function(module, filename) {
  // 同步读取json文件的内容
  const content = fs.readFileSync(filename, "utf8");

  try {
    // 尝试把JSON字符串解析为对象,并赋值给module.exports
    module.exports = JSONParse(stripBOM(content));
  } catch (err) {
    err.message = filename + ": " + err.message;
    throw err;
  }
};

6.4 Module._extensions[‘.node’]

// Native extension for .node
Module._extensions[".node"] = function(module, filename) {
  if (policy?.manifest) {
    const content = fs.readFileSync(filename);
    const moduleURL = pathToFileURL(filename);
    policy.manifest.assertIntegrity(moduleURL, content);
  }
  // Be aware this doesn't use `content`
  return process.dlopen(module, path.toNamespacedPath(filename));
};
声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。