前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >深入浅出 Nodejs( 二 ):Nodejs 文件模块机制

深入浅出 Nodejs( 二 ):Nodejs 文件模块机制

原创
作者头像
serena
修改2021-08-03 14:56:07
2.4K0
修改2021-08-03 14:56:07
举报
文章被收录于专栏:社区的朋友们社区的朋友们

作者:郭泽豪

本篇教程关于Nodejs的文件模块机制,具体讲CommonJs规范以及Nodejs文件模块的实现原理。

本章的重点内容:

  • CommonJs的模块规范,包括模块引用,模块定义以及模块标识
  • 核心模块与文件模块加载过程的区别
  • 文件模块加载过程中的路径分析、文件定位以及编译过程

一、CommonJs规范

1.1 CommonJs的出发点

CommonJs规范的提出对于Node的发展具有里程碑的意义,CommonJs规范为JavaScript制定一个美好的愿景,希望JavaScript能够在任何地方运行。从事JavaScript的开发者都知道ECMAScript,它是JavaScipt的官方规范,但是缺陷是ECMAScript规范涵盖的范畴非常小。

随着Web2.0时代的来临,在浏览器中出现了更多更强大的API给JavaScript使用,包括W3C组织对HTML5规范的推进以及各大浏览器产商对规范的大力支持,JavaScript的规范得到很好的发展,但是这些规范都局限在前端,后端JavaScript的规范却远远落后,直到CommonJs规范的出现。CommonJs规范涵盖了模块、二进制、Buffer、字符串编码、I/O流、进程环境、文件系统、套接字、单元测试、Web服务器网关接口、包管理等。

Node借鉴CommonJs的模块规范实现了一套非常易用的模块系统,NPM对于Packages规范的完成支持使得Node应用在开发中事半功倍。

1.2 CommonJs的模块规范

CommonJs对模块的定义十分简单,主要分为模块引用、模块定义和模块标识三部分。

(1)模块引用

模块引用的示例代码如下:

代码语言:javascript
复制
var math = require('math');

在CommonJs规范中,存在require方法,这个方法接收一个模块标识,即math,以此引入一个模块的API到当前上下文中。

(2)模块定义

在模块中,上下文提供了require方法来引入外部模块。对应引入的功能,外部模块通过exports对象导出模块内定义的方法和对象,它是唯一导出的出口。在模块中,还存在一个module对象,它代表模块本身,而exports是module的属性。下面的示例是通过exports导出模块内定义的add方法,然后在program.js引入add模块并调用它的add方法。

代码语言:javascript
复制
//add.js
exports.add = function(){
    var sum = 0,
        i = 0,
        args = arguments,
        l = args.length;
    while(i < l){
        sum += args[i++];
    }
    return sum;
};


//program.js
var math = require('./add.js');
increment= function(val){
    return math.add(val, 1);
};
console.log(increment(5));

(3)模块标识

模块标识其实就是传递给require()方法的参数,它必须是小驼峰命名的字符串,或者是.、..开头的相对路径,或者绝对路径。它可以没有文件名后缀,我们后面会讲没有文件名后缀怎么找到对应的模块。

每个模块具有独立的空间,它们互不干扰,导出和引用都很简单。CommonJs构建的这套模块导出和引入机制使得用户完全不必考虑变量污染的问题。

二、Node的模块实现

2.1 模块加载过程

尽管规范中exports、require和module听起来十分简单,但是Node在实现它们的过程中究竟经历了什么,这个过程需要知晓。在Node中引入文件模块,需要经历如下路径分析、文件定位、编译执行3个步骤,但并不是全部模块都需要经历,比如C/C++扩展模块即.node文件没有编译的过程,因为.node本身就是编译后的文件。

在Node中,模块分为两类:一类是Node本身提供的模块,称为核心模块;另一类是用户编写的模块,叫文件模块。

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

(2)文件模块则是在运行时加载,需要完整的路径分析、文件定位、编译执行过程。模块引入的速度比核心模块要慢。

2.2 优先从缓存加载

不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的。核心模块的缓存检查的优先级要先于文件模块缓存检查。注意Node缓存的是模块编译和执行后的对象,即module对象,我们后续会讲到它的数据结构。下面代码的输出结果是1和2,在第一次通过require()方法引入模块后,模块对象即cache变量就会缓存在内存中,当第二次引入同样的模块时,会从缓存中直接取出,缓存的key值是模块的完整文件路径。

代码语言:javascript
复制
//cache.js
var i = 0;
exports.add = function(){
    i++;
    return i;
};


//cache_program.js
var cache = require('./cache.js');
var i = cache.add();
console.log(i);
cache = require('./cache.js');
i = cache.add();
console.log(i);

2.3 路径分析和文件定位

2.3.1 模块标识符分析

因为标识符有几种形式,对于不同的标识符,模块的查找和定位有所不同。

(1)模块标识符分析

前面提到过,require()方法接受一个标识符作为参数。在Node实现中,正是基于这样一个标识符进行模块查找的。模块标识符在Node中主要分为以下几类:

  • 核心模块,如http、fs、path等
  • 或..开始的相对路径文件模块
  • 以/开始的绝对路径模块
  • 非路径形式的文件模块,如自定义的connect模块

核心模块的优先级仅次于缓存加载,它在Node源代码编译过程中已经编译为二进制代码,其加载速度最快。另外试图加载一个与核心模块同名的自定义模块,那是不会成功的,比如说想通过require(‘http’)引入自己定义的http模块,可以通过更改模块名或者标识符的路径来解决。

.、..、/开始的标识符,这里都被当做文件模块来处理。在分析文件模块时,require()方法会将路径转为真实路径,找到对应的文件后进行编译执行,执行后的结果会以真实完整路径为索引将编译执行后生成的模块对象存放在缓存中。

自定义模块指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件,也可能是一个包。这类文件的查找是最费时的。

在介绍自定义模块的查找之前,我们先介绍模块路径的概念,即node_modules。我们尝试创建module_path.js文件,其内容是console.log(module.paths),输出结果如下;

代码语言:javascript
复制
[
'C:\\Users\\Administrator\\Desktop\\nodejs\\node_modules',

  'C:\\Users\\Administrator\\Desktop\\node_modules',

  'C:\\Users\\Administrator\\node_modules',

  'C:\\Users\\node_modules',

  'C:\\node_modules' ]

可以看出,模块路径的生成规则如下所示。

  • 当前文件目录下的node_modules目录
  • 父目录下的node_modules目录
  • 父目录的父目录下的node_modules目录
  • 沿路径向上逐级递归,直到根目录下的node_modules目录。

对于自定义模块,在加载的过程中,Node会逐个尝试模块路径中的路径,知道找到目标文件或目录为止。可以看出当前文件的路径越深,模块查找耗时越长,这是自定义模块加载速度最慢的原因。

(2)文件定位

在文件的定位中,还有一些细节需要注意,主要包括文件扩展名的分析、目录和包的处理。

文件扩展名分析,require()在分析标识符的过程中,会出现标识符不包含文件扩展名的情况。CommonJs模块规范也允许标识符不包含文件扩展名,这种情况下,Node会按.js、.node、.json的顺序补全扩展名,依次尝试。

在尝试的过程中,需要调用fs模块同步阻塞式地判断文件是否存在。因为Node是单线程的,所以这里的文件定位会引起性能问题。这里有一个小诀窍,如果是.node和.json文件,在传递给require()的标识符中带上扩展名会快一些。另一个小诀窍,同步配合缓存,可以大幅度缓解Node单线程中阻塞性调用的缺陷。

(3)目录分析和包

在分析标识符的过程中,require()通过分析文件扩展名之后,可能没有查找到对应的文件,但却得到一个目录,这是很常见的事,此时Node会将目录当做一个包来处理。

首先,Node在当前目录下查找package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤。如果main属性指定的文件名错误,或者压根没有package.json文件,Node会将index当做默认文件名,然后在当前目录下依次查找index.js,index.node,index.json。

如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。

2.4 模块编译

在Node中,每个文件模块都是一个Module对象。

代码语言:javascript
复制
function Module(id, parent){
    this.id = 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模块同步读取文件后编译执行。
  • node文件。这是用C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件。
  • json文件。通过fs模块同步读取文件后,用JSON.parse()解析返回结果。
  • 其余扩展名文件,它们都被当成.js文件载入。

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

根据不同的文件扩展名,Node会调用不同的读取方式,如.json文件的调用如下:

代码语言:javascript
复制
//.json
Module._extension['.json'] = function(module, filename){
    var content = NativeModule.require('fs').readFileSync(filename, 'utf8');
    try{
        module.exports = JSON.parse(content);
    }catch(err){
        err.message = filename + ':' + err.message;
        throw err;
    }
};

在确定文件的扩展名后,Node将调用具体的编译方式将文件执行完返回给调用者。

(1)JavaScript模块的编译,即js文件

我们知道每个模块文件中存在require、exports、module这3个变量,但是它们在模块文件中并没有定义,那么它们从何而来呢?在Node的API文档中,每个模块中还有__filename__dirname这两个变量,它们又从何而来?其实在编译过程中,Node对获取的JavaScript文件内容进行头尾包装。在头部添加(function (exports, require, module, __filename, __dirname){我们的自定义脚本});,以刚才的add.js为例,一个正常的JavaScript文件会被包装成如下的样子:

代码语言:javascript
复制
(function (exports, require, module, __filename, __dirname){
    exports.add = function(){
    var sum = 0,
        i = 0,
        args = arguments,
        l = args.length;
    while(i < l){
        sum += args[i++];
    }
    return sum;
    };
});

这样每个模块文件之间都进行了作用于隔离。包装之后的代码会通过vm模块的runInThisContext()方法执行,返回一个具体的Function对象。最后把当前新建的模块对象的exports属性、require()方法、module(模块对象本身)以及在文件定位中得到的完整文件路径__filename和文件目录__dirname作为参数传递给这个Function执行。

代码语言:javascript
复制
var vm = require('vm');

var func = vm.runInThisContext('(function (exports, require, module, __filename, __dirname){\
    exports.add = function(){\
    var sum = 0,\
        i = 0,\
        args = arguments,\
        l = args.length;\
    while(i < l){\
        sum += args[i++];\
    }\
    return sum;\
    };\
});')
console.log(func);//[Function]

这就是这些变量没有在模块文件定义但却存在的原因。编译执行后,模块对象的exports属性被返回给调用方。exports属性上的任何方法和属性都可以被外部调用到,但是模块中的其余变量或属性则不可直接被调用。

(2)C/C++模块的编译,即.node文件

Node调用process.dlopen()方法进行加载和执行。在Node的架构下,dlopen()方法在windows和*nix平台下有不同的实现,通过libuv兼容层进行了封装。

实际上,.node的模块文件并不需要编译,因为它是C/C++源码编译生成的,dlopen()是跨平台的,在windows通过visualC++编译器编译生成,在nix通过gcc/g++编译器编译生成,.node文件在windows平台实际上是一个.dll文件,在nix平台是一个.so文件。下一篇教程会提及。所以.node文件只有加载和执行的过程。在执行的过程中,模块的exports对象与.node模块产生联系,然后将exports对象返回给调用者。

(3)JSON文件的编译,即.json文件

.json文件的编译是3种文件模块编译方式中最简单的。Node利用fs模块同步读取JSON文件的内容之后,调用JSON.parse()方法得到对象,然后将它赋值给模块对象的exports,供外部模块调用。

JSON文件在用作项目的配置文件时比较有用。如果你定义一个JSON文件作为配置,那就不必调用fs模块去异步读取和解析,直接调用require()引入即可。此外,还可以享受到模块缓存的好处。

作者:MIG无线合作开发部实习生marcozhguo

电子邮箱:446882229@qq.com

参考资料:

《深入浅出Nodejs》

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、CommonJs规范
    • 1.1 CommonJs的出发点
      • 1.2 CommonJs的模块规范
      • 二、Node的模块实现
        • 2.2 优先从缓存加载
          • 2.3 路径分析和文件定位
            • 2.4 模块编译
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档