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 实现与模块的作用域
还没有人评论 快来占位置吧