源码角度分析Node模块加载
前言
在前端开发过程中Node无处不在,无论你是不是专业的Node开发工程师还是Vue、React等框架开发者,Node都无处不在。了解Node的模块加载对于你开发过程中会有很大帮助,例如:
<!-- A.js -->
class A {}
module.exports = A;
<!-- B.js -->
const A = require('A.js');
class B {}
<!-- C.js -->
const A = require('A');
class C {}
针对于上面的情况你可能疑惑几点,第一点A模块到底被加载了几次,第二点import A from 'A'和import A from 'A.js'是怎么加载到A的,第三点为什么A和A.js都可以加载到。。。。。
本文带你从源码角度解开模块加载面纱。
以下所有讨论函数都在Node源码的:node-master/lib/internal/modules/cjs/loader.js,为了方便理解我会屏蔽一些细节。
模块加载require函数
Node在导入包的时候使用require函数,require函数定义如下:
Module.prototype.require = function(id) {
validateString(id, 'id');
return Module._load(id, this, false);
};
例如我们require('A')的时候,首先A作为id传入require函数,判断id合法性,在合法的前提下调用Module._load()函数
Module._load函数
_load函数定义如下:
Module._load = function(request, parent, isMain) {
const filename = Module._resolveFilename(request, parent, isMain);
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
const mod = loadNativeModule(filename, request);
if (mod && mod.canBeRequiredByUsers) return mod.exports;
const module = new Module(filename, parent);
Module._cache[filename] = module;
module.load(filename);
return module.exports;
}
以上代码省略很多细节。
当调用_load函数的时候首先传入request就是require('A')的A,然后调用_resolveFilename得到filename,首先判断,判断步骤如下:
- 第一步:执行const cachedModule = Module._cache[filename];查看filename是否在缓存中也就是说是否之前加载过了,如果加载直接返回结果
- 第二步:执行const mod = loadNativeModule(filename, request);查看filename是否是原生模块(node的http、net、path模块等),如果是则返回结果。
- 第三步:执行const module = new Module(filename, parent);Module._cache[filename] = module;添加该模块到缓存然后调用module.load(filename);并加载该模块(下面详细讲解这部分加载过程),加载成功后返回。
关于_resolveFilename函数需要特别关注一下,了解它的细节你会知道为什么A和A.js都可以require成功。
Module._resolveFilename
_resolveFilename定义如下:
Module._resolveFilename = function(request, parent, isMain, options) {
let paths;
Module._resolveLookupPaths(request, parent);
const filename = Module._findPath(request, paths, isMain, false);
if (filename) return filename;
const requireStack = [];
for (let cursor = parent;
cursor;
cursor = cursor.parent) {
requireStack.push(cursor.filename || cursor.id);
}
let message = `Cannot find module '${request}'`;
const err = new Error(message);
err.code = 'MODULE_NOT_FOUND';
err.requireStack = requireStack;
throw err;
};
该函数当收到一个A参数后,首先它会调用_resolveLookupPaths函数这个函数会返回当前目录、父目录、父父目录。。。一直到根目录的node_modules的文件目录,在我的电脑上返回为:
paths = [
'/Users/xx/mygithub/this-spring-blog/node/1/node_modules',
'/Users/xx/mygithub/this-spring-blog/node/node_modules',
'/Users/xx/mygithub/this-spring-blog/node_modules',
'/Users/xx/mygithub/node_modules',
'/Users/xx/node_modules',
'/Users/node_modules',
'/node_modules'
]
也就是说当我们require('A')的时候不仅在当前目录下面找,还会去paths所有的node_modules去找,一直找到根目录。
将所有的paths找到后调用const filename = Module._findPath(request, paths, isMain, false);在这里会确定到底有没有filename,如果有就返回,如果没有就会执行下面的throw err。
Module._findPath函数定义如下:
Module._findPath = function(request, paths, isMain) {
for (let i = 0; i < paths.length; i++) {
const curPath = paths[i];
if (curPath && stat(curPath) < 1) continue;
const basePath = resolveExports(curPath, request, absoluteRequest);
let filename;
const rc = stat(basePath);
if (!filename) {
// Try it with each of the extensions
if (exts === undefined)
exts = ObjectKeys(Module._extensions);
filename = tryExtensions(basePath, exts, isMain);
}
}
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;
};
该函数在遍历paths过程中,假设你写的是require('A')那么在执行exts = ObjectKeys(Module._extensions);时候他会判断paths中每个路径下,是否存在A.js或者A.json或者A.node,这也就是为什么说我们写require('A')仍然可以准确加载A.js原因。需要说明一点Module._extensions在node的源码中定义为:
Module._extensions['.json'] = function(module, filename) {...}
Module._extensions['.js'] = function(module, filename) {...}
Module._extensions['.node'] = function(module, filename) {...}
所以ObjectKeys返回.node、.js、.json。当通过调用filename = tryPackage(basePath, exts, isMain, request);找到后就会返回filename。
Module.load函数
函数人定义如下:
Module.prototype.load = function(filename) {
const extension = findLongestRegisteredExtension(filename);
// allow .mjs to be overridden
if (filename.endsWith('.mjs') && !Module._extensions['.mjs']) {
throw new ERR_REQUIRE_ESM(filename);
}
Module._extensions[extension](this, filename);
this.loaded = true;
const ESMLoader = asyncESM.ESMLoader;
const url = `${pathToFileURL(filename)}`;
const module = ESMLoader.moduleMap.get(url);
const exports = this.exports;
ESMLoader.moduleMap.set(
url,
() => new ModuleJob(ESMLoader, url, () =>
new ModuleWrap(url, undefined, ['default'], function() {
this.setExport('default', exports);
})
, false /* isMain */, false /* inspectBrk */)
);
};
通过上面的了解_resolveFilename会提供一个filename,如果在缓存和原生模块都没有,那么就要去重新加载,这就是load的作用。
load函数拿到filename后首先会判断是.json、.node还是.js,然后调用 Module._extensionsextension;这个方法去编译,编译成功后导出该函数,并记录缓存模块。
这里以js为例看一下Module._extensionsextension函数实现:
Module._extensions['.js'] = function(module, filename) {
const content = fs.readFileSync(filename, 'utf8');
module._compile(content, filename);
};
拿到filename后通过fs模块读进内容通过调用_compile进行编译然后输出可用模块。
总结
node模块加载过程:
- 首先判断该文件是否存在,查的过程是遍历当前目录一直到根目录的node_modules是否存在A.json、A.js、A.node,当然如果你写拓展名了可能会直接按照你写的拓展名加载,这也是加快加载速度的一个方法。如果存在下一步,不存在报错(例如:Error: Cannot find module 'hhhttp')。
- 缓存模块是否存在,如果存在返回,不存在下一步
- 判断原生模块是否存在,如果存在返回,不存在下一步
- 调用._load方法加载该模块并加入缓存中
node模块加载过程:
- 缓存模块是一个非常好的设计,避免多次加载同时可以快速加载模块
- 通过直接写出拓展名可以加快模块加载速度
- 可以提供一个卸载模块逻辑,这样可以把不必要模块卸载减小内存开销。
github
github地址(记录所有学习和项目相关)