node的模块实现
Node
在实现中并非完全按照规范实现,而是对模块规范进行了一定的取舍,同是也增加了少许自身需要的特性。
node
在实现exports、require
和module
的过程中究竟经历了什么呢?
-
在node中引入模块,需要经历三个步骤。
1 . 路径分析
2 . 文件定位
3 . 编译执行 -
模块又分为两类:
核心模块
, Node自己提供的模块。
它在node源代码的编译过程中,编译进了二进制执行文件,node的进程启动时,部分核心模块被直接加载进内存中,所以这部分核心模块在引入时,忽略了文件定位和编译执行两个步骤,并且在路径分析中优先判断。
核心模块,路径分析优先判断,免定位,免编译,加载最快。
文件模块
, 用户编写的模块,
文件模块在运行时动态加载,需要经历完整上面的三个加载步骤,路径分析、文件定位、执行编译过程。
文件模块,引入它,三步骤不能少,加载查找缓存都在核心模块后。
详细的加载过程
- 优先从缓存加载
和浏览器一样,缓存静态脚本文件来提高性能,node对引入过的模块都会进行缓存,以减少二次引入的开销。
不同在于浏览器缓存的仅仅是文件,而node缓存的是编译和执行之后的对象。
不管是核心模块还是文件模块,require方法对想用模块的二次加载都一律采用缓存优先的方式,这是第一优先级的,而且核心模块的缓存检查优先于文件模块。
- 路径分析和文件定位
- 模块标识符分析
node
中的require
是基于标识符进行模块查找的。标识符无非以下四种形式。
- 核心模块,如
http、fs、path
- 以
.
、`..``开始的相对路径的文件模块- 以
/
开始的绝对路径的文件模块- 非路径形式夺得文件模块,如自定义的connect模块。
模块的路径是node在定位文件模式的具体文件时指定的查找策略,具体表现为一个路径组成的数组,关于策略的生成规则,我们动手尝试一下:
(1)创建module_path.js文件
//module_path.js
console.log(module.paths)
(2)将它放入任意一个目录中,然后执行module_path.js文件(windows下)
//终端代码
d:\nodeDemo> node module_path
[ 'd:\\nodeDemo\\node_modules', 'd:\\node_modules' ]
上面可以看出,模块路径的生成规则是由当前文件目录沿路径向上逐渐递归,直到根目录下的node_modules
目录。
听起来有点像JavaScript的原型链以及作用域的查找方式十分类似,当文件的路径越深,查找耗时就越多,这就是自定义模块的加载速度是最慢的原因。
- 模块标识符分析
从缓存中加载的优化策略使得二次引入时省略路径分析、文件定位和变异执行的过程。
在文件的定位过程中,还有一些文件拓展名的分析、目录和包的处理细节,需要注意。
require()
在分析标识符的过程中,可能会有标识符没有文件拓展名的情况,这种情况下node会按.js
,.json
,.node
的次序补足扩展名,依次尝试。
在尝试的过程中,需要调用fs
模块同步阻塞式地判断文件是否存在。因为node是单线程的,所以这里是一个回应器性能问题的地方。
- 目录分析和包
如果在分析文件拓展名之后,没有找到对应文件,但却得到一个目录,此时Node会将目录当做一个包来处理。
在这个过程中,首先node在当前目录下查找package.json
(包的描述文件),解析出包描述对象,从中取出main
属性指定的文件名进行定位。如果没有main
属性指定的文件名, 或者压根没有描述文件(package.json
),Node
会将index
当做默认文件名,依次查找index.js、index.json、index.node
-
模块编译
在node
中每个文件模块都是一个对象,定义如下:
function module(id, parent){
this.id = id;
this.exports = {};
this.parent = parent;
if(parent&&parent.children){
parent.children.push(this)
}
this.filename = null;
this.loaded = false;
this.children = [];
}
编译和执行是引入文件模块的最后一个阶段。定位到具体的文件后,node
会新建一个模块对象,然后根据路径载入并编译。
.js
文件。通过fs
模块同步读取文件后编译执行。.json
文件。 通过fs
模块同步读取文件后,用JSON.parse()
解析返回结果。
-.node
文件。通过C/C++
编写的拓展文件,使用dlopen()
方法加载最后编译生成的文件。- 其余拓展名文件,都被认为是
.js
文件载入。
根据不同的文件拓展名,Node会调用不同的读取方式,如.json文件的调用如下:
//Native extension for .json
Module._extensions['.json'] = function (module, filename) {
var content = NativeModule.require('fs').readFileSync(filename, 'utf8');
try {
module.exports = JSON.parse(stripBOM(content));
} catch (err) {
err.message = filename + " : " + err.message;
throw err;
}
};
这里的Module._extensions
会被赋值给require()
的extensions
属性:
console.log(Module._extensions)
// { '.js': [Function], '.json': [Function], '.node': [Function] }
1 . JavaScript模块的编译
每个模块中存在着(require、exports、module
)这三个变量,甚至模块中还有_filename、_dirname
,但是模块文件中是没有定义的。
事实上,在编译过程中,Node对获取的js文件进行了头尾包装,如下:
(function(exports, require, module, _filename, _dirname){
var math = require('math');
exports.area = function(radius){
return Math.pi * radius* radius;
};
});
每个模块文件之间进行了作用域隔离,包装之后的代码会通过
vm
原生模块的runInThisContext()
方法执行(类似eval,但具有上下文,不污染全局
),返回一个具体的function
对象。最后,将当前模块对象的exports
属性,require
方法,module
(模块对象自身), 文件定位中得到的完整文件路径(_filename
)和文件目录(_dirname
)作为参数传递给 这个function
对象执行。
很多人不明白exports
和module.exports
两者的差异,以及module.exports
存在的意义。
exports = function(){
// my class
}
上面这种做法通常会失败,原因是exports
对象是通过形参的方式传入的,直接复制会改变它的引用,但并不能改变作用域外的值。像如下:
fuinction change(a){
a = 100;
console.log(a); //100
};
var a = 10
change(a) //10
如果要达到require
引入一个类的效果,请赋值给module.exports
对象,它不会改变形参的引用。
2 . c/c++模块的编译
node
调用process.dlopen()
方法进行加载和执行。在Node
的架构下,dlopen()
方法在windows和*nix平台下分别有不同的实现,通过libuv
兼容层进行了封装。
实际上,.node
的模块文件并不需要编译,只需要加载和执行的过程,执行的过程中,模块的exports
对象与。node
模块产生联系,然后返回给调用者。
c/c++
模块给node
使用者带来的优势主要是执行效率方面的,但是它的门槛比JavaScript
高。
3 . JSON文件的编译
.json
文件的编译时三种编译方式中最简单的。Node
利用fs
模块同步读取JSON
文件的内容之后,调用JSON.parse()
方法得到对象,然后复制给模块对象的exports
,以供外部调用。
网友评论