乐趣区

关于javascript:Nodejs-模块化你所需要知道的事

一、前言

咱们晓得,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
> 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 初始时为空(未赋值),第二、三个参数 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

退出移动版