在 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. 为什么说模块内部的变量对其他模块不可见?
10. 调用require(id)时,传入的id的解析规则?
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. 为什么说模块内部的变量对其他模块不可见?
同上面一个问题的解释,因为模块内部的代码都是在包装函数内部执行的,因此模块内部变量都属于这个函数作用域,而且每一个模块都有这么一个独立的函数作用域,因此,他们之间的变量是不能互相访问的。
10. 调用require(id)时,传入的id的解析规则?
-
首先通过id判断是否是本地模块(Node内置模块),如果是本地模块,则会直接返回本地模块;
-
判断id是否是相对路径,根据不同规则去定位模块:
-
如果不是相对路径,则又分为2种情况:
绝对路径:会直接使用这个绝对路径去查找模块;
第三方模块:会从文件所在目录依次往上查找,直到根目录,查找每层目录下的node_modules文件夹,如果能找到对应模块,则返回其绝对路径;
-
如果id是相对路径:则会使用类似path.resolve(__dirname, id)的形式得到id对应模块的绝对路径,并使用这个绝对路径去查找模块。
- 在上一步的根据路径查找模块时,会先判断该路径是否能断言为一个文件夹:
-
如果不能断言为文件夹,则会先当成文件解析,如果路径结尾没有后缀,则会依次加上后缀
.js/.json/.node去解析. -
如果能断言为文件夹,则会去加载文件夹下的package.json文件,读取其内部的main属性值,根据main属性值的路径再去查找模块。如果没有package.json或者main属性没值,则会依次使用
.js/.json/.node等后缀去查找文件夹下的index文件。
结语:我们在上面依次解答了最开始提出的每个问题,但是这些解答都只是说出了表面现象,如果你还想追本溯源,那么我们就一起跟着 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));
};