写点什么

Node.js 模块化你所需要知道的事

发布于: 2021 年 03 月 09 日

一、前言


我们知道,Node.js 是基于 CommonJS 规范进行模块化管理的,模块化是面对复杂的业务场景不可或缺的工具,或许你经常使用它,但却从没有系统的了解过,所以今天我们来聊一聊 Node.js 模块化你所需要知道的一些事儿,一探 Node.js 模块化的面貌。


二、正文

在 Node.js 中,内置了两个模块来进行模块化管理,这两个模块也是两个我们非常熟悉的关键字:require 和 module。内置意味着我们可以在全局范围内使用这两个模块,而无需像其他模块一样,需要先引用再使用。

无需 require('require') or require('module')
复制代码


在 Node.js 中引用一个模块并不是什么难事儿,很简单:

const config = require('/path/to/file')
复制代码


但实际上,这句简单的代码执行了一共五个步骤:



了解这五个步骤有助于我们了解 Node.js 模块化的基本原理,也能让我们甄别一些陷阱,让我们简单概括下这五个步骤都做了什么:


  • Resolving:找到待引用的目标模块,并生成绝对路径。

  • Loading:判断待引用的模块内容是什么类型,它可能是.json 文件、.js 文件或者.node 文件。

  • Wrapping:顾名思义,包装被引用的模块。通过包装,让模块具有私有作用域。

  • Evaluating:被加载的模块被真正的解析和处理执行。

  • Caching:缓存模块,这让我们在引入相同模块时,不用再重复上述步骤。


有些同学看完这五个步骤可能已经心知肚明,对这些原理轻车熟路,有些同学心中可能产生了更多疑惑,无论如何,接下来的内容会详细解析上述的执行步骤,希望能帮助大家答疑解惑 or 巩固知识、查缺补漏。


By the way,如果有需要,可以和我一样,构建一个实验目录,跟着 Demo 进行实验。

2.1 什么是模块


想要了解模块化,需要先直观地看看模块是什么。


我们知道在 Node.js 中,文件即模块,刚刚提到了模块可以是.js、.json 或者.node 文件,通过引用它们,可以获取工具函数、变量、配置等等,但是它的具体结构是怎样呢?在命令行中简单执行下面的命令就可以看到模块,也就是 module 对象的结构:


~/learn-node $ node> moduleModule { id: '<repl>', exports: {}, parent: undefined, filename: null, loaded: false, children: [], paths: [ ... ] }
复制代码


可以看到模块也就是一个普通对象,只不过结构中有几个特殊的属性值,需要我们一一去理解,有些属性,例如 id、parent、filename、children 甚至都无需解释,通过字面意思就可以理解。


后续的内容会帮助大家理解这些字段的意义和作用。

2.2 Resolving


大致了解了什么是模块后,我们从第一个步骤 Resolving 开始,了解模块化原理,也就是 Node.js 如何寻找目标模块,并生成目标模块的绝对路径。


那么什么我们刚刚要先打印 module 对象,先让大家了解 module 的结构呢?因为这里有两个字段值 id、paths 和 Resolving 这个步骤息息相关。一起来看看吧。


  • 首先是 id 属性:

每个 module 都有 id 属性,通常这个属性值是模块的完整路径,通过这个值 Node.js 可以标识和定位模块的所在位置。但是在这儿并没有具体的模块,我们只是在命令行中输出了 module 的结构,所以为默认的<repl>值(repl 表示交互式解释器)。


  • 其次是 paths 属性:

这个 paths 属性有什么作用呢?Node.js 允许我们用多种方式来引用模块,比如相对路径、绝对路径、预置路径(马上会解释),假设我们需要引用一个叫做 find-me 的模块,require 如何帮助我们找到这个模块呢?

require('find-me')
复制代码


我们先打印看看 paths 中是什么内容:


~/learn-node $ node> module.paths[ '/Users/samer/learn-node/repl/node_modules', '/Users/samer/learn-node/node_modules', '/Users/samer/node_modules', '/Users/node_modules', '/node_modules', '/Users/samer/.node_modules', '/Users/samer/.node_libraries', '/usr/local/Cellar/node/7.7.1/lib/node' ]
复制代码


ok,其实就是一堆系统绝对路径,这些路径表示了所有目标模块可能出现的位置,并且它们是有序的,这意味着 Node.js 会按序查找 paths 中列出的所有路径,如果找到这个模块,就输出该模块的绝对路径供后续使用。


现在我们知道 Node.js 会在这一堆目录中查找 module,尝试执行 require('find-me')来查找 find-me 模块,由于我们并没有在任何目录放置 find-me 模块,所以 Node.js 在遍历所有目录之后并不能找到目标模块,因此报错 Cannot find module 'find-me',这个错误大家也许经常看到:

~/learn-node $ node> require('find-me')Error: Cannot find module 'find-me'    at Function.Module._resolveFilename (module.js:470:15)    at Function.Module._load (module.js:418:25)    at Module.require (module.js:498:17)    at require (internal/module.js:20:19)    at repl:1:1    at ContextifyScript.Script.runInThisContext (vm.js:23:33)    at REPLServer.defaultEval (repl.js:336:29)    at bound (domain.js:280:14)    at REPLServer.runBound [as eval] (domain.js:293:12)    at REPLServer.onLine (repl.js:533:10)
复制代码


现在,可以尝试把需要引用的 find-me 模块放在上述的任意一个目录下,在这里我们创建一个 node_modules 目录,并创建 find-me.js 文件,让 Node.js 能够找到它:

~/learn-node $ mkdir node_modules ~/learn-node $ echo "console.log('I am not lost');" > node_modules/find-me.js ~/learn-node $ node> require('find-me');I am not lost{}>
复制代码


手动创建了 find-me.js 文件后,Node.js 果然找到了目标模块。当然,当 Node.js 本地的 node_modules 目录中找到了 find-me 模块,就不会再去后续的目录中继续寻找了。


有 Node.js 开发经验的同学会发现在引用模块时,不一定非得指定到准确的文件,也可以通过引用目录来完成对目标模块的引用,例如:

~/learn-node $ mkdir -p node_modules/find-me ~/learn-node $ echo "console.log('Found again.');" > node_modules/find-me/index.js ~/learn-node $ node> require('find-me');Found again.{}>
复制代码


find-me 目录下的 index.js 文件会被自动引入。


当然,这是有规则限制的,Node.js 之所以能够找到 find-me 目录下的 index.js 文件,是因为默认的模块引入规则是当具体的文件名缺失时寻找 index.js 文件。我们也可以更改引入规则(通过修改 package.json),比如把 index -> main:

~/learn-node $ echo "console.log('I rule');" > node_modules/find-me/main.js ~/learn-node $ echo '{ "name": "find-me-folder", "main": "main.js" }' > node_modules/find-me/package.json ~/learn-node $ node> require('find-me');I rule{}>
复制代码

2.3 require.resolve


如果你只想要在项目中引入某个模块,而不想立即执行它,可以使用 require.resolve 方法,它和 require 方法功能相似,只是并不会执行被引入的模块方法:

> require.resolve('find-me');'/Users/samer/learn-node/node_modules/find-me/start.js'> require.resolve('not-there');Error: Cannot find module 'not-there'    at Function.Module._resolveFilename (module.js:470:15)    at Function.resolve (internal/module.js:27:19)    at repl:1:9    at ContextifyScript.Script.runInThisContext (vm.js:23:33)    at REPLServer.defaultEval (repl.js:336:29)    at bound (domain.js:280:14)    at REPLServer.runBound [as eval] (domain.js:293:12)    at REPLServer.onLine (repl.js:533:10)    at emitOne (events.js:101:20)    at REPLServer.emit (events.js:191:7)>
复制代码


可以看到,如果该模块被找到了,Node.js 会打印模块的完整路径,如果未找到,就报错。


了解了 Node.js 是如何寻找模块之后,来看看 Node.js 是如何加载模块的。

2.4 模块间的父子依赖关系


我们把模块间引用关系,表示为父子依赖关系。


简单创建一个 lib/util.js 文件,添加一行 console.log 语句,标识这是一个被引用的子模块。

~/learn-node $ mkdir lib~/learn-node $ echo "console.log('In util');" > lib/util.js
复制代码


在 index.js 也输入一行 console.log 语句,标识这是一个父模块,并引用刚刚创建的 lib/util.js 作为子模块。


~/learn-node $ echo "require('./lib/util'); console.log('In index, parent', module);" > index.js
复制代码

执行 index.js,看看它们间的依赖关系:

~/learn-node $ node index.jsIn utilIn index <ref *1> Module {  id: '.',  path: '/Users/samer/',  exports: {},  parent: null,  filename: '/Users/samer/index.js',  loaded: false,  children: [    Module {      id: '/Users/samer/lib/util.js',      path: '/Users/samer/lib',      exports: {},      parent: [Circular *1],      filename: '/Users/samer/lib/util.js',      loaded: true,      children: [],      paths: [Array]    }  ],  paths: [...]}
复制代码

在这里我们关注与依赖关系相关的两个属性:children 和 parent。


在打印的结果中,children 字段包含了被引入的 util.js 模块,这表明了 util.js 是 index.js 所依赖的子模块。


但仔细观察 util.js 模块的 parent 属性,发现这里出现了 Circular 这个值,原因是当我们打印模块信息时,产生了循环的依赖关系,在子模块信息中打印父模块信息,又要在父模块信息中打印子模块信息,所以 Node.js 简单地将它处理标记为 Circular。


为什么需要了解父子依赖关系呢?因为这关系到 Node.js 是如何处理循环依赖关系的,后续会详细描述。


在看循环依赖关系的处理问题之前,我们需要先了解两个关键的概念:exports 和 module.exports。

2.5 exports, module.exports


  • exports:

exports 是一个特殊的对象,它在 Node.js 中可以无需声明,作为全局变量直接使用。它实际上是 module.exports 的引用,通过修改 exports 可以达到修改 module.exports 的目的。


exports 也是刚刚打印的 module 结构中的一个属性值,但是刚刚打印出来的值都是空对象,因为我们并没有在文件中对它进行操作,现在我们可以尝试简单地为它赋值:

// 在lib/util.js的开头新增一行exports.id = 'lib/util'; // 在index.js的开头新增一行exports.id = 'index';
复制代码


执行 index.js:

~/learn-node $ node index.jsIn index Module {  id: '.',  exports: { id: 'index' },  loaded: false,  ... }In util Module {  id: '/Users/samer/learn-node/lib/util.js',  exports: { id: 'lib/util' },  parent:   Module {     id: '.',     exports: { id: 'index' },     loaded: false,     ... },  loaded: false,  ... }
复制代码


可以看到刚刚添加的两个 id 属性被成功添加到 exports 对象中。我们也可以添加除 id 以外的任意属性,就像操作普通对象一样,当然也可以把 exports 变成一个 function,例如:

exports = function() {}
复制代码


  • module.exports:

module.exports 对象其实就是我们最终通过 require 所得到的东西。我们在编写一个模块时,最终给 module.exports 赋什么值,其他人引用该模块时就能得到什么值。例如,结合刚刚对 lib/util 的操作:

const util = require('./lib/util'); console.log('UTIL:', util); // 输出结果 UTIL: { id: 'lib/util' }
复制代码


由于我们刚刚通过 exports 对象为 module.exports 赋值{id: 'lib/util'},因此 require 的结果就相应地发生了变化。


现在我们大致了解了 exports 和 module.exports 都是什么,但是有一个小细节需要注意,那就是 Node.js 的模块加载是个同步的过程。


我们回过头来看看 module 结构中的 loaded 属性,这个属性标识这个模块是否被加载完成,通过这个属性就能简单验证 Node.js 模块加载的同步性。


当模块被加载完成后,loaded 值应该为 true。但到目前为止每次我们打印 module 时,它的状态都是 false,这其实正是因为在 Node.js 中,模块的加载是同步的,当我们还未完成加载的动作(加载的动作包括对 module 进行标记,包括标记 loaded 属性),因此打印出的结果就是默认的 loaded: false。


我们用 setImmediate 来帮助我们验证这个信息:

// In index.jssetImmediate(() => {  console.log('The index.js module object is now loaded!', module)});
复制代码


The index.js module object is now loaded! Module {  id: '.',  exports: [Function],  parent: null,  filename: '/Users/samer/learn-node/index.js',  loaded: true,  children:   [ Module {       id: '/Users/samer/learn-node/lib/util.js',       exports: [Object],       parent: [Circular],       filename: '/Users/samer/learn-node/lib/util.js',       loaded: true,       children: [],       paths: [Object] } ],  paths:   [ '/Users/samer/learn-node/node_modules',     '/Users/samer/node_modules',     '/Users/node_modules',     '/node_modules' ] }
复制代码


ok,由于 console.log 被后置到加载完成(打完标记)之后,因此现在加载状态变成了 loaded: true。这充分验证了 Node.js 模块加载是一个同步过程。


了解了 exports、module.exports 以及模块加载的同步性后,来看看 Node.js 是如何处理模块的循环依赖关系。

2.6 模块循环依赖


在上述内容中,我们了解到了模块之间是存在父子依赖关系的,那如果模块之间产生了循环的依赖关系,Node.js 会怎么处理呢?假设有两个模块,分别为 module1.js 和 modole2.js,并且它们互相引用了对方,如下:

// lib/module1.js exports.a = 1; require('./module2'); // 在这儿引用 exports.b = 2;exports.c = 3; // lib/module2.js const Module1 = require('./module1');console.log('Module1 is partially loaded here', Module1); // 引用module1并打印它
复制代码


尝试运行 module1.js,可以看到输出结果:

~/learn-node $ node lib/module1.jsModule1 is partially loaded here { a: 1 }
复制代码


结果中只输出了{a: 1},而{b: 2, c: 3}却不见了。仔细观察 module1.js,发现我们在 module1.js 的中间位置添加了对 module2.js 的引用,也就是 exports.b = 2 和 exports.c = 3 还未执行之前的位置。如果我们把这个位置称作发生循环依赖的位置,那么我们得到的结果就是在循环依赖发生前被导出的属性,这也是基于我们上述验证过的 Node.js 的模块加载是同步过程的结论。


Node.js 就是这样简单地处理循环依赖。在加载模块的过程中,会逐步构建 exports 对象,为 exports 赋值。如果我们在模块被完全加载前就引用这个模块,那么我们只能得到部分的 exports 对象属性。


2.7 .json 和.node


在 Node.js 中,我们不仅能用 require 来引用 JavaScript 文件,还能用于引用 JSON 或 C++插件(.json 和.node 文件)。我们甚至都不需要显式地声明对应的文件后缀。


在命令行中也可以看到 require 所支持的文件类型:

~ % node> require.extensions[Object: null prototype] {  '.js': [Function (anonymous)],  '.json': [Function (anonymous)],  '.node': [Function (anonymous)]}
复制代码


当我们用 require 引用一个模块,首先 Node.js 会去匹配是否有.js 文件,如果没有找到,再去匹配.json 文件,如果还没找到,最后再尝试匹配.node 文件。但是通常情况下,为了避免混淆和引用意图不明,可以遵循在引用.json 或.node 文件时显式地指定后缀,引用.js 时省略后缀(可选,或都加上后缀)。


  • .json 文件:


引用.json 文件很常用,例如一些项目中的静态配置,使用.json 文件来存储更便于管理,例如:

{  "host": "localhost",  "port": 8080}
复制代码

引用它或使用它都很简单:

const { host, port } = require('./config');console.log(`Server will run at http://${host}:${port}`)
复制代码

输出如下:

Server will run at http://localhost:8080
复制代码


  • .node 文件:

.node 文件是由 C++文件转化而来,官网提供了一个简单的由 C++实现的 hello插件 ,它暴露了一个 hello()方法,输出字符串 world。有需要的话,可以跳转链接做更多了解并进行实验。


我们可以通过 node-gyp 来将.cc 文件编译和构建成.node 文件,过程也非常简单,只需要配置一个 binding.gyp 文件即可。这里不详细阐述,只需要知道生成.node 文件后,就可以正常地引用该文件,并使用其中的方法。


例如,将 hello()转化生成 addon.node 文件后,引用并使用它:

const addon = require('./addon');console.log(addon.hello());
复制代码


2.8 Wrapping

其实在上述内容中,我们阐述了在 Node.js 中引用一个模块的前两个步骤 Resolving 和 Loading,它们分别解决了模块的路径和加载的问题。接下来看看 Wrapping 都做了什么。

Wrapping 就是包装,包装的对象就是所有我们在模块中写的代码。也就是我们引用模块时,其实经历了一层『透明』的包装。


要了解这个包装过程,首先要理解 exports 和 module.exports 之间的区别。


exports 是对 module.exports 的引用,我们可以在模块中使用 exports 来导出属性,但是不能直接替换它。例如:

exports.id = 42; // ok,此时exports指向module.exports,相当于修改了module.exports.exports = { id: 42 }; // 无用,只是将它指向了{ id: 42 }对象而已,对module.exports不会产生实际改变.module.exports = { id: 42 }; // ok,直接操作module.exports.
复制代码


大家也许会有疑惑,为什么这个 exports 对象似乎对每个模块来说都是一个全局对象,但是它又能够区分导出的对象是来自于哪个模块,这是怎么做到的。


在了解包装(Wrapping)过程之前,来看一个小例子:

// In a.jsvar value = 'global' // In b.jsconsole.log(value)  // 输出:global // In c.jsconsole.log(value)  // 输出:global // In index.html...<script src="a.js"></script><script src="b.js"></script><script src="c.js"></script>
复制代码


当我们在 a.js 脚本中定义一个值 value,这个值是全局可见的,后续引入的 b.js 和 c.js 都是可以访问该 value 值。但是在 Node.js 模块中却并不是这样,在一个模块中定义的变量具有私有作用域,在其它模块中无法直接访问。这个私有作用域如何产生的?


答案很简单,是因为在编译模块之前,Node.js 将模块中的内容包装在了一个 function 中,通过函数作用域实现了私有作用域。


通过 require('module').wrapper 可以打印出 wrapper 属性:

~ $ node> require('module').wrapper[ '(function (exports, require, module, __filename, __dirname) { ',  '\n});' ]>
复制代码


Node.js 不会直接执行文件中的任何代码,但它会通过这个包装后的 function 来执行代码,这让我们的每个模块都有了私有作用域,不会互相影响。


这个包装函数有五个参数:exports, require, module, __filename, __dirname。我们可以通过 arguments 参数直接访问和打印这些参数:

/learn-node $ echo "console.log(arguments)" > index.js ~/learn-node $ node index.js{ '0': {},  '1':   { [Function: require]     resolve: [Function: resolve],     main:      Module {        id: '.',        exports: {},        parent: null,        filename: '/Users/samer/index.js',        loaded: false,        children: [],        paths: [Object] },     extensions: { ... },     cache: { '/Users/samer/index.js': [Object] } },  '2':   Module {     id: '.',     exports: {},     parent: null,     filename: '/Users/samer/index.js',     loaded: false,     children: [],     paths: [ ... ] },  '3': '/Users/samer/index.js',  '4': '/Users/samer' }
复制代码

简单了解一下这几个参数,第一个参数 exports 初始时为空(未赋值),第二、三个参数 require 和 module 是和我们引用的模块相关的实例,它们俩不是全局的。第四、五个参数__filename 和__dirname 分别表示了文件路径和目录。


整个包装后的函数所做的事儿约等于:

unction (require, module, __filename, __dirname) {  let exports = module.exports;     // Your Code...     return module.exports;}
复制代码


总而言之,wrapping 就是将我们的模块作用域私有化,以 module.exports 作为返回值将变量或方法暴露出来,以供使用。

2.9 Cache


缓存很容易理解,通过一个案例来看看吧:

echo 'console.log(`log something.`)' > index.js// In node repl> require('./index.js')log something.{}> require('./index.js'){}>
复制代码

可以看到,两次引用同一个模块,只打印了一次信息,这是因为第二次引用时取的是缓存,无需重新加载模块。


打印 require.cache 可以看到当前的缓存信息:

> require.cache[Object: null prototype] {  '/Users/samer/index.js': Module {    id: '/Users/samer/index.js',    path: '/Users/samer/',    exports: {},    parent: Module {      id: '<repl>',      path: '.',      exports: {},      parent: undefined,      filename: null,      loaded: false,      children: [Array],      paths: [Array]    },    filename: '/Users/samer/index.js',    loaded: true,    children: [],    paths: [      '/Users/samer/learn-node/repl/node_modules',      '/Users/samer/learn-node/node_modules',      '/Users/samer/node_modules',      '/Users/node_modules',      '/node_modules',      '/Users/samer/.node_modules',      '/Users/samer/.node_libraries',      '/usr/local/Cellar/node/7.7.1/lib/node'    ]  }}
复制代码


可以看到刚刚引用的 index.js 文件处于缓存当中,因此不会重新加载模块。当然我们也可以通过删除 require.cache 来清空缓存内容,达到重新加载的目的,这里不再演示。


三、总结


本文概述了使用 Node.js 模块化时需要了解到的一些基本原理和常识,希望帮助大家对 Node.js 模块化有更清晰的认识。但更深入的细节并未在本文中阐述,例如 wrapper 函数内部的处理逻辑,CommonJS 的同步加载的问题、与 ES 模块的区别等等。这些未提到的内容大家可以在本文以外做更多探索。


作者:vivo-Wei Xing


发布于: 2021 年 03 月 09 日阅读数: 21
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
Node.js 模块化你所需要知道的事