详解NodeJS模块化


Posted in NodeJs onJune 15, 2021

一、前言

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

二、正文

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

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

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

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

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

详解NodeJS模块化

了解这五个步骤有助于我们了解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模块的区别等等。这些未提到的内容大家可以在本文以外做更多探索。

以上就是详解NodeJS模块化的详细内容,更多关于NodeJS 模块化的资料请关注三水点靠木其它相关文章!

NodeJs 相关文章推荐
用nodejs写的一个简单项目打包工具
May 11 NodeJs
14款NodeJS Web框架推荐
Jul 11 NodeJs
Nodejs极简入门教程(二):定时器
Oct 25 NodeJs
iPhone手机上搭建nodejs服务器步骤方法
Jul 06 NodeJs
用nodeJS搭建本地文件服务器的几种方法小结
Mar 16 NodeJs
nodejs判断文件、文件夹是否存在及删除的方法
Nov 10 NodeJs
使用nodeJs来安装less及编译less文件为css文件的方法
Nov 20 NodeJs
修改Nodejs内置的npm默认配置路径方法
May 13 NodeJs
nodejs 生成和导出 word的实例代码
Jul 31 NodeJs
详解nodejs 开发企业微信第三方应用入门教程
Mar 12 NodeJs
nodejs微信开发之自动回复的实现
Mar 17 NodeJs
浅谈JS和Nodejs中的事件驱动
May 05 NodeJs
NodeJs内存占用过高的排查实战记录
详解nodejs内置模块
May 06 #NodeJs
浅谈JS和Nodejs中的事件驱动
May 05 #NodeJs
ubuntu系统下使用pm2设置nodejs开机自启动的方法
May 12 #NodeJs
NodeJS和浏览器中this关键字的不同之处
Mar 03 #NodeJs
nodejs处理tcp连接的核心流程
Feb 26 #NodeJs
Nodejs 数组的队列以及forEach的应用详解
Feb 25 #NodeJs
You might like
php 论坛采集程序 模拟登陆,抓取页面 实现代码
2009/07/09 PHP
php判断电脑访问、手机访问的例子
2014/05/10 PHP
mysql输出数据赋给js变量报unterminated string literal错误原因
2010/05/22 Javascript
Javascript的一种模块模式
2010/09/08 Javascript
js实现局部页面打印预览原理及示例代码
2014/07/03 Javascript
jQuery实现异步获取json数据的2种方式
2014/08/29 Javascript
jQuery大于号(&gt;)选择器的作用解释
2015/01/13 Javascript
jQuery Timelinr实现垂直水平时间轴插件(附源码下载)
2016/02/16 Javascript
javascript深拷贝和浅拷贝详解
2017/02/14 Javascript
通过js控制时间,一秒一秒自己动的实例
2017/10/25 Javascript
vue.js使用v-model指令实现的数据双向绑定功能示例
2018/05/22 Javascript
js实现简单选项卡功能
2020/03/23 Javascript
vue在自定义组件中使用v-model进行数据绑定的方法
2019/03/25 Javascript
vue实现路由切换改变title功能
2019/05/28 Javascript
基于Nuxt.js项目的服务端性能优化与错误检测(容错处理)
2019/10/23 Javascript
JavaScript, select标签元素左右移动功能实现
2020/05/14 Javascript
vuejs实现下拉框菜单选择
2020/10/23 Javascript
vue中利用three.js实现全景图的完整示例
2020/12/07 Vue.js
Python实现提取文章摘要的方法
2015/04/21 Python
python函数中return后的语句一定不会执行吗?
2017/07/06 Python
浅析python3中的os.path.dirname(__file__)的使用
2018/08/30 Python
Python修改文件往指定行插入内容的实例
2019/01/30 Python
使用python进行波形及频谱绘制的方法
2019/06/17 Python
Python利用 utf-8-sig 编码格式解决写入 csv 文件乱码问题
2020/02/21 Python
HEMA英国:荷兰原创设计
2018/08/28 全球购物
新西兰廉价汽车租赁:Snap Rentals
2018/09/14 全球购物
介绍一下Java的安全机制
2012/06/28 面试题
12月小学生校园广播稿
2014/02/04 职场文书
八项规定整改措施
2014/02/12 职场文书
计算机通信专业推荐信
2014/02/22 职场文书
无财产离婚协议书范本
2014/10/28 职场文书
幼儿园保教工作总结2015
2015/10/15 职场文书
2016大一新生入学教育心得体会
2016/01/23 职场文书
ROS系统将python包编译为可执行文件的简单步骤
2021/07/25 Python
实现AJAX异步调用和局部刷新的基本步骤
2022/03/17 Javascript
Python编写车票订购系统 Python实现快递收费系统
2022/08/14 Python