NodeJs 模块机制

1,516 阅读6分钟

一、CommonJs规范

讲到nodejs的模块化就不得不讲CommonJs规范了,在以前的文章里也有讲过CommonJs相关使用,具体使用可以到JavaScript类别下查看,这里就不放传送门了。在这里就不多做赘述了,下面就说一下基本的用法。

导出模块 module.exports:

// DateUtil.js

class DateUtil {

    static getDate() {
        return new Date();
    }

}
module.exports = DateUtil;

引入模块 require:

// main.js

const DateUtil = require('./DateUtil');

console.log('当前时间', DateUtil.getDate());

二、Node的模块实现

在Node中引入模块,需要经历如下三个步骤:

  • 路径分析
  • 文件定位
  • 编译执行

在Node中,模块分为两类:一类是Node自身提供的模块,称为核心模块:fs、http等,就像java中的jdk提供的核心类一样。第二类是用户编写的模块,称为文件模块。

  • 核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,核心模块就被直接加载进内存,所以这部分的模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。
  • 文件模块在运行时动态加载,需要完整的路径分析,文件定位,编译执行过程,加载速度比核心模块慢。

以上介绍了模块加载过程和模块的类别划分,下面我们来看看Node加载模块的具体过程,如下图:

图片来自网络,侵删
图片来自网络,侵删

Node为了优化加载模块的速度,也像浏览器一样引入了缓存,对加载过的模块会保存到缓存内,下次再次加载时就会命中缓存,节省了对相同模块的多次重复加载。模块加载前会将需要加载的模块名转为完整路径名,查找到模块后将完整路径名保存到缓存,下次再次加载该路径模块时就可以直接从缓存中取得。

上图还说明了缓存模块的加载是在核心模块之前,也就是先查询缓存,缓存没找到后再查Node自带的核心模块,如果核心模块也没有查询到,最后再去用户自定义模块内查找。

模块加载的优先级是:缓存模块 > 核心模块 > 用户自定义模块。

在require加载模块时,require参数的标识符可以以文件类型结尾require("./test.js"),也可以省略文件类型require("./test")。对于省略类型的第二种写法,Node首先会认为它是一个.js文件,如果没有查找到该js文件,然后会去查找.json文件,如果还没有查找到该json文件,最后会去查找.node文件,如果连.node文件都没有查找到,就该抛异常了。Node在执行require加载模块时是线程阻塞的,大家都知道Node是单线程执行的,如果长期阻塞的话系统其它任务就得不到执行了,所以为了加快require模块的加载,如果不是.js文件的话,在require的时候就把文件类型加上,这样Node就不会再去一一尝试了。

require加载无文件类型的优先级:.js > .json > .node

三、模块编译

在Node中,每个文件模块都是一个对象,具体定义如下:

function Module(id, parent) {
    this.id = id;
    this.exports = {};
    this.parent = parent;
    if(parent && parent.children) {
        parent.children.push(this);
    }

    this.filename = null;
    this.loaded = false;
    this.children = [];
}

编译和执行是引入文件模块的最后一个阶段。定位到具体的文件后,Node会新建一个模块对象,然后根据路径载入并编译。对于不同的文件扩展名,其载入方法也有所不同,具体如下:

  • .js文件:通过fs模块同步读取文件后编译执行。
  • .json文件:通过fs模块同步读取文件后,用JSON.parse()解析返回结果。
  • .node文件:这是用C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件。
  • 其余扩展名的文件:它们都被当作.js文件载入。

每一个编译成功的模块都会将其文件路径做为索引缓存在Module._cache对象上,以提高二次引入的性能。

我们都知道,在浏览器中编写的js文件如果变量定义不是在函数或对象内就会存在污染全局变量的情况,例如下面这种方式定义的变量:

<script>
    var a = 'test';
</script>

等同于window.a = 'test';。但是我们在Node中的每个.js模块内并没有做任何其它处理,定义的变量怎么就不会污染全局环境了呢?还有在Node的模块内怎么就可以直接使用module、require、exports、__filename、__ dirname等对象呢?事实上,在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装。例如我们一开始写的DateUtil.js

// DateUtil.js

class DateUtil {

    static getDate() {
        return new Date();
    }

}
module.exports = DateUtil;

编译包装后的文件是下面这样的:

// Node编译时包装后的DateUtil.js
(function(exports, require, module, __filename, __dirname) {
    class DateUtil {

        static getDate() {
            return new Date();
        }

    }
    module.exports = DateUtil;
});

这样每个模块之间就进行了作用域隔离。

四、模块引入

我们自己编写的.js,.node文件被称为文件模块,在文件模块内可能会用到Node提供的核心模块javascript,如buffer,crypto,evals,fs,os,而这些核心模块又可能会调用底层C/C++ 编写的内建模块。它们的依赖关系如下图所示:

模块依赖层次关系
模块依赖层次关系

文件模块也可以直接调用内建模块,但是不推荐这种直接调用,因为核心模块中基本都封装了内建模块,内建模块的内部变量和方法已经导出到核心模块了。

我们在编写文件模块时如果需要依赖核心模块,可以通过require("os")这种方式,但是在require的背后都执行了哪些逻辑?接下来我们通过下图了解一下:

os核心模块的引入流程
os核心模块的引入流程

有时候核心模块并不能够满足我们的需求,这时我们就需要根据自身的业务开发自己的sdk,在这里就需要用到扩张模块了。

扩张模块由C/C++ 编写,属于文件模块的一种。(windows系统)C/C++ 模块通过预先编译为.dll文件,通过.dll文件生成.node文件,然后调用process.dlopen()方法导出JavaScript文件。如下图所示:

扩展模块的编译和加载过程
扩展模块的编译和加载过程

五、模块调用栈

上面我们说了文件模块、核心模块、内建模块、C/C++ 扩张模块,到这里该说一下以上几个模块的调用关系了。如下图:

模块之间的调用关系
模块之间的调用关系

C/C++ 内建模块属于最底层的模块,它属于核心模块,主要提供API给JavaScript核心模块和第三方JavaScript文件模块调用。不过这里不推荐文件模块直接调用C/C++ 内建模块,除非你对它非常的了解。

JavaScript核心模块主要扮演的角色有两种:一类是做为 C/C++ 内建模块的封装层和桥接层,供文件模块调用;一类是纯碎的功能模块,它不需要根底层打交道,但是又十分重要。

文件模块通常由第三方编写,包括普通JavaScript模块和C/C++ 扩张模块,主要调用方向为普通JavaScript模块调用扩张模块。