一、前言

咱们晓得,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