先说CommonJS的愿景:希望JavaScript在任何地方运行
本篇文章参考《深入浅出NodeJS》
commonJS规范
- 模块
- 二进制
- Buffer
- 字符集编码
- I/O流
- 进程环境
- 文件系统
- 套接字
- 单元测试
- Web服务网关接口
- 包管理
commonJS对模块定义
- 模块引用
- 模块定义
- 模块标识
模块引用
let fs = require('fs')
在commonJS规范中存在require方法,这个方法接受模块标识,以此引入一个模块的api到当前的上下文中
//模块定义
//在模块中,上下文提供require方法来引入模块。
//对应引入的功能,上下文中提供了exports对象用于导出当前模块的方法或者变量,并且它是导出的唯一出口。
//在模块中还存在一个module对象,他代表模块自身,而exports是module的属性。
//在Node中,一个文件就是一个模块,将方法挂载到exports中导出
// math.js
exports.add = function(){
let sum = 0
let i = 0
let args = arguments
let l = args.length
while(i < l) {
sum += args[i++];
}
return sum
}
// 在test.js中
let math = require('math');
exports.increament = function (val) {
return math.add(val, 1)
}
模块标识
标识就是传递给require
方法的参数,必须符合小驼峰命名字符串,或者以 .,.. 开头的相对路径,或者绝对路径,可以没有.js 后缀
Node模块实现
node并非都是按照commentJS规范实现的,Node引入模块要经历三个步骤
- 路径分析
- 文件定位
- 编译执行
在Node中模块分为两类一是 核心模块
还有就是 文件模块
- 核心模块:部分在Node源码编译过程中,编译成了二进制执行文件,在Node进程启动时自动加载进内存中,部分核心模块可以省略文件定位和编译两个步骤,并在路径分析中有优先判断,所以核心模块加载时最快的
- 文件模块:在运行时动态加载,需要完整的路径分析、文件定位,编译执行。所以文件模块加载执行会慢一些
优先从缓存加载
一句话:跟前端浏览器缓存静态文件一样,都是为了提高性能
Node会对引入过的模块进行缓存,加快执行速度,不同的是浏览器缓存的是静态文件本身,Node缓存的是编译和执行后的对象。无论是核心模块还是文件模块,Node采用的都是缓存优先方式,这是第一优先级。不同之处就是核心模块缓存检查优先级高于文件模块检查
路径分析和文件定位
上面说到Node是根据标识符来查询文件的。对于不同的标识符,模块的查找和定位有不同的差异
-
模块标识符分析
- 核心模块:http fs path等
- . 或 **..**开始的相对路径文件模块
- 以/开始的绝对路径文件模块
- 非路径形式的文件模块
- 核心模块
核心模块的优先级仅次于缓存加载,如果试图加载一个标识符和核心模块一样的自定义模块,那是不会成功的,如果想要加载成功就必须选择一个不同的标识或者换一个路径的方式 - 路径形式的文件模块
**.或者..**和/开始的标识符,都会被当做文件模块处理。在分析文件模块时,require方法会将路径转为真实路径,并以真实路径作为索引,由于文件模块给Node明确了文件位置,所以在查找中可以节省大量的时间 - 自定义模块
自定义指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或者包的形式这里就得提一下Node的模块路径
,Node在定位文件模块的具体文件时制定的查找策略,是一个路径生成的数组。【‘c:\nodejs\node_modules’】这种生成方式跟JavaScript的原型链或作用域链查找方式很类似,文件越深耗时越长,这也是自定义文件模块加载慢的原因
文件定位
从缓存加载的策略大大提高了效率。但在文件中还需要注意文件扩展名和包处理
-
文件扩展名分析:
require()在分析标识符时,会出现标识符中不包含文件扩展名的情况。commonJS规范允许在标识符中不包含文件扩展名,Node会按照 .js .json .node的次序补足扩展名,依次尝试。
在尝试过程中需要调用 fs文件操作模块同步方法判断文件是否存在。因为JavaScript是单线程的,所以这里的阻塞会产生性能问题。如果是.node 或者 .json 文件,在传递给require方法的时候带上扩展名,这样加载就会快一点。另一种方法就是同步配合缓存,可以大幅缓解Node单线程中阻塞式调用的缺陷 -
目录分析和包
在分析标识符的过程中,require通过分析文件扩展名之后,可能没有找到对应的文件,但是得到了一个目录,在引入自定义模块和逐个模块路径进行查找时经常会出现。此时Node会将目录当做一个包来处理。在这个过程中,Node对commonJS包规范进行了一定程度的支持,首先Node会在当前目录下查找package.json(包描述文件),通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果缺少扩展名,就会进入扩展名分析步骤。如果main属性指定的文件有错误,或者没有packag.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
模块同步读取文件后编译执行 - .node文件:用C/C++编写的扩展文件,通过dlopen方法加载最后编译生成的文件
- .json:通过
fs
模块同步读取文件后,用JSON.parse解析返回结果 - 其他扩展名都会当做js文件载入
每个编译成功的模块都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能
以读取JSON文件为例
Module._extensions["json"] = function (module, filename) {
let 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属性,所以通过在代码中访问require.extensions可以知道系统中已有的扩展加载方式。
输入 console.log(require.extensions)
结果:{‘.js’: [Function], ‘.json’: [Function], ‘.node’: [Function]}
我们都知道每个模块文件中存在着 require exports module 这三个变量,但是它们在模块文件中并没有定义,在Node的API中还可以了解到模块中还有 __filename和__dirname这两个变量的存在。为啥可以直接用呢,其实Node在编译过程中对JavaScript进行了头尾包装。
(function (exports, require,module,__filename,__dirname){
let fs = require('fs') ;
exports.area = function (radius) {
return Math.PI * radius * radius;
}
});
这样每个模块文件之间进行了作用域隔离。包装之后的代码会通过VM原生模块的runInThisContext()方法执行(类似eval, 只是具有明确的上下文,不污染全局),返回一个具体的function对象。最后当前模块对象的exports属性,require方法、module(模块对象自身),以及在文件定位中得到的完善文件路径和文件目录作为参数传递给这个function执行
在执行之后,模块的exports属性被返回给调用方。exports属性上的任何方法和属性都可以被外部访问到,但是模块中的其余变量或属性不能被直接调用。
为什么在存在了exports的情况下还存在module.exports
exports = function() {
……
}
这样的代码会得到失败的结果。原因在于,exports对象是通过形参的方法传入的,直接赋值会改变形参的引用,但并不能改变作用域外的值。
let change = function(a) {
a = 100;
console.log(a); // 100
};
var a = 10
change(a);
console.log(a); //10
C/C++模块编译
Node调用 process.dlopen方法进行加载和执行c/c++模块。在Node架构下,dlopen方法在Windows和Linux平台下分别有不同的实现,通过libuv兼容层进行了封装
实际上,.node模块文件并不需要编译,因为它是编写C/C++模块之后编译生成的,所以这里只有加载和执行的过程。在执行的过程中,模块的exports对象与.node模块产生联系,然后返回给调用者。C/C++给Node的使用者带来的优势主要是执行效率方面,劣势就是学习门槛较高
JSON文件编译
.json文件的编译是最简单的。Node利用fs模块同步读取JSON文件的内容之后,调用JOSN.parse()方法得到对象,然后将它赋值给模块对象的exports,供外界调用。