这些在Node.js中 require 的机制,你知道了吗

原文地址:medium.freecodecamp.org/requiring-m…

node中采用了两个核心模块来管理模块依赖:

  • require模块:全局可见,不需要额外使用require('require')
  • module模块:全局可见,不需要额外使用require('module')

可以认为require模块是一个command,module模块是所需模块的organizer。 在Node中引用模块并不是一件复杂的事情:const config = require('/path/to/file'); require模块暴露出一个函数(就像上面看到的那样)。当require()函数传入一个path参数的时候,node会依次执行如下步骤:

  • Resolving : 找到path的绝对路径。
  • Loading: 确定文件的内容。
  • Wrapping:构造私有的作用域。Wrapping可以确保每次require文件的时候,require和exports都是私有的。
  • Evaluating:evaluating环节是VM处理已加载文件的最后一个环节。
  • Caching:为了避免引用相同的文件情况下,不重复执行上面的步骤。

本文中笔者将通过案例讲解上面提到的不同阶段以及这些阶段对于开发者开发node模块的影响。 首先通过终端创建一个文件夹mkdir ~/learn-node && cd ~/learn-node 下面本文所有的命令都是在~/learn-node中执行。

Resolving a local path

首先来介绍下module对象。读者可以通过REPL来看到module对象

每个module对象都有id属性用来区分不同的模块。id属性一般都是模块对应的绝对路径,在REPL中会简单的设置为<repl>。Node模块和系统磁盘上的文件是一一对应的。引用模块实际上会将文件中的内容加载到内存中。node支持通过多种方式来引用文件(比如说通过相对路径或者预配置路径),在把文件中内容加载到内存之前,需要先找到文件的绝对路径。 当不指定路径直接引用find-me模块的时候:require('find-me'); node会依次遍历module.paths指定的路径来寻找find-me模块:

上面的路径是从当前路径到根目录所有目录下的node_modules文件夹的路径,除此之外也包括一些遗留的但是已经不推荐使用的路径。如果node在上述路径中都找不到find-me.js就会抛出一个“cannot find module error.”的异常。

如果在当前文件夹下创建一个node_modules文件夹,并创建一个find-me.js文件,这时require('find-me')就能够找到find-me了。

如果其他路径下也存在find-me.js 比如在用户的home目录下的node_modules文件夹下面存在另外一个find-me.js:

当在learn-code目录下执行require('find-me'),由于在learn-code下的node_modules目录下有一个find-me.js,此时用户的home目录下的find-me.js并不会加载执行。

如果我们从~/learn-code目录下删除node_modules文件夹,再执行引用find-me,则会使用用户home目录下的node_modules下的fine-me:

Requiring a folder

模块不一定只是一个文件,读者也可以创建一个find-me文件夹,并且在文件夹中创建index.js,require('find-me')的时候会引用index.js:

注意此时由于当前目录下有了find-me, 则此时引用find-me会忽略用户home目录下的node_modules。当引用目录的时候,默认情况下会寻找index.js,但是我们可以通过package.json中的main属性来指示用那个文件。举个例子,为了让require('find-me')能够解析到find-me文件夹下的其他文件,我们需要在find-me目录下加一个package.json,并指定应该解析到哪个文件:

require.resolve

如果只想解析模块但不执行模块,可以使用require.resolve函数。resolverequire函数的表现除了不执行文件之外,其他方面表现是一致的。当文件找不到的时候仍然会抛出一个异常,在找到文件的情况下会返回文件的绝对路径。

resolve函数可以用来检测是否安装了某个模块,并在检查到模块的情况下使用已安装的模块。

Relative and absolute paths

除了从node_modules中解析出模块,我们也可以把模块放在任何地方,通过相对路径(./或者../打头)或者绝对路径(/打头)的方式来引用该模块。 比如,如果find-me.js在lib目录下而不是在node_modules目录下,我们可以通过这种方式来引用find-me:require('./lib/find-me');

Parent-child relation between files

创建一个lib/util.js并加入一行console.log来做区分,同时输出module对象:

在index.js也加入类似的代码,后面我们通过node执行index.js。在index.js中引用lib/util.js:

在node中执行index.js:

注意index模块(id: '.')lib/util模块的父模块。但是输出结果中lib/util模块并没有显示在index模块的子模块中。取而代之的是一个[Circular]的值,因为这儿是一个循环引用。此时如果node打印lib/utilindex的子模块的话,则会进入到死循环。这也可以解释了为什么需要简单的用[Circular]来代替lib/util。 那么如果在lib/util模块中引用index模块会发生什么。这就是node中所允许的的循环引用。

为了能够更好的理解循环依赖,首先需要了解一些关于module对象上的一些概念。

exports, module.exports, and synchronous loading of modules

任何模块中exports都是一个特别的对象。注意到上面的结果中,每次打印module对象,都会有一个为空对象的exports属性。我们可以在这个特别的exports对象上加入一些属性。比如为index.jslib/index.js暴露id属性。

现在再执行index.js, 就能看到每个文件的module对象上新增的属性:

这里为了简洁,笔者删除了输出结果中的一些属性,但是可以看到exports对象现在就有了我们之前定义的属性。你可以在exports对象上增加任意数量的属性,也可以把整个exports对象替换成其他东西。比如说想要把exports对象换成一个函数可以如下:

再执行index.js就可以看到exports对象变成了一个函数:

这里把exports对象替换成函数并不是通过exports = function(){}来完成的。实际上我们也不能这么做,因为模块中的exports对象只是module.exports的引用,而module.exports才是负责暴露出来的属性。当我们给exports对象重新赋值的时候,会断开对module.exports的引用,这种情况下只是引入了一个新的变量而不是修改module.exports属性。

当我们引入某个模块,require函数返回的实际上是module.exports对象。举个例子,把index.js中require('./lib/util')修改为:

上面的代码把lib/util中暴露出来的属性赋值给UTIL常量。当我们执行index.js时,最后一行会返回如下结果:UTIL: { id: 'lib/util' }

下面来谈谈每个module对象上的loaded属性。到目前为止,每次我们打印module对象的时候,loaded属性都是为false。module对象通过loaded属性来记录哪些模块已经加载(loaded为true),哪些模块还未加载(loaded为false)。可以通过setImmediate方法来再下一个event loop中看到模块已经加载完成的信息。

输出结果如下:

再延迟的console.log中我们可以看到lib/util.jsindex.js已经被完全加载。 当node加载模块完成后,exports对象也会变成已完成状态。requiring和loading的过程是同步的。这也是为什么我们能够在一个循环之后能够看到模块加载完成信息的原因。

同时这也意味着我们不能异步的修改exports对象。比如我们不能像下面这么做:

Circular module dependency

下面来回答前面提到的循环依赖的问题:如果模块1依赖模块2,同时模块2又依赖模块1,这时候会发生什么呢? 为了找到答案,我们在lib目录下创建两个文件,module1.jsmodule2.js,并让他们互相引用:

当执行module1.js的时候,会看到如下结果:

我们在module1还没有完全加载成功的情况下引用module2,由于module2中在module1还没有完全加载成功的情况就引用module1,此时在module2中能够得到的exports对象是循环依赖之前的全部属性(也就是require('module2')之前)。此时只能访问到a属性,因为bc属性在require('module2')之后。

node在循环依赖这块的处理十分简单。你可以引用哪些还没有完全加载的模块,但是只能拿到一部分属性。

JSON and C/C++ addons

通过require函数我们可以原生的加载JSONC++扩展。使用的时候甚至不需要指定扩展名。在文件扩展名没有指定的情况下,node首先会尝试加载.js的文件。如果.js的文件没有找到,则会尝试加载.json文件,如果找到.json文件则会解析.json文件。如果.json文件也没有找到,则会尝试加载.node文件。但是为了避免语义模糊,开发者应该在非.js的情况下指定文件的扩展名。

加载.json文件对于管理静态配置、或者周期性的从外部文件中读取配置的场景是十分有用的。比如我们有如下json文件:

我们可以直接使用它:

运行上面的代码会输出:Server will run at http://localhost:8080 如果node找不到.js.json的情况下,会寻找.node文件,并采用解析node扩展的方式来解析.node文件。

Node 官方文档中有一个c++写的扩展案例。该案例暴露了一个hello()函数,执行hello()函数会输出world。你可以使用node-gyp.cc文件编译、构建成.node文件。开发者需要配置binding.gyp来告诉node-gyp该做什么。在构建addon.node成功后,就可以像引用其他模块一样使用:

require.extensions可以看到目前只支持三种类型的扩展:

可以看到每种类型都有不同的加载函数。对于.js文件使用module._compile方法,对于.json文件使用JSON.parse方法,对于.node文件使用process.dlopen方法。

All code you write in Node will be wrapped in functions

node中对模块的包裹常常被误解,在理解node对模块的包裹之前,先来回顾下exports/module.exports的关系。 我们可以用exports来暴露属性,但是不能直接替换exports对象,因为exports对象只是对module.exporst的引用。

准确的来说,exports对象对于每个模块来说是全局的,定义为module对象上属性的引用。 在解释node包装过程前,我们再来问一个问题。 在浏览器中,当我们在全局环境中申明一个变量:var answer = 42; 在定义answer变量之后的脚本中,answer变量就属于全局变量。 在node中并不是这样的。当我们在一个模块中定义了一个变量,另外的模块并不能直接访问该模块中的变量,那么在node中变量是如何被局部化的呢?

答案很简单。在编译模块之前,node把模块代码包装在一个函数中,我们可以通过module对象上的wrapper属性来看到这个函数:

node并不会直接执行你写在文件中的代码。而是执行包裹函数的代码,包裹函数会把你写的代码包装在函数体中。这就保证了任何模块中的顶级变量对于别的模块来说是局部的。

wrapper函数有5个参数:exports,require,module,__filename__dirname。这也是为什么对于每个模块来说,这些变量都像是全局的原因,实际上对每个模块来说,这些变量都是独立的。

当node执行包装函数的时候,这些变量都已经被正确赋值。exports被定义为module.exports的引用,requiremodule都指向待执行的函数,__filename__dirname表示了被包裹模块的文件名和目录的路径。

如果你运行了一个出错的模块,立马就能看到包裹函数。

可以看到报错的是wrapper函数的第一行。除此之外,由于每个模块都被函数包裹了一遍,我们可以通过arguments来访问wrapper函数所有的参数。

第一个参数是exports对象,一开始是一个空对象,接着是require/module对象,这两个对象不是全局变量,都是与index.js相关的实例化对象。最后两个参数表示文件路径和文件夹路径。

包裹函数的返回值是module.exporst。在包裹函数内部,我们可以通过exports对象来修改module.exports的属性,但是不能对exports重新赋值,因为exports只是一个引用。

上面描述的等价于下面的代码:


如果我们修改了exports对象,则exports对象不再是module.exports的引用。这种引用的方式不仅在这里可以正常工作,在javascript中都是可以正常工作的。

The require object

require对象并没有什么特殊的。require是一个函数对象,接受模块名或者路径名,并返回module.exports对象。如果我们想的话,可以随意的覆盖require对象。 比如为了测试,我们希望可以mock require函数的默认行为,返回一个模拟的对象,而不是引用模块返回module.exports对象。对require进行赋值可以实现这一目的:

在对require进行重新赋值之后,每次调用require('something')都会返回mock对象。 require对象也有自身的属性。前面我们已经看到过了用于解析模块路径的resolve属性以及require.extensions属性。 除此之外,还有require.main属性用来区别当前模块是被引用还是直接运行的。比如说我们在print-in-frame.js文件中有一个printInFrame函数:

这个函数接受一个数值类型的参数numberic和一个字符串类型的参数header,函数中首先根据size参数打印指定个数*的frame,并在frame中打印header。 我们可以有两种方式来使用这个函数:

  1. 命令行直接调用:~/learn-node $ node print-in-frame 8 Hello命令行中给函数传入8和Hello,打印一个8个*组成的frame,并在frame中输出hello
  2. require方式调用:假设print-in-frame.js暴露出一个printInFrame函数,我们可以这样调用:

这样会在5个*组成的frame 中打印Hey。 我们需要某种方式来区分当前模块是命令行单独调用还是被其他模块引用的。这种情况,我们可以通过require.main 来做判断:

这样我们可以通过这个条件表达式来实现上述应用场景:

如果当前模块没有以模块的方式被其他模块引用,我们可以根据命令行参数process.argv来调用printInFrame函数。否则,我们设置module.exports参数为printInFrame函数。

All modules will be cached

理解模块缓存是十分重要的。我们来通过一个简单的例子来讲解下,比如说我们有一个如下的字符画的js文件:

我们希望每次require文件的时候都能显示字符画。比如我们引用两次字符画的js,希望可以输出两次字符画:

第二次引用并不会输出字符画,因为此时模块已经被缓存了。在第一次引用后,我们可以通过require.cache来查看模块缓存情况。cache对象是一个简单的键值对,每次引用的模块都会被缓存在这个对象上。cache上的值就是每个模块对应的module对象。我们可以从require.cache上移除module对象来让缓存失效。如果我们从缓存中缓存中移除module对象,重新require的时候,node依然会重新加载该模块,并重新缓存该模块。 但是,对于这种情况,上面的修改缓存的方式并不是最好的方法。最简单的方法是把ascii-art.js包装在函数中然后暴露出去,这样的话,当我们引用ascii-art.js的时候,会得到一个函数,每次执行的时候都会输出字符画。

参与评论