nodejs模块机制深入浅出

1,475 阅读12分钟

前言 

一直以来,都想好好系统的分析分析nodejs源码,既能帮助我们在运用nodejs编写代码时更加游刃有余,也算是对编写好代码方法的一种学习,所以接下来就想以nodejs源码为出发点,写一些关于nodejs周边的系列,会包括nodejs本身及包管理工具npm的原理,也会包含一些依赖nodejs而实现的前端热门工具,如webpackbabel等。

平常工作太忙而疏于写文章总结沉淀,在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,这样内部核心模块中就能使用process
  • native_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.exportsrequiremoduleprocessinternalBindingprimordials这些变量,也就是说除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模块机制的两个加速模块加载的方法:

  1. 针对内部核心js模块,会build成二进制文件,然后在nodejs bootstrap后直接加载到内存
  2. 针对于用户自定义外部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文件的加载器实现。

从上图源码中我们可以看到加载器中主要做两件事:

  1. 通过fs文件系统同步读取文件内容,因为是同步读取,所以这里更仰仗与nodejs模块机制里的缓存功能。
  2. 对文件内容进行module._compile编译执行。

所有的魔法都是在module._compile中发生:

我抓取了这个方法中最核心的源码,让我们一起来分析一下。首先就是进行对文件内容进行包裹组装wrapSafe,默认情况下,这个patched标志位是false,只有当wrapper被改变时会变成true,也就是说默认情况下,这里的组装是走的下面的compileFunction方式,在拿到compiledWrapper之后会注入module.exportsrequiremodulefilenamedirname,这也正是为什么我们的自定义模块中能拿到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是很有帮助的,同时这部分内容我后续也会再起一个专题去专门研究,大家如果有什么干货想分享的,也非常欢迎多留言讨论,一起共同快速进步。