Node学习笔记-Node中的模块机制

之后会发表一系列的学习笔记,Node 系列的学习笔记是基于我在阅读 朴灵所著《深入浅出 Nodejs》一书的学习总结和札记。这是第一篇。

通过阅读本文章,你可以了解到:

  1. 三个重要的 JS 模块规范:CommonJS 规范、AMD 规范和 CMD 规范
  2. Node 的模块加载机制
  3. Node 中的内建模块、核心模块、文件模块和 C/C++ 扩展模块
  4. Node 中的包与 NPM

三个重要的 JS 模块规范

为什么要制定 CommonJS 规范?

因为官方的规范(ECMAScript)所规范的范围太小,这些规范内只提及了最基本的一些要素:词法、类型、上下文、表达式、声明、方法、对象等规范。虽然现在已经流行起了 HTML5,但是html5 仅仅是 W3C 组织针对浏览器端的规范,对于后端 javascript 来说,仍然相对薄弱,或者说,对于JavaScript 本身来说,它依旧是薄弱的。具体缺陷如:

  • 没有包管理系统
  • 没有模块系统
  • 标准库比较少(例如文件系统、I/O流等,虽然W3C在推动,但是仅限于浏览器端。)
  • 没有一个标准的接口(没有服务器或数据库之类的标准接口)

所以 CommonJS 规范就诞生了~

它为JavaScript 补齐了诸如 模块、二进制、I/O流、文件系统、WEB服务器网关接口、包管理等诸多规范。

CommonJS 模块规范

CommonJS 对于模块的定义十分简单,主要涉及到 模块引入、模块定义和模块标识三个部分。先看一个例子:

//example.js
var x = 5;
var addX = function (value) {
  return value + x;
};
exports.x = x;
exports.addX = addX;

//other.js
var example = require('example');
console.log(example.x); // 5
console.log(example.addX(1)); // 6

在Node 中,一个文件就是一个模块,每个模块(文件)中都默认宝来了一个 exports 对象,这个对象用于导出模块的变量分或者方法,并且这个对象是模块导出的唯一出口。模块中还包含一个 module 对象,module 对象就是模块自身,同理,刚刚提到的 exports 对象是 module 对象的一个子属性。上文中example.js 还可以写成:

var x = 5;
var addX = function (value) {
  return value + x;
};
module.exports.x = x;
module.exports.addX = addX;

我们可以直接使用 require(‘example’) 来引用 example.js 中定义的模块。require() 中接受一个模块的标识,’example’ 就是一个模块标识。

模块标识可以是:小驼峰的字符串、或者一个相对或绝对路径、也可以是一个没有 .js 后缀的文件名。

AMD模块规范

CommonJS 规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。AMD 规范则是非同步加载模块,允许指定回调函数。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以 CommonJS 规范比较适用。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用 AMD 规范。

注意 Node 中采用的是 CommonJS 规范。

AMD规范使用define方法定义模块,下面就是一个例子:

define(['package/lib'], function(lib){
  function foo(){
    lib.log('hello world!');
  }

  return {
    foo: foo
  };
});

自 RequireJS 2.0 开始,AMD规范允许输出的模块兼容CommonJS规范,这时define方法需要写成下面这样:

define(function (require, exports, module){
  var someModule = require("someModule");
  var anotherModule = require("anotherModule");

  someModule.doTehAwesome();
  anotherModule.doMoarAwesome();

  exports.asplode = function (){
    someModule.doTehAwesome();
    anotherModule.doMoarAwesome();
  };
});

CMD 模块规范

AMD 是 RequireJS 在推广过程中对模块定义的规范化产出。CMD 是 SeaJS 在推广过程中对模块定义的规范化产出。CMD 规范是由国内 玉伯 提出的。

实际上 AMD 规范自 RequireJS 2.0 开始,也开始支持 CMD 的这种写法了,但是 AMD 官方并不推崇这种写法。现在,AMD 规范和 CMD 规范 通常被合并为 AMD/CMD规范。

CMD 推崇依赖就近,AMD 推崇依赖前置。看代码:

// CMD
define(function(require, exports, module) {
var a = require('./a')
a.doSomething()
// 此处略去 100 行
var b = require('./b') // 依赖可以就近书写
b.doSomething()
// ... 
})

// AMD 默认推荐的是
define(['./a', './b'], function(a, b) { // 依赖必须一开始就写好
a.doSomething()
// 此处略去 100 行
b.doSomething()
...
})

虽然 AMD 也支持 CMD 的写法,同时还支持将 require 作为依赖项传递,但 RequireJS 的作者默认是最喜欢上面的写法,也是官方文档里默认的模块定义写法。

需要注意的是,CMD 规范是 不支持 模块依赖前置的,也就是说没有全局 require,而是就近引用。

更多内容参见这里 – 知乎玉伯亲自对AMD和CMD作出的解释。、

Node 中的内建模块、核心模块、文件模块和 C/C++ 扩展模块

Node 中的所有模块可以分为两类:Node 自身提供的核心模块和用户自己写的文件模块

核心模块中,有一部分是用 C/C++ 完成核心功能,然后用 JavaScript 进行包装或向外导出,一般来说,C/C++ 的执行效率和性能要优于 JavaScript,但是JavaScript 的高度封装,又使得使用 JavaScript 开发的开发速度比C/C++ 高出数倍。这类 C/C++ 和JavaScript 混合的模块能够在 性能和开发速度之剑找到一个平衡点。

当然,核心模块中,还有另一类模块,这类模块完全由 C/C++ 编写完毕,一般来说,用户是直接调取不到的,我们把这些模块称之为内建模块。这些模块由于是由C/C++ 编写的,所以其本身性能就高于 JavaScript 模块,其次在进行文件编译的时候,他们会被编译成二进制文件,在Node开始执行的时候,他们就会被直接加载到内存中,后续无需寻址和编译,直接执行。

用户自己写的 js 模块文件被称为文件模块

当然,如果我们的 js 模块的性能开销十分巨大的时候(例如频繁的位运算、转码、编码等,使用 JavaScript 会需要巨大的 CPU资源),我们不妨使用C/C++ 编写 扩展模块来提升我们整体模块的性能。使用 C/C++ 编写的扩展模块并不会像内建模块一样一开始就编译并加载到内存中,而是先编译成 .node 文件,在需要用到的时候,通过 process.dlopen() 动态加载、编译执行。

注意,linux 下的 .node 文件和 windows 下的 .node 文件不兼容。更多内建模块的运行机制、如何编写,以及C/C++ 扩展模块如何编写、编译,可以参见《深入浅出 Nodejs》

Node 的模块加载机制

前面已经介绍了Node 中的模块类型,如果要在 Node 中引入模块,需要经历3个步骤。

  1. 路径分析
  2. 文件定位
  3. 编译执行

对于任何模块,Node 都会优先从缓存加载,这是最高的优先级,优于其他一切模块的引入顺序。如果Node 引入了一个新的模块,就会自动将其编译和习性后的对象缓存,下次就直接从缓存中读取,而不需要再次分析路径、编译执行。

前面已经提到过引入一个模块的方法是:通过 require() 来引入,括号内接受一个标识符。Node 就是通过这个标识符查找到需要的文件。这个标识符有好多种:

  • 核心模块,例如 http、fs、path等
  • 一个文件模块的相对路径或绝对路径
  • 非路径形式的文件名

如果在缓存中找不到上述列表中的某个模块,Node 会接下来去找 核心模块。核心模块的优先级仅次于缓存模块,它们在Node编译的时候,已经被编译为二进制文件,加载的过程最快。

注意如果用户编写了一个和核心模块名称相同的模块,是不会被加载成功的,因为Node 会优先去核心模块中查找有没有这个名字的模块。如果一定要用的话,需要改成别的名字,或者改用路径的方式引用这个模块。

接下来Node 会去查找文件模块。require() 方法会首先将路径转化为真实的路径,并把真实路径作为索引,将编译执行后的结果放到缓存中。优于这种模块被指明了所在路径,加载时会节省大量的路径查找的时间,速度仅次于核心模块。

然后,我们要介绍这种自定义的没有路径的模块。它可能是一个文件或者一个包。这种模块的查找和匹配是最费时的,也是所有方式中最慢的。

这时候,Node 先会去进行路径分析,Node 会先在当前文件目录下的node_modules 目录内查找,若没有,就会去其父级目录的 node_modules 目录内查找,若没有,就会去更上级的目录中去查找,直到根目录。

每次路径分析后,都会进行文件定位,首先,我们上面的模块不仅没有路径,也没有扩展名,所以,Node 会按 .js、.json、.node 的扩展名依次去匹配,在尝试匹配的过程中,会调用 fs 模块来匹配。优于Node 是单线程的, fs 的同步阻塞会造成 Node 的性能问题。所以建议 js 可以不加扩展名,但是 json 和 node 都加上扩展名。

若文件后缀匹配后还是没有找到需要的模块文件,这时却有一个目录与其对应,那么这个时候,Node将会自动将这个目录当做一个来处理。接下来,Node 会到这个目录的根目录去查找 package.json, 然后通过其中的 main 属性对模块入口文件进行定位。如果缺少扩展名,就会进入扩展名分析的步骤,如果main指定的文件错误,或者根本就没有package.json,那么就会自动把目录内的 index.js、index.json、index.node 当做默认文件。如果还是没有,就会接着往上层目录继续进行目录查找。如果到根目录还是没有,就会报错。

关于模块编译的相关内容暂不做解释,有兴趣可以参见《深入浅出 Nodejs》

Node 中的包与 NPM

上文已经提到了的概念。

在nodejs中,可以通过包来对一组具有相互依赖关系的模块进行统一管理。一个包其实就是一个目录直接打包后的 .zip 或 tar.gz 格式的文件,其中包含了用于对包进行描述的JSON格式的package.json文件,在一个包中,通常包含如下所示的内容:

  1. 在包的根目录存放package.json文件。
  2. 在bin子目录中存放二进制文件。
  3. 在lib子目录中存放javascript文件。
  4. 在doc子目录存放对包和包的使用方法进行说明的文档。
  5. 在test子目录中存放一些对包进行单元测试的测试用例。

如何发布一个包到NPM?

首先在npmjs.org注册一个账号,如下:https://www.npmjs.org/signup

然后通过 npm adduser 来注册新账号或登录老账号:

最后,在我们模块的根目录,通过命令 npm publish . 发布包了,如下所示:

我们可以登录npm查看已经发布的包,如下:

可以看到publicbag是我们刚刚发布的包~。

如何下载我们刚刚发布的包?

下面我们还是进入F盘后,新建文件夹loadBag文件,然后进入命令行窗口后,进入loadBag文件内,使用命令 npm install publicbag 即可把publicbag包下载到loadBag文件内,如下所示:

然后在loadBag文件内生存包publicbag,如下所示:

当然我们也可以全局下载安装;npm install publicbag –g 如下:

就自动下载到我们npm安装包路径下了。

npm 包管理工具

在node.js中,提供了一个npm包管理工具,该管理工具可用第三方网站上下载node.js包。我们可以在命令行中,通过不同的命令输入以执行npm包管理工具中的各种功能。

  • 可以通过如下所示的命令来卸载命令行提示窗口当前目录下安装的某个包:npm  uninstall <包名>
  • 可以通过如下所示的命令来卸载Node.js的全局包的安装路径下的安装的某个包。npm  uninstall  -g  <包名>
  • 可以通过如下所示的命令来更新命令行提示窗口当前目录下安装的某个包。npm  update <包名>
  • 可以通过如下所示的命令来更新Node.js的全局包的安装路径下安装的某个包。npm update –g <包名>
  • 可以通过如下所示的命令来更新命令行提示窗口当前目录下安装的所有包。npm update
  • 可以通过如下所示的命令来更新Node.js的全局包的安装路径下安装所有包。npm update -g

关于如何镜像一个局域网内NPM 可以参见《深入浅出 Nodejs》,里面有十分详细且简单易懂的教程