CommonJS规范

前言

CommonJS规范的提出,使得javascript具备开发大型应用的基础能力,规范制定者希望用CommonJS API写出的应用可以具备跨宿主环境的能力,能够在任何地方运行。这样javascript不仅可以用开发富客户端应用,而且还可以编写:

  1. 服务器端javascript应用程序
  2. 命令行工具
  3. 桌面图形界面应用程序。
  4. 混合应用。

目前,该规范依旧在成长。它涵盖了模块、二进制、Buffer、字符集编码、I/O流、进程环境、文件系统、套接字、单元测试、web服务器网关接口、包管理等。
node借鉴CommonJS的Modules规范实现了一套非常易用的模块系统,NPM对Packages规范的完好支持使得Node应用在开发中事半功倍。

CommonJS的模块规范

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

1.模块引用

var math = require('math');

require这个方法接受模块标识,以此引入一个模块的API到当前上下文中。

2.模块定义

对应引入的功能,上下文提供了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;
};

// program.js
var math = require('./math');
console.log(math.add(1, 2));// 3

3.模块标识

模块标识其实就是传递给require()方法的参数,它必须是符合小驼峰命名的字符串,或者以.、..开头的相对路径,或者绝对路径。可以没有文件名后缀.js。

模块的意义在于将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出功能以顺畅地连接上下游依赖。

node的模块实现

Node在实现中并非完全按照规范实现。

在node中引入模块,需要经历3个步骤。

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

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

  1. 核心模块部分在node源代码的编译过程中,编译进了二进制执行文件。在node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。
  2. 文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

接下来,我们展开详细的模块加载过程。

1.优先从缓存加载

node对引入过的模块都会进行缓存,以减少二次引入时的开销。与浏览器仅仅缓存文件不同,node缓存的是编译和执行之后的对象。

无论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的,核心模块的缓存检查先于文件模块的缓存检查。

2.路径分析和文件定位

2.1模块标识符分析

require()方法接受一个标识符作为参数,node正是基于这样一个标识符进行模块查找的。模块标识符在node中主要分为几类。

  1. 核心模块,如http、fs、path等
  2. 以.或..开始的相对路径文件模块
  3. 以/开始的绝对路径文件模块
  4. 非路径形式的文件模块,如自定义的connect模块
  5. 核心模块

核心模块的优先级仅次于缓存加载,它在node的源代码编译过程中已经编译为二进制文件代码,其加载过程最快。

  1. 路径形式的文件模块

以.、..和/开始的标识符,这里都被当做文件模块来处理。在分析路径模块时,require()方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快。由于文件模块给node指明了确切的文件位置,所以在查找过程中可以节约大量时间,其加载速度慢于核心模块。

  1. 自定义模块

自定义模块指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或者包的形式。这类模块的查找是最费时的,也是所有方式中最慢的一种。

我们需要先介绍一下模块路径这个概念。模块路径是node在定位文件模块的具体文件时制定的查找策略,具体表现为一个路径组成的数组。关于这个路径的生成规则,我们可以手动尝试一番。

(1)创建module_path.js文件,其内容为console.log(module.paths);
(2)将其放在任意一个目录中然后执行node module_path.js

在Linux下,你可能得到的是这样一个数组输出:

['/home/jackson/research/node_modules',
'/home/jackson/node_modules',
'/home/node_modules',
'/node_modules']

在window下,也许是这样:

['c:\\nodejs\\node_modules', 'c:\\node_modules']

可以看出,模块路径的生成规则如下所示。
1.当前文件目录下的node_modules目录
2.父目录下的node_modules目录
3.父目录的父目录下的node_modules目录
4.沿路径向上逐级递归,直到根目录下的node_modules目录
在加载过程中,node会逐个尝试模块路径中的路径,直到找到目标文件为止。当前文件路径越深,模块查找耗时越多,这是自定义模块的加载速度最慢的原因。

2.1文件定位

从缓存加载的优化策略使得二次引入时不需要路径分析、文件定位和编译执行的过程,大大提高了再次加载模块的效率。

但在文件定位过程中,还有一些细节需要注意,这主要包括文件拓展名的分析、目录和包的处理。
2.1.1文件扩展名分析
CommonJS模块规范允许在标识符中不包含文件扩展名,这种情况下node会按.js、.json、.node的次序补足扩展名,依次尝试。在尝试过程中,需要调用fs模块同步阻塞式判断文件是否存在。因为node是单线程,这里是一个会引起性能问题的地方。小诀窍是:标识符带上扩展名,这样会加快一点速度。另一个诀窍是:同步配合缓存,也可以大幅度缓解Node单线程阻塞式调用的缺陷。

2.1.2目录分析和包

在分析标识符的过程中,require()通过分析拓展名之后,可能没有查找到对应文件,但却得到一个目录,此时Node会将目录当做一个包来处理。

Node会在当前目录下查找package.json(包描述文件),通过JSON.parse()解析包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤。而如果main属性指定的文件名错误,或者压根没有package.json文件,node会将index当做默认文件名,然后依次查找index.js、index.json、index.node。

如果没有定位成功,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依旧没有查找到,则会抛出查找失败的异常。

模块编译

在Node中,每个文件模块都是一个对象,它的定义如下

function Module(id, parent) {
  this.id = id; // 模块的识别符,通常是带有绝对路径的模块文件名。
  this.exports = {}; // 表示模块对外输出的值。
  this.parent = parent; // 返回一个对象,表示调用该模块的模块。可以判断parent是否为null判断当前模块是否为入口脚本。
  if (parent && parent.children) {
    parent.children.push(this);
  }
  this.filename = null; // 模块的文件名,带有绝对路径。
  this.loaded = false; // 返回一个布尔值,表示模块是否已经完成加载。
  this.children = []; // 返回一个数组,表示该模块要用到的其他模块。
}

定位到具体的文件后,Node会新建一个模块对象,然后根据路径载入并编译。对于不同的文件扩展名,其载入方法也有所不同。

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

每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能
.json文件的调用如下:

// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
  var content = NativeModule.require('fs').readFileSync(filename, 'utf8');
  try {
    module.exports = JSON.parse(stripBOM(content));
  } catch (err) {
    err.message = filename + ':' + err.message;
    throw err;
  }
}

其中,Module._extensions会被赋值给require()的extensions属性,所以访问require.extensions可以知道系统中已有的扩展加载方式:

console.log(require.extensions);

结果如下:

[Object: null prototype] { '.js': [Function], '.json': [Function], '.node': [Function] }

如果想对自定义的扩展名进行特殊的加载,可以通过类似require.extensions['.ext']的方式实现。早期的CoffeeScript文件就是通过添加require.extensions['.coffee']扩展的方式来实现加载的。但是从V0.10.6开始,官方不鼓励通过这种方式进行加载,而是期望先将其他语言或文件编译成JavaScript文件后再加载,这样做的好处在于不将烦琐的编译加载等过程引入Node的执行过程中。

在确定文件的扩展名之后,Node将调用具体的编译方式来将文件执行后返回给调用者。
1.javaScript模块的编译
每个模块文件都有exports、require、、module、__filename、__dirname这些变量存在

{
  '0': {},
  '1':
   { [Function: require]
     resolve: { [Function: resolve] paths: [Function: paths] },
     main:
      Module {
        id: '.',
        exports: {},
        parent: null,
        filename: '/Users/xxx/Desktop/study/node-test/module_path.js',
        loaded: false,
        children: [],
        paths: [Array] },
     extensions:
      [Object: null prototype] { '.js': [Function], '.json': [Function], '.node': [Function] },
     cache:
      [Object: null prototype] {
        '/Users/xxx/Desktop/study/node-test/module_path.js': [Module] } },
  '2':
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: '/Users/xxx/Desktop/study/node-test/module_path.js',
     loaded: false,
     children: [],
     paths:
      [ '/Users/xxx/Desktop/study/node-test/node_modules',
        '/Users/xxx/Desktop/study/node_modules',
        '/Users/xxx/Desktop/node_modules',
        '/Users/xxx/node_modules',
        '/Users/node_modules',
        '/node_modules' ] },
  '3': '/Users/xxx/Desktop/study/node-test/module_path.js',
  '4': '/Users/xxx/Desktop/study/node-test' }

在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装。在头部添加了:

(function (exports, require, module, __filename, __dirname) {
  var content = "content";
  exports.content = function () {
    console.log(content);
  };
})

这样每个模块文件之前都进行了作用域隔离。包装之后的代码会通过vm原生模块的runInThisContext()方法执行(类似eval,只是具有明确上下文,不污染全局),返回一个具体的function对象。最后,将当前模块对象的exports属性、require方法、module(模块对象自身),以及在文件定位中得到的完整文件路径和文件目录作为参数传递给这个function()执行.

在执行之后,模块的exports属性被返回给了调用方。exports属性上的任何方法和属性都可以被外部调用到。但模块中的其余变量或属性则不可直接被调用。

那么存在exports的情况下,为何存在module.exports。理想情况下,只要赋值给exports即可

exports = function () {
  // My Class
};

但是会得到一个失败的结果。

CommonJS规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)表示当前模块对外输出的接口。其他文件加载该模块,实际上就是读取module.exports变量。

为了方便,Node为每个模块提供一个exports变量,指向module.exports。
如果要达到require引入一个类的效果,赋值给exports会切断exports与module.exports的联系,请赋值给module.exports对象。

// a.js
// exports = function () {};
module.exports = function () {};
// b.js
var a = require('./a.js');
console.log(a);// [Function]

AMD规范与CommonJS规范的兼容性

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

AMD规范使用define方法定义模块

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

  return {
    foo: foo
  };
});

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();
  };
});

模块的循环加载

如果发生模块的循环加载,即A加载B,B又加载A,则B将加载A的不完整版本。

// a.js
exports.x = 'a1';
console.log('a.js ', require('./b.js').x);
exports.x = 'a2';

// b.js
exports.x = 'b1';
console.log('b.js ', require('./a.js').x);
exports.x = 'b2';

// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);

上面代码是三个JavaScript文件。其中,a.js加载了b.js,而b.js又加载a.js。这时,Node返回a.js的不完整版本,所以执行结果如下。

$ node main.js       
b.js  a1
a.js  b2
main.js  a2
main.js  b2 // 取的缓存

require.main

require方法有一个main属性,可以用来判断模块是直接执行,还是被调用执行。

直接执行的时候(node module.js),require.main属性指向模块本身。

require.main === module
// true

调用执行的时候(通过require加载该脚本执行),上面的表达式返回false。

模块的加载机制

CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
下面是一个模块文件lib.js。

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

上面代码输出内部变量counter和改写这个变量的内部方法incCounter。
加载上面的模块。

// main.js
var counter = require('./lib').counter;
var incCounter = require('./lib').incCounter;

console.log(counter);  // 3
incCounter();
console.log(counter); // 3

上面代码说明,counter输出以后,lib.js模块内部的变化就影响不到counter了。

require的内部处理流程

最后总结一下require。require命令是CommonJS规范之中,用来加载其他模块的命令。它其实不是一个全局命令,而是指向当前模块的module.require命令,而后者又调用Node的内部命令Module._load。

Module._load = function(request, parent, isMain) {
  // 1. 检查 Module._cache,是否缓存之中有指定模块
  // 2. 如果缓存之中没有,就创建一个新的Module实例
  // 3. 将它保存到缓存
  // 4. 使用 module.load() 加载指定的模块文件,
  //    读取文件内容之后,使用 module.compile() 执行文件代码
  // 5. 如果加载/解析过程报错,就从缓存删除该模块
  // 6. 返回该模块的 module.exports
};

上面的第4步,采用module.compile()执行指定模块的脚本,逻辑如下。

Module.prototype._compile = function(content, filename) {
  // 1. 生成一个require函数,指向module.require
  // 2. 加载其他辅助方法到require
  // 3. 将文件内容放到一个函数之中,该函数可调用 require
  // 4. 执行该函数
};

上面的第1步和第2步,require函数及其辅助方法主要如下。

  1. require(): 加载外部模块
  2. require.resolve():将模块名解析到一个绝对路径
  3. require.main:指向主模块
  4. require.cache:指向所有缓存的模块
  5. require.extensions:根据文件的后缀名,调用不同的执行函数

一旦require函数准备完毕,整个所要加载的脚本内容,就被放到一个新的函数之中,这样可以避免污染全局环境。该函数的参数包括require、module、exports,以及其他一些参数。

(function (exports, require, module, __filename, __dirname) {
  // YOUR CODE INJECTED HERE!
});

Module._compile方法是同步执行的,所以Module._load要等它执行完成,才会向用户返回module.exports的值。

你可能感兴趣的