为了让Node.js的文件可以相互调用,Node.js提供了一个简单的模块系统。
模块是Node.js应用程序的基本组成部分,文件和模块是一一对应的。换言之,一个Node.js文件就是一个模块,这个文件可能是JavaScript代码、JSON或编译过的C/C++扩展。
在Node.js应用中,主要有以下两种定义模块的格式。
·CommonJS规范:该规范是自Node.js创建以来,一直使用的基于传统模块化的格式。
·ES6模块:在ES6中,使用新的“import”关键字来定义模块。由于目前ES6是所有Java Script都支持的标准,因此Node.js技术指导委员会致力于为ES6模块提供一流的支持。
CommonJS规范的提出,主要是为了弥补JavaScript没有标准的缺陷,以达到像Python、Ruby和Java那样具备开发大型应用的基础能力,而不是停留在开发浏览器端小脚本程序的阶段。
CommonJS规范主要分为三部分:模块引用、模块定义、模块标识。
如果在main.js文件中使用如下语句:
意为使用require()方法,引入math模块,并赋值给变量math。事实上,命名的变量名和引入的模块名不必相同,例如:
赋值的意义在于,main.js中将仅能识别Math,因为这是已经定义的变量,并不能识别math,因为math没有定义。
上面的例子中require()的参数仅仅是模块名称的字符串,没有带有路径,引用的是main.js所在当前目录下的node_modules下的math模块。如果当前目录下没有node_modules目录或node_modules目录下没有安装math模块,便会报错。
如果要引入的模块在其他路径,就需要使用相对路径或绝对路径,例如:
上面的例子中引入了当前目录下的sum.js文件,并赋值给sum变量。
·module对象:在每一个模块中,module对象代表该模块自身。
·export属性:module对象的一个属性,它向外提供接口。
仍然采用上一个示例,假设sum.js中的代码为:
尽管main.js文件引入了sum.js文件,前者却仍然无法使用后者中的sum()函数,在main.js文件中sum(3,5)这样的代码会报错,提示sum不是一个函数。sum.js中的函数要能被其他模块使用,就需要暴露一个对外的接口,export属性用于完成这一工作。将sum.js中的代码改为:
main. js文件就可以正常调用sum.js中的方法,如下面的示例。
这样的调用能够正常执行,前一个sum意为本文件中sum变量代表的模块,后一个sum是引入模块的sum()方法。
模块标识指的是传递给require()方法的参数,必须是符合小驼峰命名的字符串,或者以“.”“..”开头的相对路径,或者绝对路径。其中,所引用的JavaScript文件可以省略后缀“.js”,因此上述例子中:
等同于:
CommonJS模块机制,避免了JavaScript编程中常见的全局变量污染的问题。每个模块拥有独立的空间,它们互不干扰。图2-1展示了模块之间的引用。
图2-1 模块引用
虽然CommonJS模块机制很好地为Node.js提供了模块化的机制,但这种机制只适用于服务端,针对浏览器端,CommonJS是无法适用的。为此,ES6规范推出了模块,期望用标准的方式来统一所有JavaScript应用的模块化。
可以使用export关键字将已发布代码部分公开给其他模块。最简单的方法就是将export放置在任意变量、函数或类声明之前。以下是一些导出的示例。
其中,除了export关键字之外,每个声明都与正常形式完全一样。每个被导出的函数或类都有名称,这是因为导出的函数声明与类声明必须要有名称。不能使用这种语法来导出匿名函数或匿名类,除非使用了default关键字,观察multiply()函数,它并没有在定义时被导出,而是通过导出引用的方式进行了导出。
一旦有了包含导出的模块,就能在其他模块内使用import关键字来访问已被导出的功能。
import语句有两个部分,一是需要导入的标识符,二是需导入的标识符的来源模块。下面是导入语句的基本形式。
在import之后的花括号指明了从给定模块导入对应的绑定,from关键字则指明了需要导入的模块。模块由一个表示模块路径的字符串(module specifier,被称为模块说明符)来指定。
当从模块导入了一个绑定时,该绑定表现得就像使用了const的定义。这意味着不能再定义另一个同名变量(包括导入另一个同名绑定),也不能在对应的import语句之前使用此标识符,更不能修改它的值。
可以在导出模块中进行重命名。如果想用不同的名称来导出,可以使用as关键字来定义新的名称:
上面的例子中,sum()函数被作为add()导出,前者是本地名称(local name),后者则是导出名称(exported name)。这意味着当另一个模块要导入此函数时,它必须改用add这个名称:
在导入时同样可以使用as关键字进行重命名:
此代码导入了add()函数,并使用了导入名称(import name)将其重命名为sum()(本地名称)。这意味着在此模块中并不存在名为add的标识符。
以下总结了CommonJS和ES6模块的异同点。
·对于基本数据类型,属于复制,即会被模块缓存。同时,在另一个模块可以对该模块输出的变量重新赋值。
·对于复杂数据类型,属于浅拷贝。由于两个模块引用的对象指向同一个内存空间,因此对该模块的值做修改时会影响另一个模块。
·当使用require命令加载某个模块时,就会运行整个模块的代码。
·当使用require命令加载同一个模块时,不会再执行该模块,而是取到缓存之中的值。也就是说,CommonJS模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。
·循环加载时,属于加载时执行。即脚本代码在require的时候,就会全部执行。一旦出现某个模块被“循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。
·ES6模块中的值属于动态只读引用。
·对于只读来说,即不允许修改引入变量的值,import的变量是只读的,不论是基本数据类型还是复杂数据类型。当模块遇到import命令时,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块中去取值。
·对于动态来说,原始值发生变化,import加载的值也会发生变化,不论是基本数据类型还是复杂数据类型。
·循环加载时,ES6模块是动态引用。只要两个模块之间存在某个引用,代码就能够执行。
在Node.js中,模块分为以下两类。
·Node. js自身提供的模块,称为核心模块,如fs、http等,就像Java中自身提供核心类一样。
·用户编写的模块,称为文件模块。
核心模块部分在Node.js源代码的编译过程中,编译进了二进制执行文件。在Node.js进程启动时,核心模块就被直接加载进内存,所以这部分的模块引入时,文件定位和编译执行这两个步骤可以省略,并且在路径分析中优先判断,所以它的加载速度是最快的。
文件模块在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,加载速度比核心模块慢。
图2-2展示了Node.js加载模块的具体过程。
Node. js为了优化加载模块的速度,也像浏览器一样引入了缓存,对加载过的模块会保存到缓存内,下次再加载时就会命中缓存,节省了对相同模块的多次重复加载。模块加载前会将需要加载的模块名转化为完整路径名,查找到模块后再将完整路径名保存到缓存,下次再加载该路径模块时就可以直接从缓存中取得。
在图2-2中也能清楚地看到,模块加载时先查询缓存,缓存没找到后再查Node.js自带的核心模块,如果核心模块也没有查询到,最后再去用户自定义模块内查找。因此,模块加载的优先级为缓存模块>核心模块>用户自定义模块。
在前文也讲了,require加载模块时,require参数的标识符可以省略文件类型,例如,require('./sum.js')等同于require('./sum')。在省略类型时,Node首先会认为它是一个.js文件,如果没有查找到该.js文件,会去查找.json文件,如果还没有查找到该.json文件,最后会去查找.node文件,如果连.node文件都没有查找到,就会抛异常了。其中,.node文件是指用C/C++编写的扩展文件。由于Node.js是单线程执行的,在加载模块时是线程阻塞的,因此为了避免长期阻塞系统,如果不是.js文件的话,在require的时候就把文件类型加上,这样Node.js就不会再去一一尝试了。
因此require加载无文件类型的优先级为.js>.json>.node。
图2-2 Node.js加载模块过程