NodeJS模块系统解析
昨日文章:exports和module.exports的区别
本文借鉴NodeJS官网关于模块系统的介绍,同时会引入自己关于浏览器端和服务器端模块机制的理解。如果文章有误,希望广大大佬可以指出哈。
文章目录:
- 
CommonJS规范与浏览器端的模块规范 
- 
模块包装器 
- 
模块分析 
- 
模块缓存 
CommonJS规范与浏览器端的模块规范
JavaScript起初并没有内置的模块系统,CommonJS社区为了使JavaScript可以提供一个类似Python、Ruby等的标准库,自己实现了一套API填补了JavaScript没有内置模块的空白。
CommonJS规范本身涵盖了模块、二进制、Buffer、文件系统、包管理等内容,而NodeJS正是借鉴了CommonJS规范的模块系统,自身实现了一套非常易用的模块系统。CommonJS对模块的定义可分为三部分:模块引用(require)、模块定义(exports、module)、模块标识。
模块引用:require函数用于引入外部模块到当前上下文中
模块定义:exports导出当前模块的变量或方法,是唯一导出的出口。在模块中,还有一个module对象,它代表模块自身,且exports是module对象的属性。
模块标识:就是传递给require方法的参数。
- 
// math.js
- 
const { PI } = require('math')
- 
exports.circle = (r) => {
- 
return PI * r ** 2
- 
}
- 
// main.js
- 
const math = require('./math.js')
- 
console.log('waiting...')
- 
console.log(math.circle(2)) // 4PI
上面代码中,在math.js文件中通过exports对象导出该模块下的circle方法,在main.js文件中通过require方法引入了circle方法。
在NodeJS中,每一个文件就是一个模块,其内部定义的变量是属于这个模块的,不会对外暴露,也就是说不会污染全局变量。因此以上math.js模块定义的PI常量不会作为全局变量存在,而是被包裹在NodeJS的模块包装器中,作为局部变量存在。什么是模块包装器会在下文给予说明。
但是存在一种特殊情况,看以下代码。
- 
// math.js
- 
circle = 12
- 
// main.js
- 
const circle = require('./math')
- 
console.log(circle) // 12
math.js模块中没有使用exports导出circle,却在main.js中获取了变量circle。如果没有使用关键字 var, let, const定义变量circle,它会成为global对象下的属性。在NodeJS官网上vm模块里那么一句话
运行中的代码无法获取本地作用域,但可以获取当前的
global对象。
这里的circle就等于global.circle,因此可以获取到circle变量。如果要避免这种情况,那么可以添加'use strict'表明其为严格环境,那么就不会绑定到global对象上。NodeJS这种特性与JavaScript类似。在JavaScript下,如果是在非严格环境,全局变量会绑定到window对象上,而非严格环境下则会报错。
- 
// math.js
- 
'use strict'
- 
circle = 12
- 
// main.js
- 
const circle = require('./math')
- 
console.log(circle) // ReferenceError: circle is not defined
上面的代码除了模块包装器的概念还涉及NodeJS模块系统中其他概念,会在下文给予说明。
仍然从上面代码中可以看出,CommonJS规范的模块加载机制是同步加载的。只有math.js模块加载完毕之后,才能继续往下执行对应的逻辑。这在服务端来说没有什么问题,因为在服务端读写的操作主要在本地硬盘中完成,不涉及到网络请求,不受带宽等条件的限制所以加载起来会比较快。但是如果将CommonJS规范运用在浏览器环境,就不太合适了。在浏览器端同步加载服务端的文件意味着阻塞后续代码的执行,如果文件太大就直接导致了浏览器处于空白(假死)的状态,这种在现如今讲究用户体验的情况下是不能忍受的。
因此浏览器环境下就出现了AMD, CMD, ES6模块机制。这里大概的提一下。
AMD意即Asynchronous Module Definition,中文为异步模块定义。AMD规范为浏览器环境提供了全局的define和require方法,其实现有点类似于CommonJS,但是它采用异步方式加载模块,模块的加载不影响后面语句的执行。语法如下。
- 
require([module1, module2, module3, ...], callback)
AMD规范一个很大的特点是 依赖前置,提前执行。也就是说依赖的所有module模块都会提前加载好,在执行callback的内容。如果稍微思考一下这种加载方式,你会发现,如果我在回调函数里面什么也不写,那把模块全部加载好了不是浪费请求了吗?因此原因也就出现了CMD规范。
CMD意即Common Module Definition,中文为通用模块定义。CMD也属于异步加载模块,但是与AMD规范不同的是,CMD规范的特点是 依赖就近,延迟执行。
- 
define(function(require, exports, module) {
- 
const math = require('./math') // 依赖就近书写
- 
math.doSomething()
- 
// 此处略去 100 行
- 
const async = require('async')
- 
async.parallel({...})
- 
})
实现CMD规范的主要库是sea.js。
虽然CMD规范确实解决了前端模块的问题,但是ES6的出现带来了自己的模块机制,使得前端模块化开发向前迈了一大步。ES6模块机制的学习可以参考这篇文章。传送门:ECMAScript 6入门。
http://es6.ruanyifeng.com/#docs/module
由于JavaScript拥有自己的模块系统,而NodeJS采用的是CommonJS规范,因此可以对比一下浏览器端和服务器端模块系统的区别,具体可以看看这篇文章。传送门:CommonJS模块和ES6模块的区别 - 凯斯keith - 博客园
http://www.cnblogs.com/unclekeith/p/7679503.html
模块包装器
前面其实有谈到,NodeJS中,每一个js文件都是一个模块,在正确定义(var, let, const)和使用严格模式的情况下,模块内部的定义的方法都是该模块下的局部变量。模块包装器其实就是一个匿名函数。
- 
(function(exports, require, module, __filename, __dirname) {
- 
// 模块的代码实际上在这里
- 
});
_filename*,* _dirname这两个其实就没什么好说的了,对应表示着当前模块下文件和文件夹的绝对路径。而对于其他三个参数有必要深究一下。
exports:
在模块定义处,exports是一个空对象,用于导出该模块的变量或方法。exports实际上就是module.exports的引用,两者指向同一个内存地址。即
- 
exports === module.exports
而当我们在require一个模块的时候,导出的是module.exports。所以倘若给exports对象重新赋值,会导致exports指向另一个内存地址了,而不再是module.exports的引用了,所以导出的是一个空对象,即module.exports。
- 
// 错误
- 
// a.js
- 
exports = {a: 1} // 指向内存中的另一个地址,与module.exports没有关系了
- 
// b.js
- 
const a = require('./a')
- 
console.log(a) // {} , 即module.exports
- 
- 
// 正确
- 
// a.js
- 
exports.a = 1
- 
// b.js
- 
const a = require('./a')
- 
console.log(a) // {a: 1}
module:
上面有谈到,module对象代表文件本身,通过require引入一个模块时,引入的就是module对象上的exports属性。module对象除了exports属性作为模块唯一出口之外,还有其他几个我感觉需要掌握的属性。
module.paths: 官网对paths属性的介绍,只有一句话,模块的搜索路径。真是醉了... 竟然只有一句话: ) 模块的搜索路径意思是当你require一个模块的时候,该模块的查找路径。看一下代码。
- 
// fe/test/index.js
- 
console.log(module.paths)
- 
[ 'E:\\fe\\test\\node_modules',
- 
'E:\\fe\\node_modules',
- 
'E:\\node_modules' ]
加入要查找require('math')模块(第三方模块),那么:
首先先去当前目录下寻找node_modules目录下的文件查找math模块。
如果此时仍然不到,就会退出一层,寻找fe下的node_modules目录。
直到文件顶层,如果仍然查找不到,就会报Error: Cannot find module 'math'的错误。
上面查找的math.js是按照第三方模块的方式查找的。当然了,模块标志不同,require查找的方式也不同。
require:
require()用于引入一个模块到当前作用域中,实际上也就是引入这个模块的module.exports属性。我们来简单的看一下NodeJS的源码关于require函数的实现。
- 
Module.prototype.require = function(id) {
- 
...
- 
// 返回_load函数
- 
return Module._load(id, this, /* isMain */ false);
- 
// 1\. id表示模块id
- 
// 2\. this指向Module实例对象
- 
// 3\. isMain是符号连接的标志
- 
- 
};
- 
Module._load = function(request, parent, isMain) {
- 
...
- 
// _load方法大致逻辑
- 
// _load函数会检查模块是否已经存在于缓存中,
- 
// 如果存在,则直接从Module._cache对象读取,返回module.exports属性
- 
// 如果不存在,则会创建一个模块,并将其放入缓存中,并且加载模块内容之后再返回module.exports
- 
// 属性
- 
return module.exports;
- 
};
除了知道上述require一个模块实际上是加载module.exports对象之外,还应该明白的是require函数的模块分析和模块缓存机制。
模块分析
NodeJs模块分为三类:核心模块、第三方模块和文件模块。
核心模块定义在源码lib/目录下,是NodeJS自身提供的一些常用模块。
第三方模块是通过npm(或其他方式)下载的,保存在node_modules目录下,第三方模块查找路径为Module.path的对应路径
文件模块是通过相对路径'.' '..'和绝对路径'/'为模块名,相对路径是根据当前模块的路径,也就是_dirname*。*可以不写扩展名,Node会根据Module.extensions对象中默认的扩展名进行查找。假如引入了以下模块
- 
const math = require('./math')
则,Node会根据按照'math.js', 'math.json', 'math.node'顺序进行查找。
另外的,如果不是上述三个模块,而是以目录src作为模块,则可以根据package.json文件的name, main字段查找;如果没有package.json文件,则会试图加载 src/index.js 和 src/index.node模块;如果还是没有找到则会报Error: Cannot find module 的错误
这里需要注意的是,如果需要加载文件模块,一定要加上相对路径或者绝对路径标识符,否则会当作核心模块(同名情况下)或者第三方模块处理
来简单的分析一下require一个模块时,会经历什么过程
- 
1\. require(id)的id不是非空字符串,则抛出ERR_INVALID_ARG_TYPE的错误
- 
2\. 检查模块id在Module._cache对象中是否存在缓存,如果key等于模块id,则表示缓存存在,
- 
则返回对应的value值,即module实例对象。以下步骤不会执行。
- 
3\. 如果key不存在,表示缓存不存在,那么就会调用Module
- 
构造函数,将返回的module实例对象作为value值传入Module._cache对象中。
- 
4\. 判断是否是NativeModule(核心模块),如果是,则加载核心模块的module.exports属性。
- 
从这里可以看出,模块缓存的加载优先于核心模块。以下代码不会执行。
- 
5\. 调用tryModuleLoad函数加载模块代码,传入module实例对象和filename。
- 
6\. 调用Module.prototype.load方法,传入filename。
- 
7\. 检查filename的扩展名,如果没有传递扩展名,则添加扩展名为.js;如果存在扩展名,但是扩
- 
展名不在Module._extensions数组内,会将其扩展名修改为.js。Module._extensions数组默认
- 
扩展名有['.js', '.json', '.node']。这也意味着,除了.json, .node之外,其他形式的文件
- 
都是js文件。
- 
8\. 根据不同的扩展名加载不同的文件。
- 
如果是.json文件,则调用fs.readFileSync同步读取utf8编码的内容,然后通过JSON.parse解析后return;
- 
如果是.node文件,则会调用process.dlopen处理;
- 
如果是其他文件类型,统一以.js文件处理,调用fs.readFileSync函数同步读取utf8编码后的内容,
- 
将内容和filename作为参数传入Module.prototype._module函数中。
- 
9\. 将内容包裹在(function (exports, require, module, __filename, __dirname) { ... })
- 
模块包装器中,调用NodeJS的核心模块vm的runInThisContext方法,执行内部代码。
- 
runInThisContext方法与window.eval方法类似。
简单的说,上述分析过程主要经历了这么几个过程
- 
检查是否存在缓存 
- 
检查是否为核心模块 
- 
检查扩展名 
- 
解析执行(根据不同后缀名) 
以上稍微分析了一下require的执行流程,简单看完源码可以发现,会有几个重要点:
- 
缓存优于核心模块加载。 
- 
除了.json, .node文件外,其他前端资源统一作为.js文件处理 
具体的可以看看结合源码的分析过程。传送门:https://github.com/KeithChou/node/blob/master/lib/internal/modules/cjs/loader.js。分析过程会使用/* ... */注释。
模块缓存
在上面的分析过程中,可以看出,模块在第一次被加载后会被缓存。这也意味着如果每次调用require('./math')都解析到同一个文件,则会返回相同的对象。多次调用require解析到同一个模块不会导致模块的代码被多次执行。这是一个很重要的特性,也意味着,如果循环引用某个模块,只会执行已经加载的部分,未加载的部分不会执行。具体的循环引用例子可以参考以下文章,对比了CommonJS和ES6 Module模块的不同点。传送门:CommonJS模块和ES6模块的区别 - 凯斯keith - 博客园。:
模块缓存解决了两件事情:
- 
多次调用同一个模块时,可以从缓存中读取,这样模块加载速度更快 
- 
循环引用时,不会造成死循环。只执行已经加载的部分,未加载的部分不执行 
OK,关于NodeJS模块机制的分析就差不多了,感谢大家的阅读。
参考文章:
- 
《深入浅出NodeJS - 朴灵》 
- 
JavaScript模块化 --- Commonjs、AMD、CMD、ES6 modules 
- 
Node.js CommonJS 实现与模块的作用域 
 
  
 
 
  
 

还没有人评论 快来占位置吧