Node.js干货学习

289 阅读12分钟

avatar

前言

本节由Node的模块机制入手,简述CommonJS在Node中的应用,通过学习模块机制,使我们更好地了解Node的底层架构与实现。

CommonJS规范

1. 规范的提出

在Web时代很长一段时间里,JavaScript主要是通过<script>标签引入代码的,相对于其他高级语言,如Java有类文件,Python有import机制,Ruby有require机制而言,JavaScript就显得杂乱无章,语言自身毫无组织和约束能力。为了弥补当前JavaScript没有良好的模块系统的缺陷,以达到像Python、Ruby和Java具备开发大型应用的基础能力,而不是停留在小脚本程序的阶段,CommonJS规范就应运而生了。

2. 规范三要素

  • 模块引用

CommonJS规范使用require()方法引入一个模块的API到当前上下文中,这个方法接受的参数为模块标识

    const fs = require('fs');
  • 模块定义

在Node中,一个文件就是一个模块,文件编译时上下文提供了exports对象,将当前模块的方法或者变量挂载在exports对象上作为属性即可定义导出的方式。在模块中,还存在一个module对象,它代表模块自身,而exports是module的一个属性。

    const MAX_SUM = 100;
    exports.MAX_SUM = MAX_SUM;
    
    exports.add = function (a, b) {
      return a + b;
    };
  • 模块标识

模块标识是传递给require()方法的参数,用于模块的查找定位,它必须是符合小驼峰命名的字符串,或者以.、..开头的相对路径,或者绝对路径

    const fs = require('fs');  //'fs'就是模块标识
    const util = require('./util');  //'./util'就是模块标识
    const validator = require('ux/validator');  //'ux/validator'就是模块标识

Node与CommonJS规范的关系

CommonJS规范是Node模块化的方案,Node借鉴CommonJS的Modules规范实现了一套非常易用的模块系统,Node的发展离不开CommonJS规范的影响。

我们可以从代码模块层面上看Node的组成架构,Node大致主要由ECMAScript以及Buffer、Stream等模块组成,如图:

Node与CommonJS规范的关系

Node的模块实现

Node在实现中并非完全按照CommonJS规范实现,而是对模块规范进行了一定的取舍,同时也增加了少许自身需要的特性。尽管规范中exports、require和module使用起来十分简单,但是要想真正了解Node的模块化,我们还是有必要深入学习Node的模块实现,了解实现它们的过程中究竟经历了什么。

作为Node的使用者,借助Modules规范,开发者可以很简洁地使用require()方法引入对应的模块,无需关注代码底层逻辑,但是了解模块加载步骤有助于我们更加深入地了解Node。

在Node中引入模块时,底层需要经历路径分析文件定位编译执行3个步骤。

在学习模块引入机制前,我们有必要先了解核心模块文件模块优先从缓存加载这几个概念。

  • 核心模块

我们称Node提供的内置模块为核心模块

  • 文件模块

我们称由用户自定义编写的模块为文件模块

  • 优先从缓存加载

与前端浏览器会缓存静态脚本文件以提高性能一样,Node对引入过的模块都会进行缓存,以减少二次引入时的开销。不同的地方在于,浏览器仅仅缓存文件,而Node缓存的是编译和执行之后的对象。

不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的。不同之处在于核心模块的缓存检查先于文件模块的缓存检查。

1. 路径分析

前面提到过,require()方法接受一个标识符作为参数。在Node实现中,正是基于这样一个标识符进行模块查找的。标识符有几种形式,对于不同的标识符,模块的查找和定位有不同程度上的差异,下面根据不同的标识符形式展开简述对应的路径分析过程。

1.1 核心模块

核心模块在Node的源代码编译过程中已经编译为二进制代码了,其加载过程最快;Node自身维护了一套核心模块的引用集合,核心模块路径分析优先级仅次于缓存加载,当当前模块标识无法在缓存中加载时,首先会与核心模块的引用集合进行比对,看是否可以定位到对应的匹配模块路径。

1.2 文件模块

文件模块一般以.、..和/开始的相对路径或绝对路径为标识符,在分析路径时,require()方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快。由于文件模块给Node指明了确切的文件位置,所以其析加载速度比较快,但慢于核心模块

1.3 自定义模块

自定义模块指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或者包的形式,最常见的就是我们从NPM里下载的第三方模块,如Express。这类模块的查找是最费时的,也是所有方式中最慢的一种。

在自定义模块的路径分析时,会有模块路径这个概念,其本质上是个包含路径的数组,形式如下:

    [ 'D:\\node\\src\\node_modules', 'D:\\node\\node_modules', 'D:\\node_modules' ]

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

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

有了模块路径数组后,Node根据制定的查找策略去定位文件模块的具体文件,查找策略如下:

它的查找方式与JavaScript的原型链或作用域链的查找方式十分类似。在加载的过程中,Node会逐个尝试模块路径中的路径,直到找到目标文件为止

2. 文件定位

Node完成模块路径分析定位到模块位置后,接下来就会进行具体的文件定位,在文件的定位过程中,还有一些细节需要注意,主要是文件扩展名的分析目录和包的处理

2.1 文件扩展名的分析

在日常开发中我们在使用require()方法引入模块时,标识符常常会省略文件后缀,那么Node又怎么在缺少后缀的情况下正确加载相应模块的呢?其实,这得益于文件扩展名的分析

所谓的文件扩展名的分析指的是,在标识符中不包含文件扩展名时,Node会按.js、.json、.node的次序补足扩展名,依次尝试匹配具体文件。

文件定位分析标识符的过程中,通过分析文件扩展名之后,可能没有查找到对应文件,但却得到一个目录,这在引入自定义模块和逐个模块路径进行查找时经常会出现,例如,引入第三方模块Express,此时Node会将Express目录当做一个包来处理,进行目录和包的处理

2.2 目录和包的处理

当遇到上述问题,进行目录和包的处理时,Node会根据CommonJS规范进行处理:

  1. 首先,Node在当前目录下查找package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位;如果文件名缺少扩展名,将会进入扩展名分析的步骤。
  2. 如果main属性指定的文件名错误,或者压根没有package.json文件,Node会将index当做默认文件名,然后依次查找index.js、index.node、index.json。
  3. 如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。

编译执行

模块路径分析、文件定位完成后,还需要进行编译执行才可以真正引入对应模块。其实模块经过Node编译后我们本质上引入的是一个模块对象,因为在Node中,每个文件模块都是一个对象,当定位到具体的文件后,Node会新建一个模块对象,然后根据路径载入并编译模块。

使用过Node的开发者都知道每个模块文件中存在着require、exports、module、__filename、__dirname这几个变量,但是它们在模块文件中并没有被显式定义,那么为什么我们可以直接使用它们呢,其实Node在编译模块时进行了一系列的操作,对开发者屏蔽了底层逻辑,现在让我们一一揭开它的神秘面纱吧!

由上文我们可以知道,Node中的模块包括核心模块、文件模块、自定义模块,不同的模块Node在编译时会存在差异,需要分开讨论。

1.1 文件模块编译(包括自定义模块)

Node在编译文件模块时,对.js、.node、.json文件模块编译也存在差异

1.1.1 JavaScript模块的编译

在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装。在头部添加了(function (exports, require, module, __filename, __dirname) {,在尾部添加了})。一个正常的JavaScript文件会被包装成函数的样子:

    (function (exports, require, module, __filename, __dirname) {   //头部包装
        const util = require('./util');
        exports.filename = __filename;
        exports.dirname = __dirname;
        exports.add = function (a, b) {
            return util.add(a, b);
        };
    });  // 尾部包装

编译过程中,通过调用底层模块的runInThisContext()方法,执行返回一个具体的function对象。最后,将当前模块对象的exports属性、require()方法、module(模块对象自身),以及在文件定位中得到的完整文件路径__filename文件目录__dirname作为参数传递给这个function(exports, require, module, __filename, __dirname)执行,执行之后,模块的exports属性被返回给了调用方,因此exports属性上的任何方法和属性都可以被外部调用到。

值得注意的是,我们执行包装后的函数时,传入的exports是一个对象,我们不可以直接给它做赋值操作,因为赋值操作会改变该对象原来的内存地址以及改变形参的引用,并不能改变作用域外的值,导致修改的值无法达到预期的效果。

1.1.2 .node模块的编译

.node的模块文件并不需要编译,因为它是编写C/C++模块之后编译生成的,所以这里只有加载和执行的过程。在执行的过程中,模块的exports对象与.node模块产生联系,然后返回给调用者。

1.1.3 .json模块的编译

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

2.1 核心模块的编译

在讨论核心模块的编译编译前,我们得知道核心模块分为C/C++编写的和JavaScript编写的两部分,而且Node的核心模块在编译成可执行文件的过程中被编译进了二进制文件。

2.1.1 JavaScript核心模块的编译过程

Node会将所有的JavaScript核心模块文件编译为C/C++代码,这个过程经历了两个步骤:

  • 转存为C/C++代码:

    即将所有内置的JavaScript代码转换成C++里的数组,生成一个名为node_natives.h头文件。在这个过程中,JavaScript代码以字符串的形式存储在node命名空间中,是不可直接执行的。在启动Node进程时,JavaScript代码直接加载进内存中,等待被引入。

  • 包装JavaScript核心模块:

    内置的所有JavaScript模块文件也没有定义require、module、exports这些变量。当我们引入核心模块的时,JavaScript核心模块将从内存中加载出来,进行头尾包装的过程,然后才执行和导出了exports对象。

2.1.2 C/C++核心模块的编译过程

C/C++核心模块中虽然主体基本是C/C++实现,但有些模块全部由C/C++编写,有些模块则由C/C++完成核心部分,两者有一定区别:

  • 内建模块:

    我们将那些由纯C/C++编写的部分统一称为内建模块;由于它们本身由C/C++编写,性能上会优于脚本语言,并且在进行文件编译时,它们被编译进二进制文件,一旦Node开始执行,它们被直接加载进内存中,所以内建模块性能较佳。

    一般来说,开发过程中我们很少会直接调用内建模块,因为核心模块中基本都封装了内建模块,引入核心模块后,我们就可以使用对应的功能了。说到这里,我们需要了解内建模块是如何将内部变量或方法导出,以供外部JavaScript核心模块调用的过程:

    Node在启动时,会生成一个全局变量process,并提供Binding()方法来协助加载内建模块,在加载内建模块时,我们先创建一个exports空对象,然后调用内置的get_builtin_module()方法取出内建模块对象,通过执行内置的register_func()填充exports对象,最后将exports对象按模块名缓存,并返回给调用方完成导出。

  • 非内建模块:

    非内建模块指的是模块由C/C++完成核心部分,其他部分则由JavaScript实现包装或向外导出,以满足性能需求。这种方式能够有效提高模块的性能。非内建模块编译包装过程与文件模块包装过程大同小异的。