前言
本节由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的模块实现
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规范进行处理:
- 首先,Node在当前目录下查找package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位;如果文件名缺少扩展名,将会进入
扩展名分析
的步骤。 - 如果main属性指定的文件名错误,或者压根没有package.json文件,Node会将index当做默认文件名,然后依次查找index.js、index.node、index.json。
- 如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个
模块路径
进行查找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。
编译执行
模块路径分析、文件定位完成后,还需要进行编译执行
才可以真正引入对应模块。其实模块经过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实现包装或向外导出,以满足性能需求。这种方式能够有效提高模块的性能。非内建模块编译包装过程与文件模块包装过程大同小异的。