在 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; };
从上面我们就可以很清晰的看到: exports
与module.exports
都指向同一个地址,他们的初始值为一个空对象,但是要注意最终返回的是module.exports
,这也就意味着实际使用场景中会有如下几个需要注意的地方:
- 因为 exports 与 module.exports 都是对象,所以我们使用
exports.key = value
和module.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 的值都正常导出了。
- 因为
require
函数最终返回的是module.exports
的值,所以如果我们使用了module.exports = newValue
的形式给其重新赋了值,就会导致module.exports
和exports
的联系断掉。这时,如果我们还使用了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
。
- 不能使用
exports = value
的形式导出模块,不然也会导致module.exports
和exports
的联系断掉,致使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 个需要重点关注的地方,分别是:
- 处理循环引用
- 解析文件路径
- 加载本地模块的具体实现
- 加载文件模块
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)); };