1.1 Node.js的模块机制

在介绍C++模块的时候,在此有必要老生常谈,回过头来再讲一遍Node.js下的模块机制,这样读者在今后的阅读过程中就能有一个更清晰的思路。

1.1.1 CommonJS的模块规范

笔者假设读者对CommonJS规范、AMD规范和CMD规范等都有了一定的认知。本节我们对Node.js所使用的CommonJS规范再梳理一遍。首先我们知道Node.js的根基就是JavaScript或者说是ECMAScript有关ECMAScript的信息可以参阅https://developer.mozilla.org/en-US/docs/Web/JavaScript/Language_Resources。,而JavaScript自身是不带模块机制的,所以CommonJS规范应运而生,而且CommonJS不仅仅是Node.js中的模块定义规范。总的来说,它可以给下面的一些场景且不限于这些场景提供相同的规范:

· 服务端JavaScript应用;

· 命令行工具;

· 图形化用户界面(Graphical User Interface,GUI)桌面应用;

· 混合应用(如Titanium有关Titanium的信息可以参阅https://en.wikipedia.org/wiki/Appcelerator_Titanium。、Adobe AIR等)。

CommonJS对于一个模块的定义遵循下面的三个约定。

1.require

require是一个函数,这个函数有一个参数代表模块标识,它的返回值就是其所引用的外部模块所暴露的API。

讲得直白一点,就是能通过代码const biu=require("boom_shakalaka")的形式引入boom_shakalaka这个模块并赋给biu。

2.模块上下文

在一个CommonJS模块的上下文中,需要有满足如下条件的一些事物存在。

① require函数,在前面已经提到了。

②一个名为exports并且长得挺不错的对象,这个exports对象里面挂载的内容会被暴露到模块外部去。也就是说exports就是一口通往神魔大陆的“神魔之井”,并且这口连接异界的“神魔之井”也只能叫作exports

③一个名为module的对象,里面包含了这个模块的一些自带属性,如一个只读的"id"属性。实际上在Node.js中,module对象里面还有一个exports对象,其初始指针指向与上面说的exports相同,而且模块真正暴露出去的是module中的exports。也就是说,如果笔者直接替换了module.exports对象(如执行代码module.exports={}),那么导出的就是被替换的对象,而不是上面说的exports了。至于module、exports与外部模块在Node.js模块中的关系,这里用图1-1表示。

图1-1 module、exports与外部模块的关系

3.模块标识

模块标识其实就是一个字符串,用于传给require函数。

它需要小驼峰格式的标识名,或者以"."以及".."带头的相对路径。从理论上来说不应该带上后缀名,如".js"。

4.未指定的约定

这里有两个未指定的约定,也就是说在符合CommonJS规范的情况下,无论以下的情况如何都是可用的。

①模块的存储方案未指定,一个模块的内容可以存在于数据库、文件系统、工厂函数,甚至于一个链接库中。

②实现CommonJS规范的模块加载器可以支持PATH环境变量用以加载时的寻径,但是也可以不支持。

这里给出一个遵循CommonJS规范的简单样例代码该样例代码来自commonjs.org的Wiki,其代表了规范1.1.1的样例。CommonJS规范的更多细节也可以参阅这个网页:http://wiki.commonjs.org/wiki/Modules/1.1.1。

1.1.2 Node.js的模块

Node.js的应用通过入口文件之后,是由一个个模块组成的。通常一个模块是一个遵循CommonJS规范书写的JavaScript源文件,也有可能是一个后缀为*.node的C++模块二进制文件,这些文件通过Node.js中的require()函数被引入并使用。

使用CommonJS规范来作为Node.js的模块导出、引入机制,相当于把每个JavaScript文件或者模块当作一个不会污染全局的闭包,再配合NPMNPM的官网:https://www.npmjs.com/。(Node.js社区目前最流行的包管理系统)2.x版本的嵌套式依赖方案,能非常优雅地实现某个依赖在一个项目中多版本共存(哪怕是不兼容的多个不同大版本)的情况。与比较适合前端开发的扁平化依赖方案NPM 3.x相比,2.x更适合于Node.js应用的开发。

1.Node.js模块寻径

对于Node.js来说,除了引入CommonJS规范,还对其做了一些附加的工作,比如模块寻径。

在1.1.1节中,笔者已经阐述过模块的标识,它是一个遵循小驼峰命名法的字符串,或者是一个以"."、".."开头的文件相对路径。但是在Node.js中,即使你不遵循小驼峰命名法的规范也是可以的,如"chinese-random-name"这个字符串在Node.js中也是一个合法的标识。除此之外,它还可以是一个不以"."、".."开头的相对路径,甚至也可以是一个绝对路径。

上面几种命名对于Node.js来说,会采用几种不同的方法来寻找模块的路径。

(1)Node.js核心模块

代码存在于Node.js源码库,并且将其API暴露给开发者的模块称为核心模块。这些核心模块都有自己的预留标识,当执行require()函数时传入的标识与某个Node.js核心模块相吻合时,该函数返回的是该核心模块的API,如"fs"、"net"等。更多核心模块可以参考Node.js官方文档Node.js v6.9.4的文档地址是https://nodejs.org/dist/v6.9.4/docs/api/。

(2)文件模块

文件模块是指那些需要Node.js进行文件寻径获得的模块。下面会阐释寻径方式略微不同的两种模块——第三方模块项目模块

这两种模块虽然寻径方式有略微的不同,但是也有一些共通之处。

如果Node.js通过寻径找出的路径代表的是一个目录,那么其会依次寻找该目录下的"index.js"、"index.json"以及"index.node"文件。若存在上述任何一个字段,则立刻返回。

比如一个JavaScript文件路径是/Users/biu/index.js,那么在执行require("/Users/biu")的时候,该JavaScript文件会被加载。

不过寻找到的如果是一个第三方模块目录,其目录下存在一个合法package.json文件的话,会在上述步骤之前解析package.json中的"main"字段。若该字段存在且合法,那么会直接加载该字段所指向的文件。

如当前目录下有这么一个目录结构,如图1-2所示。

如果在a_program.js中的代码是这样的:

那么在模块寻径的时候会是node_modules/biu目录,该目录下同时存在package.json和index.js,这个时候Node.js会先解析package.json文件。

图1-2 文件模块样例目录结构

这个时候如果package.json里面是这样的:

那么,这个时候执行该require()所得到的结果就应该是node_modules/biu/biu.js这个文件了。

(3)第三方模块

除了上述的核心模块外,其他不是以"/"、"./"或者"../"开头的字符串作为标识的模块被称为第三方模块,这些模块通常以Node.js依赖包的形式存在。当require()函数传入的是第三方模块的标识时,则Node.js不仅仅在当前目录的node_modules目录下寻找文件名或者文件夹名与之相吻合的模块。这个“不仅仅”的意思如下:

① 当前文件目录的node_modules目录下;

② 若①没有符合的模块,则去当前文件目录的父目录的node_modules下;

③ 若没有符合的模块,则再往上一层目录的node_modules;

④ 若没有符合的模块,重复③,直到寻找到符合的模块或者根目录为止。

在找到符合的模块之后,require()函数就会返回找到的模块所暴露的API了。

(4)项目模块

在一个项目中执行require()来载入一个"/"、"./"或者"../"带头的模块就是项目模块了。它会加载文件路径相对于传入的标识的相对路径的模块,或者是绝对路径所指向的模块。由于通常加载Node.js模块的时候我们并不需要给其加上后缀名,这也是CommonJS中所规定的,因此Node.js在加载项目模块或者第三方模块的时候会枚举尝试后缀名。尝试的后缀名依次为".js"、".json"和".node",其中".node"就是C++模块。

图1-3显示了一个项目模块的样例目录结构。

图1-3 项目模块样例目录结构

假设当前目录下的文件结构如图1-3所示,如果我们在program.js的代码是这样的:

那么在模块加载寻径的时候,显而易见./cpp_addon是不存在的,这个时候Node.js就会依次去寻找./cpp_addon.js、./cpp_addon.json和./cpp_addon.node,最后会加载并返回cpp_addon.node所暴露的API。

2.模块缓存

实际上在Node.js运行中,通常情况下一个包一旦被加载了,那么在第二次执行require()的时候就会在缓存中获取暴露的API,而不会重新加载一遍该模块里面的代码再次返回。

如果有一个文件是dog.js,代码如下蛋花汤是笔者养的一条宠物狗的名字。

而且在相同目录下有一个entry.js,代码如下在Node.js以及大多数的JavaScript解释器中都支持Unicode字符串作为变量名,可以参照https://mathiasbynens.be/notes/javascript-identifiers。当然,在实际线上项目中笔者还是推荐按照团队规范来,而个人的玩具项目则可以多尝试这些好玩的特性。

在entry.js中执行require()时会通过寻径找到dog.js并执行,暴露出来的变量值为"嘘,蛋花汤在睡觉。",也就是说输出会如下所示:

第一句输出{'':'嘘,蛋花汤在睡觉。'},理所当然;到了第二句的require()时,由于dog.js已经被加载过了,Node.js中会将其暴露的内容缓存到它的运行时中(其原理会在后续的章节中加以阐释),这个时候再执行require()就会直接返回内存中已加载好的module.exports,如下所示:

而不会出现重新执行一遍dog.js,重新声明boom这个变量,更不会在原来的boom基础上再拼接一次"在睡觉。"以及出现"嘘,蛋花汤在睡觉。在睡觉。"的荒谬结果。

1.1.3 小结

本节主要讲述了CommonJS的模块规范,以及基于它进行改装的Node.js模块规范。

本节列举了CommonJS模块规范的三个要素:require、模块上下文和模块标识,讲述了Node.js的模块寻径算法和模块缓存机制。

这些老生常谈的基础内容是在Node.js C++模块开发中必不可少的,且不仅仅局限于C++模块开发,任何Node.js模块开发都离不开这些基础知识。

1.1.4 参考资料

[1]Modules/1.1.1:http://wiki.commonjs.org/wiki/Modules/1.1.1.

[2]朴灵.深入浅出Node.js[M].北京:人民邮电出版社,2013.

[3]Modules-Node.js v6.9.4 Documentation:https://nodejs.org/docs/v6.9.4/api/modules.html#modules_addenda_package_manager_tips.