前言
一直以来,都想好好系统的分析分析nodejs
源码,既能帮助我们在运用nodejs
编写代码时更加游刃有余,也算是对编写好代码方法的一种学习,所以接下来就想以nodejs源码为出发点,写一些关于nodejs周边的系列,会包括nodejs本身及包管理工具npm
的原理,也会包含一些依赖nodejs而实现的前端热门工具,如webpack
、babel
等。
平常工作太忙而疏于写文章总结沉淀,在2021新年之际,是该好好写一写了。接下来这篇文章会以node index.js这个我们非常熟悉的命令来揭开nodejs模块机制的实现原理。
首先,众所周知,nodejs实现了CommonJS模块规范,而browser并没有,在nodejs内部存在一套完善的模块机制。作为和browser一样的js运行时宿主,这套模块机制极大的方便了外部js模块开发和使用。我们都知道nodejs的模块机制是通过exports
导出内部变量或方法,然后在另外模块中通过require
引入,那nodejs内部到底是怎么通过exports导出,又是怎么通过require来引入其他模块的呢?今天我们就来通过node index.js 这个例子来分析nodejs源码是怎么一步步实现模块机制的,话不多说,我们马上开始!
内部模块机制
首先,我们知道nodejs是通过c++
写的,在build之后其内部已经内建了很多模块,包括我们熟悉的path路径解析
、fs文件系统
、http网络模块
等。同时我们来看node源码的目录结构,src下都是核心模块的c++源码实现,在lib下会存在基于c++实现的对应核心模块的js实现,这些js 核心模块在build之后会被编译成二进制文件,在nodejs bootstrap
之后,这些js核心模块就会被加载到内存,因此对于这些模块的引用是非常快的。 既然nodejs是通过c++写成的,那我们要搞明白node index.js这行命令的整个内部执行逻辑,我们就要直切重点,找到它的main方法,也就是在src/node_main.cc
下。从下图可以看出main方法里主要是做了一些初始化工作,比如初始化所有的signal处理器及标准输入输出流,然后才进入到了真正的入口Node::Start
,
也就是在node.cc下的Start方法,这Start方法里主要做了以下几件事情:
创建NodeMainInstance实例
- 在
NodeMainInstance
构造器中,创建V8运行时Isolate
实例并注册到对应platform上,同时设置Microtasks策略 - 并将基础环境信息env_info传入Run方法,这里的env_info里主要包括native_modules标识符列表等配置信息。
执行NodeMainInstance::Run
- 锁定Isolate实例,只用于当前线程,同时设置不可修改
- 创建Isolate执行主作用域,类似于browser的全局作用域
- 创建主运行时环境
CreateMainEnvironment
,初始化运行时执行上下文InitializeContextRuntime
,同时设置诸如原型链原型变量名称__proto__
等js语法层面的相关名称
加载执行环境LoadEnvironment
- 初始化
libnv
,初始化诊断工具 - 根据
process.argv[1]
来选择特定的StartExecution
,以node index.is
为例,此时process.argv[1]为"index.js",则会进到StartExecution(env, "internal/main/run_main_module")
,即通过加载内部核心js模块internal/main/run_main_module.js来执行具体的处理逻辑,通过这个内部核心js模块的名字我们也可以猜到这个模块是做什么用的,run_main_module,没错,就是nodejs执行外部模块的入口所在,因为nodejs在执行外部模块的时候会实例化一个MainModule。
执行ExecuteBootstrapper,开始真正的执行逻辑
-
通过
NativeModuleLoader
查找并编译LookAndCompile。上面说过,内部核心js模块在nodejs源码被build之后已经被编译成二进制文件,这里会给internal/main/run_main_module.js文件标识加上node:
字符串来告知NativeModuleLoader内部模块加载器当前正要查找的模块是一个内部js模块,然后会直接从内存中加载对应的binary二进制文件内容。然后依赖V8运行时Isolate实例对其进行组装编译,整个过程如上面图中红框里的调用栈过程所示,核心步骤GetWrappedFunction
中,结合获取到的internal/main/run_main_module模块的ScriptDetail脚本数据,再配合注入以下一系列占位符字符串: -
process
变量名 -
require
方法名,此处的reqire方法更应该叫做native_module_require,是有别于我们自定义js模块里拿到的require方法,这个native_module_require事实上是nodejs内部模块引用的基础实现,而我们自己写的自定义js模块里的require事实上是在native_module_require上做了一层封装,后面会着重介绍,先按下不表 -
internal_binding
方法名,该方法是用于引用nodejs内部基于c++实现的核心模块的加载器方法,当我们编写自定义核心模块来对nodejs进行扩展时,外部模块就可以通过这个方法引用新添加的核心js模块,在内建的js核心模块中这个方法的名称是getInternalBinding -
primordials
变量名,这个变量是nodejs内部对原生js众多类型构造器/方法的一个外观,防止当原生js构造器/方法被覆盖时导致出错 通过以上这些占位符的注入,也使得我们能在核心js模块内部使用这些变量。
最后通过NewFunctionFromSharedFunctionInfo
对组装好的Script脚本生成NewFunction
,最终得到待执行fn,如下图,传入以下变量执行这个函数,这些变量是分别对应编译时所传入的占位符
- 执行上下文
context
,这个context即是V8运行时Isolate实例所绑定的执行上下文,后续内部模块的执行是在这个上下文内执行的 process
对象,即是我们在外部模块能拿到的全局变量process,这样内部核心模块中就能使用processnative_module_require
方法,用于引用其他内部核心js模块internal_binding_loader
方法,用于应用其他内部核心c++模块primodials
对象
最后在当前主作用域下执行这个fn,至此nodejs的NodeMainInstance的c++执行部分已经完成,接下来就是核心模块internal/main/run_main_module该上场的时候了。
外部模块执行
无论是c还是java,乃至我们的js,当我们的程序代码被编译成可执行文件最终加载到内存,最终作为指令集列表被加载进cpu寄存器执行,都是会有一个主函数入口。所以对于nodejs来说,它同样会在模块机制上存在一个最外层的主模块执行入口,所以从代码组织上我们也可以很快发现主入口逻辑所在,也就是下面要说的这个run_main_module,让我们来看下internal/main/run_main_module中做了什么。
我们会发现run_main_module中最终用来执行外部模块的入口在internal/modules/cjs/loader模块下的Module.runMain(process.argv[1])
,这里的process.argv[1]自然就是"index.js",而在run_main_module中还有另一个内部核心js模块internal/bootstrap/pre_execution的身影,在这个模块内部为Module挂载了runMain
方法,即internal/modules/run_main的executeUserEntryPoint
。
另外插一句,大家会发现这里也是用到了require来加载模块,正如上面所说的这里的require并不是我们平常自定义js模块中的require方法,它实际上是nativeModuleRequire
。
NativeModule.map
上缓存了所有的内部js核心模块,也就是一个个NativeModule实例,这里不贴代码大家可以移步自行前往源码查看,当我们require一个这样的模块时,实际上就会通过每个NativeModule
实例mod.compileForInternalLoader
进行加载,解读这个方法内部核心逻辑,我们可以发现nodejs模块机制的另一个神奇之处。当加载内部核心js模块时,nodejs会通过模块标识id进行compileFunction编译得到一个function,然后传入module.exports
、require
、module
、process
、internalBinding
和primordials
这些变量,也就是说除internal/main/run_main_module这个比较特殊的内部模块之外,其他内部模块获得process、internalBinding和primordials这些变量的方式是通过nativeModuleRequire来实现的,这也解释了其他内部模块内部为什么能使用这些变量。
OK,现在再回过头来看executeUserEntryPoint,从方法名字上想必大家也能马上看出来它的作用,它的核心作用就是通过Module._load来加载外部模块。因为对于nodejs来说,我们最经常使用就是CommonJS的模块规模,所以这里并不会利用useESMLoader,当然想使用esModule方式也很简单。
那么现在问题的核心就在于弄清楚Module
是一个什么,它是如何加载并执行外部模块的?我们直接前往Module所在的内部核心js模块/internal/modules/cjs/loader.js,我们直切重点Module._load
。这里顺便提一下,关于nodejs模块机制的两个加速模块加载的方法:
- 针对内部核心js模块,会build成二进制文件,然后在nodejs bootstrap后直接加载到内存
- 针对于用户自定义外部js模块,会在每次加载后直接缓存到
Module_.cache
,同时也会对文件标识进行缓存,后续再次加载相同的文件时会直接从缓存中查找
因此在Module._load
中,首先会根据文件标识查找缓存,如果找不到,则解析当前文件标识Module._resolveFilename
,根据Module._resolveLookupPaths
逐级向上查找对应文件标识的模块,像上面的例子node index.js,会直接查找当前目录下的对应文件,找不到则报错,而如果是加载对应核心js模块,在默认下,会从当前目录下node_modules/下查找,找不到则逐级向上查找父级目录的node_modules/。这个过程也就是nodejs模块机制下的文件路劲解析。具体的逻辑大家可以前往源码部分看一下,并不复杂,这里就不在赘述了。找到文件后生成一个新的Module实例,最后进行该实例module.load。根据findLongestRegisteredExtension查找对应文件扩展名,默认会返回.js。接着就是核心的加载和编译过程了,根据对应的扩展名找到对应的加载器Module._extensions[extension]
,我们主要来看下.js文件的加载器实现。
从上图源码中我们可以看到加载器中主要做两件事:
- 通过fs文件系统同步读取文件内容,因为是同步读取,所以这里更仰仗与nodejs模块机制里的缓存功能。
- 对文件内容进行
module._compile
编译执行。
所有的魔法都是在module._compile
中发生:
我抓取了这个方法中最核心的源码,让我们一起来分析一下。首先就是进行对文件内容进行包裹组装wrapSafe,默认情况下,这个patched标志位是false,只有当wrapper被改变时会变成true,也就是说默认情况下,这里的组装是走的下面的compileFunction
方式,在拿到compiledWrapper之后会注入module.exports
、require
,module
、filename
、dirname
,这也正是为什么我们的自定义模块中能拿到exports、require、module、__filename、__dirname这些变量,同时也是为什么module.exports和exports是同一个引用,而用exports=来导出会失败的原因。如果你仔细观察,会发现compiledWrapper
调用的时候this指针
指向的是module.exports,也就是说在自定义模块中我们是可以通过this.xx来导出变量/方法的。
这里还要提一点,眼尖的同学肯定发现了,compiledWrapper传入的require其实是const require = makeRequireFunction(this, redirects),这里我就不贴makeRequireFunction
方法的源码了,大家可以自行去看下源码,其实我上面也说了,我们自定义模块内拿到的require方法其实上是在nativeModuleRequire
上的封装,也就是这个require方法同样支持引用内部核心js模块,这个能力取决于传入的redirects,否则会fallback到Module.protptype.require
。
let wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
function wrapSafe(filename, content, cjsModuleInstance) {
// 默认情况下,这个patched标志位是false,只有当wrapper被改变时会变成true
// 也就是说默认情况下,这里的组装是走的下面的 compileFunction 方式
if (patched) {
const wrapper = Module.wrap(content);
return vm.runInThisContext(wrapper, {
filename,
lineOffset: 0,
displayErrors: true,
importModuleDynamically: async (specifier) => {
const loader = asyncESM.ESMLoader;
return loader.import(specifier, normalizeReferrerURL(filename));
},
});
}
let compiled;
try {
compiled = compileFunction(
content,
filename,
0,
0,
undefined,
false,
undefined,
[],
[ 'exports', // 这里会注入对应的变量名, 'require', 'module', '__filename', '__dirname', ]
);
} catch (err) {
if (process.mainModule === cjsModuleInstance) enrichCJSError(err);
throw err;
}
Module.prototype._compile = function(content, filename) {
...
const compiledWrapper = wrapSafe(filename, content, this);
...
const dirname = path.dirname(filename);
const require = makeRequireFunction(this, redirects);
let result;
const exports = this.exports;
const thisValue = exports; // this指针指向了exports
const module = this;
...
result = ReflectApply(compiledWrapper, thisValue, [exports, require, module, filename, dirname]);
...
return result;
};
以上就是我们关于nodejs模块机制的探索,看到这里,大家再回过头去看webpack这个目前前端业界使用比较广泛的构建工具,大家有没有一种webpack和nodejs同宗同源的感觉。其实大家可以借助这个思路来看webpack的源码,本质上,webpack就是把nodejs这套模块机制搬进了browser里。
总结
至此我们已经分析完node index.js到底做了什么,其实里面还有一部内容值得我们深入研究下,就是nodejs如何通过V8我们的js进行编译,或者说V8是如何工作的,我认为搞清楚V8底层原理,对于我们前端程序员玩转js是很有帮助的,同时这部分内容我后续也会再起一个专题去专门研究,大家如果有什么干货想分享的,也非常欢迎多留言讨论,一起共同快速进步。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!