CommonJS规范

1次阅读

共计 8723 个字符,预计需要花费 22 分钟才能阅读完成。

前言

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

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

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

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 的值。

正文完
 0