在长时间的尝试中,JavaScript被不断的类聚和抽象,以更好的组织业务逻辑,但是在一个角度而言,它也道出了JavaScript先天就缺乏的一项功能——模块。
Java有类文件,Python有import机制,Ruby有require。而JavaScript只有通过
<script>
标签引入而显得杂乱无章,语言本身毫无组织和约束能力。
经过了十多年的发展,CommonJs规范的提出算是一个重要的里程碑。
CommonJS规范
CommonJS规范为Javascript制定了一个美好的愿景,希望JavaScript能够在任何地方运行。
CommonJS将模块分为:
- 模块引用
- 模块定义
- 模块标识
- 模块引用
var math=require('math')
require()方法接受模块标识,以此引入一个模块的API到当前上下文中。
- 模块定义
直接看代码:
//math.js
exports.add = function () {
var sum = 0,
i = 0,
args = arguments,
l = args.length;
while (i < l) {
sum += args[i++];
}
return sum;
};
在Node中,一个文件(比如math.js)就是一个module对象,它代表模块自身,而exports是module的属性。
上下文提供了exports对象用于导出当前模块的方法或者变量,并且它是唯一导出的出口。
在另一个文件中,通过require()方法引入模块后,就可以调用定义的属性或者方法了。比如:
// program.js
var math = require('math');
exports.increment = function (val) {
return math.add(val, 1);
};
- 模块标识
模块标识其实就是传递给require()方法的参数,它必须是符合小驼峰命名的字符串,或者以..
或者.
开头的相对/绝对路径。它可以没有后缀名js。
Node的模块实现
尽管规范中的exports/require/module听起来十分简单,但是Node中引入模块,需要经历下面三个步骤:
1. 路径分析
2. 文件定位
3. 编译执行
在Node中,模块分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。
- 核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。这部分在Node进程启动时,直接被加载进内存中。所以文件定位和编译执行这两个步骤被跳过,并且在路径分析中优先判断,所以加载速度最快。
- 文件部分要经历完成的上述三个过程。
优先从缓存加载
Node对引入过的模块都会进行缓存,以减少二次引入时的开销。
不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先的方式。
路径分析和文件定位
- 模块标识分析符
前面提到过require()接受一个标识符作为参数。模块标识符主要分为下面几类:
- 核心模块,如http、fs、path等。
.
或..
开始的相对路径文件模块。- 以
/
开始的绝对路径文件模块。 - 非路径形式的文件模块,如自定义的connect模块。
下面的方式实验:
(1)创建module_path.js文件,其内容为console.log(module.paths)
(2)将其放在任意一个目录然后执行node module_path.js
根据其输出,可以看到,模块路径的生成规则:
- 当前目录下的node_modules目录
- 父目录下的node_modules目录
- …
- 沿路径向上逐级扫描,直到根目录下的node_modules目录
当前文件的路径越深,模块查找会越耗时,这是自定义模块的加载速度最慢的原因。
- 文件定位
从缓存加载使得第二次引入不需要分析路径、文件定位和编译执行的过程。
但是在文件的定位过程中,还有一些细节需要注意,包括文件扩展名的分析、目录和包的处理。
CommonJS模块规范允许标识符中不包含文件扩展名,这种情况下Node会按照.js、.json、.node的次序补足扩展名,依次尝试。所以加上扩展名会加快一点速度。
- 目录分析和包
如果在分析扩展名后,没有找到对应的文件但是找到了对应的目录,这时Node会将目录当作一个包来处理。
模块编译
Node中每个文件模块都是一个对象,它的定义如下:
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
if (parent && parent.children) {
parent.children.push(this);
}
this.filename = null;
this.loaded = false;
this.children = [];
}
编译和执行是引入文件模块的最后一个阶段。针对不同的文件名,载入方法也不同:
- .js文件:通过fs模块同步读取文件后编译执行。
- .node文件:通过dlopen()方法处理。
- .json文件:通过fs模块同步读取文件后,用JSON.parse()解析返回结果。
- 其余扩展名:当作.js文件载入。
NPM
Node组织了自身的核心模块,也使得第三方文件模块可以有序的编写和使用。但是在第三方模块中,模块与模块之间仍然是散列在各地的,相互之间不能直接引用。而在模块之外,包和NPM则是将模块联系起来的一种机制。
包的出现,则是在模块的基础上进一步组织JavaScript代码。
包结构
包实际上是一个存档文件,即一个目录直接打包为.zip或tar.gz文件。安装后解压还原为目录。
完全符合CommonJS规范的包目录应该包含如下这些文件:
- package.json:包描述文件。
- bin:用于存放可执行的二进制文件的目录。
- lib:用于存放JavaScript代码的目录。
- doc:用于存放文档的目录。
- test:用于存放单元测试用例的代码。
NPM帮助Node完成了第三方模块的发布、安装和依赖。借助NPM,Node与第三方模块之间形成了很好的生态系统。
包描述文件与NPM
package.json文件定义了如下一些必须的字段:
- name:包名。
- description:包简介。
- version:版本号。
- keywords:关键词数组。NPM中主要用来做分类搜索,一个好的关键词数组有利于用户快速找到你编写的包。
- maincontainers:包维护者列表。每个维护者由name、email、web组成。比如:“maincontainers”:[{“name”:“LeesangHyuk”,“email”:“xxxx@163.com”,“web”:“http://xxxx.xxxx”}]
- contributors:贡献者列表。
- bugs:反馈bug的网页地址。
- …等等