2.模块机制

1.CommonJs规范

1.1 CommonJs的出发点

在JavaScript的发展历程中,它主要在浏览器前端发光发热。由于官方规范(ECMAScript)规范化的时间较
早,规范涵盖的范畴非常小。这些规范中包含词法、类型、上下文、表达式、声明(statement)、方法、对
象等语言的基本要素。在实际应用中,JavaScript的表现能力取决于宿主环境中的API支持程度。在Web 1.0
时代,只有对DOM、BOM等基本的支持。随着Web 2.0的推进,HTML5崭露头角,它将Web网页带进Web应
用的时代,在浏览器中出现了更多、更强大的API供JavaScript调用,这得感谢W3C组织对HTML5规范的推
进以及各大浏览器厂商对规范的大力支持。但是,Web在发展,浏览器中出现了更多的标准API,这些过程
发生在前端,后端JavaScript的规范却远远落后。对于JavaScript自身而言,它的规范依然是薄弱的,还有以
下缺陷。

  • 缺乏模块系统
  • 标准库较少,ECMAscript定义了部分核心库,对于文件系统,I/O流等常见需求却没有标准的
    API。就HTML5的发展状况而言,W3C标准化在一定意义上是在推进这个过程,但是它仅限于浏览器
    端。
  • 没有标准接口。在JavaScript中,几乎没有定义过如Web服务器或者数据库之类的标准统一接口。
  • 缺乏包管理系统。这导致JavaScript应用中基本没有自动加载和安装依赖的能力。

CommonJS规范的提出,主要是为了弥补当前JavaScript没有标准的缺陷,以达到像Python、Ruby和Java具备开发大型应用的基础能力,而不是停留在小脚本程序的阶段。他们期望那些用CommonJS API写出的应用可以具备跨宿主环境执行的能力,这样不仅可以利用JavaScript开发富客户端应用,而且还可以编写以下应用。

  • 服务器端JavaScript应用程序
  • 命令行工具
  • 桌面图形界面应用程序
  • 混合应用(Titanium和Adobe AIR等形式的应用)
2.模块机制_第1张图片
Node与浏览器以及W3C组织、CommonJS组织、ECMAScript之间的关系

1.2 CommonJS的模块规范

CommonJS对模块的定义十分简单,主要分为模块引用、模块定义模块标识3个部分。

  1. 模块引用
var math = require('math');
  1. 模块定义
    在模块中,上下文提供require()方法来引入外部模块。对应引入的功能,上下文提供了exports对象
    用于导出当前模块的方法或者变量,并且它是唯一导出的出口。在模块中,还存在一个module对象,
    它代表模块自身,而exports是module的属性。在Node中,一个文件就是一个模块,将方法挂载
    在exports对象上作为属性即可定义导出的方式:
// math.js
exports.add = function () {
  var sum = 0,
  i = 0,
  args = arguments,
  l = args.length;
  while (i < l) {
    sum += args[i++];
  }
  return sum;
};

在另一个文件中,我们通过require()方法引入模块后,就能调用定义的属性或方法了:

// program.js
var math = require('math');
exports.increment = function (val) {
return math.add(val, 1);
};
  1. 模块标识
    模块标识其实就是传递给require()方法的参数,它必须是符合小驼峰命名的字符串,或者以.、..开
    头的相对路径,或者绝对路径。它可以没有文件名后缀.js。


    2.模块机制_第2张图片
    模块定义

    CommonJS构建的这套模块导出和引入机制使得用户完全不必考虑变量污染,命名空间等方案与之相
    比相形见绌。

2. Node的模块实现

在Node中引入模块,需要经历如下3个步骤

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

在Node中,模块分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件
模块。

  • 核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,部分核
    心模块
    就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省
    略掉,并且在路径分析中优先判断,所以它的加载速度是最 快的。
  • 文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块
    慢。
    1.3.1 优先从缓存加载
    1.3.2 路径分析和文件定位
    因为标识符有几种形式,对于不同的标识符,模块的查找和定位有不同程度上的差异。
  1. 模块标识符分析
    require()方法接受一个标识符作为参数。在Node实现中,正是基于这样一个标识符进
    行模块查找的。模块标识符在Node中主要分为以下几类。
  • 核心模块,如http、fs、path等。
  • .或..开始的相对路径文件模块。
  • 以/开始的绝对路径文件模块。
  • 非路径形式的文件模块,如自定义的connect模块。
  • 核心模块,核心模块的优先级仅次于缓存加载,它在Node的源代码编译过程中已经编译为二进制代码,其
    加载过程最快。
  • 路径形式的文件模块
    以.、..和/开始的标识符,这里都被当做文件模块来处理。在分析路径模块时,require()方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快。
    由于文件模块给Node指明了确切的文件位置,所以在查找过程中可以节约大量时间,其加载速度慢于核心模块。
  • 自定义模块
    自定义模块指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或者包的形式。这类模块的查找是最费时的,也是所有方式中最慢的一种。

模块路径是Node在定位文件模块的具体文件时制定的查找策略,具体表现为一个路径组成的数组。模块路径的生成规则如下所示。

○ 当前文件目录下的node_modules目录。
○ 父目录下的node_modules目录。
○ 父目录的父目录下的node_modules目录。
○ 沿路径向上逐级递归,直到根目录下的node_modules目录。

它的生成方式与JavaScript的原型链或作用域链的查找方式十分类似。在加载的过程中,Node会逐个
尝试模块路径中的路径,直到找到目标文件为止。可以看出,当前文件的路径越深,模块查找耗时会
越多,这是自定义模块的加载速度是最慢的原因。

  1. 文件定位
    文件的定位过程中,主要有文件扩展名的分析、目录和包的处理。
    ○ 文件扩展名
    require()在分析标识符的过程中,会出现标识符中不包含文件扩展名的情况。CommonJS模块规范也允许在标识符中不包含文件扩展名,这种情况下,Node会按.js、.json、.node的次序补足扩展名,依次尝试。
    在尝试的过程中,需要调用fs模块同步阻塞式地判断文件是否存在。如果是.node和.json文件,在传递给require()的标识符中带上扩展名,会加快一点速度。
    ○ 目录分析和包
     在分析标识符的过程中,require()通过分析文件扩展名之后,可能没有查找到对应文件,但却得到一个目录,这在引入自定义模块和逐个模块路径进行查找时经常会出现,此时Node会将目录当做一个包来处理。
     在这个过程中,Node对CommonJS包规范进行了一定程度的支持。首先,Node在当前目录下查找package.json(CommonJS包规范定义的包描述文件),通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤。
     而如果main属性指定的文件名错误,或者压根没有package.json文件,Node会将index当做默认
    文件名,然后依次查找index.js、index.node、index.json。
     如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查
    找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。
  2. 模块编译
    在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 = [];
}

编译和执行是引入文件模块的最后一个阶段。定位到具体的文件后,Node会新建一个模块对象,然后根据
路径载入并编译。对于不同的文件扩展名,其载入方法也有所不同,具体如下所示:

  • .js文件。通过fs模块同步读取文件后编译执行。
  • .node文件。这是用C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件。
  • .json文件。通过fs模块同步读取文件后,用JSON.parse()解析返回结果。
  • 其余扩展名文件。它们都被当做.js文件载入。

在确定文件的扩展名之后,Node将调用具体的编译方式来将文件执行后返回给调用者。

  1. JavaScript模块的编译
    在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装:
(function (exports, require, module, __filename, __dirname) {
  var math = require('math');
  exports.area = function (radius) {
    return Math.PI * radius * radius;
  };
});

exports属性上的任何方法和属性都可以被外部调用到,但是模块中的其余变量或属
性则不可直接被调用。
注:exports对象是通过形参的方式传入的,直接赋值形参会改变形参的引用。如果要达到require引入一个类的效果,请赋值给module.exports对象。

  1. C/C++模块的编译
    Node调用process.dlopen()方法进行加载和执行。在Node的架构下,dlopen()方法在Windows和*nix平台下分别有不同的实现,通过libuv兼容层进行了封装。
  2. JSON文件的编译
    json文件的编译是3种编译方式中最简单的。Node利用fs模块同步读取JSON文件的内容之后,调用JSON.parse()方法得到对象,然后将它赋给模块对象的exports,以供外部调用。

3.核心模块

4.包与npm

2.模块机制_第3张图片
包组织模块示意图

4.1 包结构

包实际上是一个存档文件,即一个目录直接打包为.zip或tar.gz格式的文件,安装后解压还原为目录。完全符合CommonJS规范的包目录应该包含如下这些文件。

  • package.json:包描述文件
  • bin:用于存放可执行二进制文件的目录
  • lib:用于存放JavaScript代码的目录
  • doc:用于存放文档的目录
  • test:用于存放单元测试用例的代码

4.2 包描述文件(package.json)

CommonJS为package.json文件定义了如下一些必需的字段。

  • name。包名。规范定义它需要由小写的字母和数字组成,可以包含.、_和-,但不允许出现空格。包名必须是唯一的,以免对外公布时产生重名冲突的误解。除此之外,NPM还建议不要在包名中附带上node或js来重复标识它是JavaScript或Node模块。
  • description。包简介。
  • version。版本号。一个语义化的版本号,这在http://semver.org/上有详细定义,通常为major.minor.revision格式。该版本号十分重要,常常用于一些版本控制的场合。
  • keywords。关键词数组,NPM中主要用来做分类搜索。一个好的关键词数组有利于用户快速找到你编写的包。
  • maintainers。包维护者列表。每个维护者由name、email和web这3个属性组成。示例如下:
    "maintainers": [{ "name": "Jackson Tian", "email": "[email protected]", "web": "http://html5ify.com" }]

4.3 前后端共用模块

由于宿舍环境不同且加载方式(Node同步,前端异步),CommonJS为后端JavaScript制定的规范并不完全适合前端的应用场景。前端采用AMD---异步模块定义和玉伯定义的CMD规范。

4.3.1 AMD规范

AMD规范是CommonJS模块规范的一个延伸,它的模块定义如下:
define(id?, dependencies?, factory);
AMD模块需要用define来明确定义一个模块,而在Node实现中是隐式包装的,它们的目的是进行作用域隔离,仅在需要的时候被引入,避免掉过去那种通过全局变量或者全局命名空间的方式,以免变量污染和不小心被修改。另一个区别则是内容需要通过返回(return)的方式实现导出。

4.3.2 CMD规范

CMD规范由国内的玉伯提出,与AMD规范的主要区别在于定义模块和依赖引入的部分。AMD需要在声明模块的时候指定所有的依赖,通过形参传递依赖到模块内容中:

define(['dep1', 'dep2'], function (dep1, dep2) {
  return function () {};
});

与AMD模块规范相比,CMD模块更接近于Node对CommonJS规范的定义:

define(factory);

在依赖部分,CMD支持动态引入,示例如下:

define(function(require, exports, module) {
  // The module code goes here
});

require、exports和module通过形参传递给模块,在需要依赖模块时,随时调用require()引入即可。

4.3.3 兼容多种模块规范

;(function (name, definition) {
  // 检测上下文环境是否为AMD或CMD
  var hasDefine = typeof define === 'function',
  // 检查上下文环境是否为Node
  hasExports = typeof module !== 'undefined' && module.exports;
  if (hasDefine) {
    // AMD环境或CMD环境
    define(definition);
  } else if (hasExports) {
    // 定义为普通Node模块
    module.exports = definition();
  } else {
    // 将模块的执行结果挂在window变量中,在浏览器中this指向window对象
    this[name] = definition();
  }
})('hello', function () {
  var hello = function () {};
  return hello;
});

你可能感兴趣的:(2.模块机制)