NodeJS模块系统解析
@zs.duan
NodeJS模块系统解析
阅读量:561
2022-09-09 14:59:25

NodeJS模块系统解析

凯斯 Node前端 2020-04-12 20:51

昨日文章:exports和module.exports的区别


 

本文借鉴NodeJS官网关于模块系统的介绍,同时会引入自己关于浏览器端和服务器端模块机制的理解。如果文章有误,希望广大大佬可以指出哈。

文章目录:

  1. CommonJS规范与浏览器端的模块规范

  2. 模块包装器

  3. 模块分析

  4. 模块缓存

CommonJS规范与浏览器端的模块规范

JavaScript起初并没有内置的模块系统,CommonJS社区为了使JavaScript可以提供一个类似Python、Ruby等的标准库,自己实现了一套API填补了JavaScript没有内置模块的空白。

CommonJS规范本身涵盖了模块、二进制、Buffer、文件系统、包管理等内容,而NodeJS正是借鉴了CommonJS规范的模块系统,自身实现了一套非常易用的模块系统。CommonJS对模块的定义可分为三部分:模块引用(require)、模块定义(exports、module)、模块标识。

模块引用:require函数用于引入外部模块到当前上下文中

模块定义:exports导出当前模块的变量或方法,是唯一导出的出口。在模块中,还有一个module对象,它代表模块自身,且exports是module对象的属性。

模块标识:就是传递给require方法的参数。

  1. // math.js

  2. const { PI } = require('math')

  3. exports.circle = (r) => {

  4. return PI * r ** 2

  5. }

  6. // main.js

  7. const math = require('./math.js')

  8. console.log('waiting...')

  9. console.log(math.circle(2)) // 4PI

上面代码中,在math.js文件中通过exports对象导出该模块下的circle方法,在main.js文件中通过require方法引入了circle方法。

在NodeJS中,每一个文件就是一个模块,其内部定义的变量是属于这个模块的,不会对外暴露,也就是说不会污染全局变量。因此以上math.js模块定义的PI常量不会作为全局变量存在,而是被包裹在NodeJS的模块包装器中,作为局部变量存在。什么是模块包装器会在下文给予说明。

但是存在一种特殊情况,看以下代码。

  1. // math.js

  2. circle = 12

  3. // main.js

  4. const circle = require('./math')

  5. 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对象上,而非严格环境下则会报错。

  1. // math.js

  2. 'use strict'

  3. circle = 12

  4. // main.js

  5. const circle = require('./math')

  6. console.log(circle) // ReferenceError: circle is not defined

上面的代码除了模块包装器的概念还涉及NodeJS模块系统中其他概念,会在下文给予说明。

仍然从上面代码中可以看出,CommonJS规范的模块加载机制是同步加载的。只有math.js模块加载完毕之后,才能继续往下执行对应的逻辑。这在服务端来说没有什么问题,因为在服务端读写的操作主要在本地硬盘中完成,不涉及到网络请求,不受带宽等条件的限制所以加载起来会比较快。但是如果将CommonJS规范运用在浏览器环境,就不太合适了。在浏览器端同步加载服务端的文件意味着阻塞后续代码的执行,如果文件太大就直接导致了浏览器处于空白(假死)的状态,这种在现如今讲究用户体验的情况下是不能忍受的。

因此浏览器环境下就出现了AMD, CMD, ES6模块机制。这里大概的提一下。

AMD意即Asynchronous Module Definition,中文为异步模块定义。AMD规范为浏览器环境提供了全局的define和require方法,其实现有点类似于CommonJS,但是它采用异步方式加载模块,模块的加载不影响后面语句的执行。语法如下。

  1. require([module1, module2, module3, ...], callback)

AMD规范一个很大的特点是 依赖前置,提前执行。也就是说依赖的所有module模块都会提前加载好,在执行callback的内容。如果稍微思考一下这种加载方式,你会发现,如果我在回调函数里面什么也不写,那把模块全部加载好了不是浪费请求了吗?因此原因也就出现了CMD规范。

CMD意即Common Module Definition,中文为通用模块定义。CMD也属于异步加载模块,但是与AMD规范不同的是,CMD规范的特点是 依赖就近,延迟执行。

  1. define(function(require, exports, module) {

  2. const math = require('./math') // 依赖就近书写

  3. math.doSomething()

  4. // 此处略去 100 行

  5. const async = require('async')

  6. async.parallel({...})

  7. })

实现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)和使用严格模式的情况下,模块内部的定义的方法都是该模块下的局部变量。模块包装器其实就是一个匿名函数。

  1. (function(exports, require, module, __filename, __dirname) {

  2. // 模块的代码实际上在这里

  3. });

_filename*,* _dirname这两个其实就没什么好说的了,对应表示着当前模块下文件和文件夹的绝对路径。而对于其他三个参数有必要深究一下

exports:

在模块定义处,exports是一个空对象,用于导出该模块的变量或方法。exports实际上就是module.exports的引用,两者指向同一个内存地址。即

  1. exports === module.exports

而当我们在require一个模块的时候,导出的是module.exports。所以倘若给exports对象重新赋值,会导致exports指向另一个内存地址了,而不再是module.exports的引用了,所以导出的是一个空对象,即module.exports。

  1. // 错误

  2. // a.js

  3. exports = {a: 1} // 指向内存中的另一个地址,与module.exports没有关系了

  4. // b.js

  5. const a = require('./a')

  6. console.log(a) // {} , 即module.exports

  7.  

  8. // 正确

  9. // a.js

  10. exports.a = 1

  11. // b.js

  12. const a = require('./a')

  13. console.log(a) // {a: 1}

module:

上面有谈到,module对象代表文件本身,通过require引入一个模块时,引入的就是module对象上的exports属性。module对象除了exports属性作为模块唯一出口之外,还有其他几个我感觉需要掌握的属性。

module.paths: 官网对paths属性的介绍,只有一句话,模块的搜索路径。真是醉了... 竟然只有一句话: ) 模块的搜索路径意思是当你require一个模块的时候,该模块的查找路径。看一下代码。

  1. // fe/test/index.js

  2. console.log(module.paths)

  3. [ 'E:\\fe\\test\\node_modules',

  4. 'E:\\fe\\node_modules',

  5. '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函数的实现。

  1. Module.prototype.require = function(id) {

  2. ...

  3. // 返回_load函数

  4. return Module._load(id, this, /* isMain */ false);

  5. // 1\. id表示模块id

  6. // 2\. this指向Module实例对象

  7. // 3\. isMain是符号连接的标志

  8.  

  9. };

  10. Module._load = function(request, parent, isMain) {

  11. ...

  12. // _load方法大致逻辑

  13. // _load函数会检查模块是否已经存在于缓存中,

  14. // 如果存在,则直接从Module._cache对象读取,返回module.exports属性

  15. // 如果不存在,则会创建一个模块,并将其放入缓存中,并且加载模块内容之后再返回module.exports

  16. // 属性

  17. return module.exports;

  18. };

除了知道上述require一个模块实际上是加载module.exports对象之外,还应该明白的是require函数的模块分析和模块缓存机制。

模块分析

NodeJs模块分为三类:核心模块、第三方模块和文件模块。

核心模块定义在源码lib/目录下,是NodeJS自身提供的一些常用模块。

第三方模块是通过npm(或其他方式)下载的,保存在node_modules目录下,第三方模块查找路径为Module.path的对应路径

文件模块是通过相对路径'.' '..'和绝对路径'/'为模块名,相对路径是根据当前模块的路径,也就是_dirname*。*可以不写扩展名,Node会根据Module.extensions对象中默认的扩展名进行查找。假如引入了以下模块

  1. 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. 1\. require(id)的id不是非空字符串,则抛出ERR_INVALID_ARG_TYPE的错误

  2. 2\. 检查模块idModule._cache对象中是否存在缓存,如果key等于模块id,则表示缓存存在,

  3. 则返回对应的value值,即module实例对象。以下步骤不会执行。

  4. 3\. 如果key不存在,表示缓存不存在,那么就会调用Module

  5. 构造函数,将返回的module实例对象作为value值传入Module._cache对象中。

  6. 4\. 判断是否是NativeModule(核心模块),如果是,则加载核心模块的module.exports属性。

  7. 从这里可以看出,模块缓存的加载优先于核心模块。以下代码不会执行。

  8. 5\. 调用tryModuleLoad函数加载模块代码,传入module实例对象和filename

  9. 6\. 调用Module.prototype.load方法,传入filename

  10. 7\. 检查filename的扩展名,如果没有传递扩展名,则添加扩展名为.js;如果存在扩展名,但是扩

  11. 展名不在Module._extensions数组内,会将其扩展名修改为.jsModule._extensions数组默认

  12. 扩展名有['.js', '.json', '.node']。这也意味着,除了.json, .node之外,其他形式的文件

  13. 都是js文件。

  14. 8\. 根据不同的扩展名加载不同的文件。

  15. 如果是.json文件,则调用fs.readFileSync同步读取utf8编码的内容,然后通过JSON.parse解析后return

  16. 如果是.node文件,则会调用process.dlopen处理;

  17. 如果是其他文件类型,统一以.js文件处理,调用fs.readFileSync函数同步读取utf8编码后的内容,

  18. 将内容和filename作为参数传入Module.prototype._module函数中。

  19. 9\. 将内容包裹在(function (exports, require, module, __filename, __dirname) { ... })

  20. 模块包装器中,调用NodeJS的核心模块vmrunInThisContext方法,执行内部代码。

  21. runInThisContext方法与window.eval方法类似。

简单的说,上述分析过程主要经历了这么几个过程

  1. 检查是否存在缓存

  2. 检查是否为核心模块

  3. 检查扩展名

  4. 解析执行(根据不同后缀名)

以上稍微分析了一下require的执行流程,简单看完源码可以发现,会有几个重要点:

  1. 缓存优于核心模块加载。

  2. 除了.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 - 博客园。:

模块缓存解决了两件事情:

  1. 多次调用同一个模块时,可以从缓存中读取,这样模块加载速度更快

  2. 循环引用时,不会造成死循环。只执行已经加载的部分,未加载的部分不执行

OK,关于NodeJS模块机制的分析就差不多了,感谢大家的阅读。

参考文章:

  1. 《深入浅出NodeJS - 朴灵》

  2. JavaScript模块化 --- Commonjs、AMD、CMD、ES6 modules

  3. Node.js CommonJS 实现与模块的作用域

     


 

exports 和 module.exports 的区别

进程与线程的一个简单解释

Nodejs内存限制与解决方案

作为一个前端开发到底要不要写测试?

评论:

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

一个小前端
我是一个小前端
zs.duan@qq.com
zs.duan@qq.com
微信
微信
地址
重庆市沙坪坝
微信小程序
微信小程序
我的标签
小程序
harmonyOS
HTML
微信小程序
javaSrcipt
typeSrcipt
vue
uniapp
nodejs
react
防篡改
nginx
mysql
请求加解密