浅探node app.js的底层源码加载流程(纯js部分的源码)
发布于 4 个月前 作者 hyj1991 893 次浏览 来自 分享

执行了node app.js时发生了什么

从入门开始,我们就写了大量的node app.js,但是不知道大家思考过,当我们执行

node app.js

时,Node这个runtime是如何解释执行我们在app.js中的代码呢? 其实对于Node来说,加载app.js是通过加载源码目录src/node.js的方式,做了真正调用V8引擎解释执行app.js的内容之前的一些预处理。本文的主要目的,是单纯站在js的角度,对涉及到的一些使用js编写的内建模块的源码进行解析,使大家能对Node中的一些行为理解的更加透彻。所以实际上,我们编写的app.js是由src/node.js包裹后被执行的,所以下面首先来分析下,node.js文件究竟做了些什么 。

src/node.js分析

我们首先对src/node.js的代码进行简化剥离,去除一些无关的if选项以及全局的global对象字面量中的变量创建,那么可以得到大致如下的代码,可以看到,其实就是一个匿名函数,实际上,Node源码中的c++部分,会解析这个node.js文件,并且将解析得到的如下匿名函数,传入构造好的process参数,并且执行这个匿名函数,下面对这个匿名函数进行按行的注释分析,来探究此函数如何加载了我们编写的app.js:

(function start(process) {
//创建global对象字面量
this.global = this;
//核心函数startup
function startup() {
    //此处由于无法直接使用require,所以使用在node.js中编写的NativeModule提供的require方法,加载内建模块 events
    var EventEmitter = NativeModule.require('events');
    process._eventsCount = 0;
    //使用setPrototypeOf方法,使得 process对象继承 events类,同时将process对象的构造器依旧指向原本的process.constructor
    //这种继承方式,和内建模块中的util.inherits方式是一样的
    Object.setPrototypeOf(process, Object.create(EventEmitter.prototype, {
        constructor: {
            value: process.constructor
        }
    }));
    //依旧是继js继承的一部分,作用是让process对象,继承 events类的所有非原型链属性
    EventEmitter.call(process);
    //将完整的events类,赋值给process的EventEmitter属性上备用
    process.EventEmitter = EventEmitter;
    //process.argv[0],原始存储的是环境变量中的node路径,此处将实际的node可执行文文件路径,替换环境变量中的node路径
    process.argv[0] = process.execPath;
    //process.argv[1]中存储的是node app.js里的app.js
    if (process.argv[1]) {
        //调用内建模块的path方法,使用NativeModule.require而不是直接require的原因和上述的events一致
        var path = NativeModule.require('path');
        //使用path.resolve方法,补全app.js的全路径,并且替换到process.argv[1]中的参数
        process.argv[1] = path.resolve(process.argv[1]);
        //调用内建模块的module方法
        var Module = NativeModule.require('module');
        //如果process._preload_modules有内容,则调用module._preloadModules方法,载入预加载的各个模块
        startup.preloadModules();
        //调用内建模块的module提供的runMain方法(是一个静态方法),启动主函数,app.js,正是在runMain中被包裹加载
        Module.runMain();
    }
}
//主函数入口
startup();
});

通过上面的注释,可以看到,实际上我们执行

node app.js

时,实际上最终执行了Node中的内建模块中提供的module模块中的runMain方法。下面我们就来分析下runMain方法的逻辑。

lib/module.js中的runMain方法

Module.runMain = function () {
   Module._load(process.argv[1], null, true);
   process._tickCallback();
};

以上是runMain方法的源码,可以发现,process.argv[1]存储的是app.js在当前服务器上的全路径,所以真正执行包裹我们编写的app.js的应该是Module.load方法。 顺带提一下,第二行的process.tickCallback()函数,其实就是我们使用process.nextTick的主回调函数,这个函数会把我们往nextTickQueue数组中push进去的回调函数,全部拿出来按照顺序进行处理。这里调用是为了防止在执行runMain之前nextTickQueue数组中已经注册了等待nextTick处理的回调函数。

lib/module.js中的_load方法

Module._load = function (request, parent, isMain) {
//使用Module._resolveFilename方法,获取request(这里就是app.js的全路径)参数的路径
//其中如果request被内建模块命中,则直接返回request,如果不是,则返回该模块的全路径,如果未找到,则throw "Cannot find module request"的err
var filename = Module._resolveFilename(request, parent);
//Module._cache中保存的是所有已加载的模块的缓存,并且以 key(全模块路径)——value(模块内容)的方式进行缓存
var cachedModule = Module._cache[filename];
//如果当前模块全路径,在_cache中查询到对应的缓存,则直接返回该缓存的exports属性对应的值
//这里其实就是module.exports的内容
if (cachedModule) {
    return cachedModule.exports;
}
//如果该模块匹配命中内建模块,则调用NativeModule.require方法加载该内建模块
if (NativeModule.nonInternalExists(filename)) {
    debug('load native module %s', request);
    return NativeModule.require(filename);
}
//生成Module的一个实例
var module = new Module(filename, parent);
//isMain表示是否为main函数,这里的app.js显然是main函数,所以此处为true
if (isMain) {
    //给process全局变量增加mainModule属性
    process.mainModule = module;
    module.id = '.';
}
//将当前的模块以 key(全模块路径)——value(模块内容)的形式添加入_cache缓存
Module._cache[filename] = module;
//标记位,用来表示模块加载出错时,删除对应的缓存
var hadException = true;
try {
    //核心方法,真正加载filename这个全路径对应的js文件,这里就是app.js的全路径
    module.load(filename);
    hadException = false;
} finally {
    if (hadException) {
        delete Module._cache[filename];
    }
}
//这里可以看到,require方法,最终返回的就是module实例的exports属性对应的值
return module.exports;
};

以上是对module._load方法的注释解析,在这里,我们其实可以明白如下的几个原理: 1.node app.js最后就和普通的require模块一样,最后执行了相当于执行了require(‘app.js全路径’),只是此处的require是由runMain方法发起的; 2.node中执行require一个模块,匹配的顺序始终是最优先匹配内建模块(http, https, path等嵌入Node本身的api库),再匹配项目中的模块 3.每一个require的动作,其实都生成了Module的一个实例,并且,require得到的是Module实例的exports属性(this.exports)的值,所以对应的,我们想到在一个js文件中导出方法给外部require,要么就是exports = func,要么就是module.exports = func

以上几个原理,能帮助我们更加深刻的理解node加载模块时的一些细节,我们接下来分析,可以看到,核心方法module.load(filename),应该是下一个需要分析的核心流程,而且从名字上也能看出来,_load是一个内部方法,load是其本体~

lib/module.js中的load方法

Module.prototype.load = function (filename) {
//获取需要加载的文件的扩展名,默认为.js,这里的app.js的全路径扩展名,则必然为.js
var extension = path.extname(filename) || '.js';
//判断上一步获取到的扩展名是否符合预设,只支持 '.js', '.json'和'.node'三种,如果不符合,重置为默认的.js
if (!Module._extensions[extension]) extension = '.js';
//调用Module的_extensions['.js']对应的方法,传入this和文件全路径,进行最终的加载
Module._extensions[extension](this, filename);
//将module.loaded重置为true,表示加载完毕
this.loaded = true;
};

依旧是按行的注释,此函数逻辑比较简单,那么此处的app.js加载,最终会调用 Module.extensions[‘.js’]的方法进行加载,注意的此处的this指的是上述load函数中的module(module = new Module(filename, parent)),filename依旧是app.js的全路径。下面继续分析Module._extensions[‘.js’]方法的内容。

lib/module.js中的_extensions加载js文件方法

Module._extensions['.js'] = function (module, filename) {
//调用内建模块中fs中的readFileSync方法,以'utf8'的形式读取文件内容,此处其实就是根据app.js的全路径
var content = fs.readFileSync(filename, 'utf8');
//首先使用stripBOM方法,将读取出的内容中的utf-8编码特殊的头部信息剥离掉,然后调用_compile方法进行编译
module._compile(internalModule.stripBOM(content), filename);
};

这个方法注释解析如上,也是比较简单的,值得注意的是,我们编写的js文件,被Node加载时,是以utf-8的编码进行读取的,读取完成后,首先剥离掉utf-8编码的特殊头部信息,再进行编译的。下面来看下compile方法的实现,此处传入的参数是剥离掉utf-8信息之后的app.js的内容,以及app.js的全路径。在分析compile之前,我们需要先看两个函数,有助于接下来最终的_compile方法的理解。

lib/module.js中的wrap方法

NativeModule.wrapper = [
	'(function (exports, require, module, __filename, __dirname) { ',
		'\n});'
	];
NativeModule.wrap = function (script) {
	//wrap方法在script的外部,包裹了成形如:
	/*(function (exports, require, module, __filename, __dirname) {
    	//传入的script
   	});
	*/
	return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

这里比较简单,大家看下就可以了。

lib/module.js中的_compile方法

Module.prototype._compile = function (content, filename) {
//如果js文件的内容里面存在释伴,则将释伴内容替换为空字符串,释伴是*inx系统中的指定执行变量的一个方式
//比如:#!/usr/bin/env node,那该js文件可以无需添加node而自动执行
content = content.replace(shebangRe, '');
//wrap方法,会在content的外部,包一层函数,具体见Module.wrap方法
var wrapper = Module.wrap(content);
//通过调用内建模块vm的runInThisContext方法,编译该经过包裹后的js代码,返回的得到的是一个函数
//形如:(function (exports, require, module, __filename, __dirname) { app.js的代码内容 });
var compiledWrapper = runInThisContext(wrapper, {filename: filename, lineOffset: 0});
//获取dirname信息
const dirname = path.dirname(filename);
//这里是得到require方法,具体可见makeRequireFunction函数的解析,此处传入的this就是Module的实例
const require = internalModule.makeRequireFunction.call(this);
//这里是一个老API接口调用的异常抛出,那么从Node v4.x开始,不再支持require.paths的方法传入匹配path,修改为
//process.env.NODE_PATH的赋值,如果继续调用require.paths,那么就会抛出这个异常
Object.defineProperty(require, 'paths', {
    get: function () {
        throw new Error('require.paths is removed. Use ' +
            'node_modules folders, or the NODE_PATH ' +
            'environment variable instead.');
    }
});
//同样的老API接口调用的异常抛出,require.registerExtension已经被require.extensions方法替代
require.registerExtension = function () {
    throw new Error('require.registerExtension() removed. Use ' +
        'require.extensions instead.');
};
//定义参数
const args = [this.exports, require, this, filename, dirname];
//绑定上述的compiledWrapper得到的匿名函数,并且绑定this到module.exports,同时传入
//module.exports, require, module, filename, dirname这几个参数
//至此,我们的app.js最终被执行,执行的方式形如:
/*let fn = (function(exports, require, module, __filename, __dirname){
    //我们编写的app.js代码
  });
  fn.apply(module.exports, [module.exports, require, module, filename, dirname]);
*/
//所以,这里可以明白,为什么在app.js里面,我们可以直接调用require, module.exports, exports等方法~
return compiledWrapper.apply(this.exports, args);
};

这个函数其实就是最终编译执行app.js的地方,通过源码的注释分析,我们可以发现,最终的app.js,其实被以形如:

let fn = (function(exports, require, module, __filename, __dirname){
    //我们编写的app.js代码
  });
fn.apply(module.exports, [module.exports, require, module, filename, dirname]);

执行了,我们的app.js在外部被包裹了一层函数,这样防止了变量的全局污染,也是为什么我们在node的空白js文件中直接执行console.log(this),会得到一个空对象,而不是像浏览器中的那样指向global,其实代码里解释的很清楚,因为我们编写的js文件一定会被wrap方法在外部包裹一个函数,并且执行的时候this指针被绑定到module.exports上去,所以会出现这样的现象。

结语

本文算是打算开始研究学习Node c++部分源代码的热身吧,因为对js比较熟悉,所以先开始看lib目录下的一些内建模块的js实现和最初被加载的位于src目录下的node.js文件的实现方式,收获也是蛮大的。 比如在为什么在很多npm源码库里面,我们会看到exports = module.exports = {}的写法,比如为什么module.exports = {}这样赋值后,exports.A = funcA就无效了。

3 回复

赞!在朴大的《深入浅出NodeJS》中提到过这个把js文件包裹起来的内容,这下楼主给出了比较详细的解析!

@DevinXian 看了朴灵大神的书,很想能深入研究学习下,哈哈

回到顶部