Nodejs 模块机制

nodejs 模块机制

简单模块定义和使用

在Node.js中,定义一个模块十分方便。我们以计算圆形的面积和周长两个方法为例,来表现Node.js中模块的定义方式。

var PI = Math.PI;
exports.area = function (r) {
    return PI * r * r;
};
exports.circumference = function (r) {
    return 2 * PI * r;
};

将这个文件存为circle.js,然后新建一个app.js文件,并写入以下代码:

var circle = require('./circle.js');
console.log( 'The area of a circle of radius 4 is ' + circle.area(4));

可以看到模块调用也十分方便,只需要require需要调用的文件即可。

在require了这个文件之后,定义在exports对象上的方法便可以随意调用。Node.js将模块的定义和调用都封装得极其简单方便,从API对用户友好这一个角度来说,Node.js的模块机制是非常优秀的。

关于exports的内容,可以参考之前的文章 exports && module.exports

模块分类

核心模块

核心模块优先级仅次于缓存加载,因此无法加载一个和核心模块标识符相同的自定义模块。

路径形式的文件模块

以"."、".."开头和"/"开始的标识符,这里都被当作文件模块来处理。require()方法会将路径转为真实路径,并以真实路径作为索引,并将编译执行后的结果存放到缓存中。

自定义模块(特殊的文件模块)

自定义模块是指非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或者包的形式。
模块路径是Node在定位文件模块的具体文件时制定的查找策略,具体表现为一个路径组成的数组(module.paths)。这个路径由当前目录开始往上一直到根目录,Node会逐个尝试模块路径中的路径,直到找到目标文件未知,若到达根目录还是没有找到目标文件,则会抛出查找失败的异常。当前文件的目录越深,模块查找耗时越多。

模块载入策略

上文中说道,Node.js的模块分为两类,一类为原生(核心)模块,一类为文件模块。

原生模块在Node.js源代码编译的时候编译进了二进制执行文件,加载的速度最快。另一类文件模块是动态加载的,加载速度比原生模块慢。但是Node.js对原生模块和文件模块都进行了缓存,于是在第二次require时,是不会有重复开销的。由于通过命令行加载启动的文件几乎都为文件模块。我们从Node.js如何加载文件模块开始谈起。

我们从命令行启动上文的app.js文件。

node app.js

加载文件模块的工作,主要由原生模块module来实现和完成,该原生模块在启动时已经被加载,进程直接调用到runMain静态方法。

// bootstrap main module.
Module.runMain = function () {
    // Load the main module--the command line argument.
    Module._load(process.argv[1], null, true);
};

_load静态方法在分析文件名之后执行

var module = new Module(id, parent);

并根据文件路径缓存当前模块对象,该模块实例对象则根据文件名加载。

module.load(filename);

实际上在文件模块中,又分为3类模块。这三类文件模块以后缀来区分,Node.js会根据后缀名来决定加载方法。

  • .js。通过fs模块同步读取js文件并编译执行。
  • .node。通过C/C++进行编写的Addon。通过dlopen方法进行加载。
  • .json。读取文件,调用JSON.parse解析加载。

这里我们将详细描述js后缀的编译过程。Node.js在编译js文件的过程中实际完成的步骤有对js文件内容进行头尾包装。以app.js为例,包装之后的app.js将会变成以下形式:

(function (exports, require, module, __filename, __dirname) {
    var circle = require('./circle.js');
    console.log('The area of a circle of radius 4 is ' + circle.area(4));
});

这段代码会通过vm原生模块的runInThisContext方法执行(类似eval,只是具有明确上下文,不污染全局),返回为一个具体的function对象。最后传入module对象的exportsrequire方法,module__filename(文件名),__dirname(目录名)作为实参并执行。

这就是为什么require并没有定义在app.js文件中,但是这个方法却存在的原因。从Node.js的API文档中可以看到还有__filename__dirnamemoduleexports几个没有定义但是却存在的变量。

__filename``和__dirname在查找文件路径的过程中分析得到后传入的。module变量是这个模块对象自身,exports是在module的构造函数中初始化的一个空对象({},而不是null)。

在这个主文件中,可以通过require方法去引入其余的模块。而其实这个require方法实际调用的就是load方法

load方法在载入、编译、缓存了module后,返回moduleexports对象。这就是circle.js文件中只有定义在exports对象上的方法才能被外部调用的原因。

以上所描述的模块载入机制均定义在lib/module.js中。

require 方法中的文件查找策略

尽管require方法极其简单,但是内部的加载却是十分复杂的,其加载优先级也各自不同。

image1.jpg-29.2kB
image1.jpg-29.2kB

从文件加载

当文件模块缓存中不存在,而且不是原生模块的时候,Node.js会解析require方法传入的参数,并从文件系统中加载实际的文件,加载过程中的包装和编译细节在前一节中已经介绍过,这里我们将详细描述查找文件模块的过程,其中,也有一些细节值得知晓。

require方法接受以下几种参数的传递:

  • http、fs、path等,原生模块。
  • ./mod或../mod,相对路径的文件模块。
  • /pathtomodule/mod,绝对路径的文件模块。
  • mod,非原生模块的文件模块。

在进入路径查找之前有必要描述一下module path这个Node.js中的概念。对于每一个被加载的文件模块,创建这个模块对象的时候,这个模块便会有一个paths属性,其值根据当前文件的路径计算得到。我们创建modulepath.js这样一个文件,其内容为:

console.log(module.paths);

我们将其放到任意一个目录中执行node modulepath.js命令,将得到以下的输出结果(mac的演示结果)。

[ '/Users/beifeng/Desktop/test_node/node_modules',
  '/Users/beifeng/Desktop/node_modules',
  '/Users/beifeng/node_modules',
  '/Users/node_modules',
  '/node_modules' ]

可以看出module path的生成规则为:从当前文件目录开始查找node_modules目录;然后依次进入父目录,查找父目录下的node_modules目录;依次迭代,直到根目录下的node_modules目录。

文件模块查找流程

image2.jpg-69.9kB
image2.jpg-69.9kB

简而言之,如果require绝对路径的文件,查找时不会去遍历每一个node_modules目录,其速度最快。其余流程如下:

1.从module paths数组中取出第一个目录作为查找基准。
2.直接从目录中查找该文件,如果存在,则结束查找。如果不存在,则进行下一条查找
3.尝试添加.js.json.node后缀后查找,如果存在文件,则结束查找。如果不存在,则进行下一条。
4.尝试将require的参数作为一个包来进行查找,读取目录下的package.json文件,取得main参数指定的文件。
5.尝试查找该文件,如果存在,则结束查找。如果不存在,则进行第3条查找。
6.如果继续失败,则取出module path数组中的下一个目录作为基准查找,循环第1至5个步骤。
7.如果继续失败,循环第1至6个步骤,直到module paths中的最后一个值。
8.如果仍然失败,则抛出异常。

整个查找过程十分类似原型链的查找和作用域的查找。所幸Node.js对路径查找实现了缓存机制,否则由于每次判断路径都是同步阻塞式进行,会导致严重的性能消耗。

CommonJS规范

JavaScript缺少包结构。CommonJS致力于改变这种现状,于是定义了包的结构规范(http://wiki.commonjs.org/wiki/Packages/1.0 )。

CommonJS(http://www.commonjs.org)规范的出现,其目标是为了构建JavaScript在包括Web服务器,桌面,命令行工具,及浏览器方面的生态系统。

一个符合CommonJS规范的包应该是如下这种结构:

  • 一个package.json文件应该存在于包顶级目录下
  • 二进制文件应该包含在bin目录下。
  • JavaScript代码应该包含在lib目录下。
  • 文档应该在doc目录下。
  • 单元测试应该在test目录下。

由上文的require的查找过程可以知道,Node.js在没有找到目标文件时,会将当前目录当作一个包来尝试加载,所以在package.json文件中最重要的一个字段就是main。而实际上,这一处是Node.js的扩展,标准定义中并不包含此字段,对于require,只需要main属性即可。

引用文章

推荐阅读更多精彩内容

  • 说明:该学习笔记参考《深入浅出Node.js》在学习过程中,添加了自己的理解和适当的补充!仅供参考! NodeJs...
    秋意思寒阅读 275评论 0 1
  • 模块 Node 有简单的模块加载系统。在 Node 里,文件和模块是一一对应的。下面例子里,foo.js加载同一个...
    保川阅读 162评论 0 0
  • 1 Node.js模块的实现 之前在网上查阅了许多介绍Node.js的文章,可惜对于Node.js的模块机制大都着...
    zlx_2017阅读 693评论 0 1
  • 1 Node.js模块的实现# 之前在网上查阅了许多介绍Node.js的文章,可惜对于Node.js的模块机制大都...
    猿码架构阅读 1,396评论 1 51
  • Node.js是目前非常火热的技术,但是它的诞生经历却很奇特。 众所周知,在Netscape设计出JavaScri...
    w_zhuan阅读 2,756评论 2 40