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

1、前言

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

2、正文

在Node.js中,内置了两个模块来进行模块化管理,这两个模块也是两个咱们很是熟悉的关键字:require和module。内置意味着咱们能够在全局范围内使用这两个模块,而无需像其余模块同样,须要先引用再使用。html

无需 require('require') or require('module')

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

const config = require('/path/to/file')

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

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

  • Resolving:找到待引用的目标模块,并生成绝对路径。
  • Loading:判断待引用的模块内容是什么类型,它多是.json文件、.js文件或者.node文件。
  • Wrapping:顾名思义,包装被引用的模块。经过包装,让模块具备私有做用域。
  • Evaluating:被加载的模块被真正的解析和处理执行。
  • Caching:缓存模块,这让咱们在引入相同模块时,不用再重复上述步骤。

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

By the way,若是有须要,能够和我同样,构建一个实验目录,跟着Demo进行实验。缓存

2.1 什么是模块

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

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

~/learn-node $ node
> module
Module {
  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.js
In util
In 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.js
In 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.js
setImmediate(() => {
  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.js
Module1 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.js
var value = 'global'
 
// In b.js
console.log(value)  // 输出:global
 
// In c.js
console.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初始时为空(未赋值),第2、三个参数require和module是和咱们引用的模块相关的实例,它们俩不是全局的。第4、五个参数\_\_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来清空缓存内容,达到从新加载的目的,这里再也不演示。

3、总结

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

做者:vivo-Wei Xing