CommonJS 规范

CommonJS 规范

概述

ES6 之前,JavaScript 没有模块的概念,不支持私有的作用域和依赖管理,传统的脚本加载方式会污染变量,无法保证脚本的加载顺序。因此,CommonJS 诞生了。

CommonJS 规范是为了解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块它自身的命名空间中执行。规范中包括了模块(modules)、包(packages)、系统(system)、二进制(binary)、控制台(console)、编码(encodings)、文件系统(filesystems)、套接字(sockets)、单元测试(unit testing)等部分。

CommonJS 规定每个文件都是一个模块,拥有自己的作用域;模块内的变量、函数等都是私有的,对于其它模块不可见。模块内,变量 module 表示当前模块。

1
2
3
// Module
var name = '';
function showName() {};

如果想多文件共享变量,可以将其定义为 global 对象的属性,但不建议这么做

global.shareProp = '';

module 是一个对象,它包含 exports 和 require 两个对象;其中 exports 用于输出模块接口,require 用于从外部获取一个模块的接口,即所获取模块的 exports 对象。

1
2
3
4
// moduleA.js
module.exports = function (str) {
console.info(str);
}
1
2
3
// moduleB.js
var log = require('./moduleA');
log('Module');

Node.js 是目前 CommonJS 规范最热门的一个实现,但在实现中并非完全按照规范实现,而是对模块规范进行了一定的取舍,同时增加了少许自身需要的特性。

module 对象

在 Node 内部提供了一个 Module 构建函数,所有模块都是 Module 实例。

1
2
3
4
5
6
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent
...
}

每个 module 都包含以下属性:

  • id

    模块标识符,一般为带绝对路径的模块文件名

  • filename

    带绝对路径的模块文件名

  • loaded

    模块加载状态,表示模块是否已加载完成

  • parent

    表示调用该模块的模块,可以通过该属性判断模块是否问入口脚本。

    1
    2
    // Module A
    console.info(module.parent);
    1
    2
    3
    4
    5
    // 命令行执行
    node moduleA.js // null

    // 其他模块引入
    const moduleA = require('./moduleA'); // 调用模块名
  • children

    返回包含所有依赖的模块的数组

  • exports

    返回对外输出的值;为了方面,提供了 exports 变量,指向 module.exports

    1
    var exports = module.exports;
    1
    exports.fn = function() {}

require()

require() 用于读取并执行指定的模块,返回模块对应的 exports 对象。

在 Node 中引入模块,需要经历三个步骤:

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

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

  • 核心模块部分在 Node 源码编译过程中编进进了二进制执行文件中,Node 进程启动后,部分核心模块直接被加载进内存中,因此在引入这些模块时,不需要文件定位和编译执行的步骤,并且在路径分析中优先判断,因此加载速度最快
  • 文件模块需要在运行时动态加载,需要经过上面的三个步骤,加载速度比核心模块慢

Node 对引入过的模块会缓存其编译和执行后的对象,以减少二次引入时的开销。require() 对相同模块的多次加载都是采用缓存优先的方式,核心模块的缓存检查先与文件模块的缓存检查。

路径分析

require() 接受一个标识符作为参数,基于模块标识符去查找;Node 中模块标识符主要分为以下几类:

  • 核心模块

    核心模块指 Node 自身提供的模块,如:http、fs、path 等。核心模块的加载优先级仅次于缓存加载,由于其在编译过程中已经编译为二进制文件放入内存中,因此加载过程最快。

    如果文件模块的模块标识符和核心模块相同,那么必须选择更换模块标识符或以路径的方式加载,否则无法正确加载

  • 路径模块

    路径模块指以 .../ 开头的文件模块。在分析路径模块时,require() 会将其转为真实路径,并以真实路径为索引,将其编译执行后放入缓存中,因此其加载速度仅次于核心模块。

  • 自定义模块

    自定义模块指除核心模块、路径模块外的模块。在介绍自定义模块的查找方式前,先介绍下模块路径的概念。

    模块路径是 Node 在定位文件模块的具体文件时制定的查找策略,具体表现为路径组成的数组,可以通过 module.paths 查看当前模块的模块路径,具体的生成规则为:

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

    在加载自定义模块过程中,Node 会逐个尝试模块路径中的路径,直至找到目标文件为止;模块路径越深,耗时越长,这也就是自定义模块加载速度最慢的原因。

文件定位

路径分析完成后,需要根据分析结果定位目标文件。在定位过程中,有两处细节需要留意:一个是文件扩展名分析,另一个是目录和包的处理。

  • 文件扩展名分析

    require() 允许模块标识符中不包含文件扩展名;在缺省的情况下,Node 会按 .js.node.json 的次序补足扩展名,然后依次尝试。

    在尝试过程中,会调用 fs 模块同步阻塞地判断文件是否存在,由于 Node 是单线程,因此这里可能会导致性能问题。建议在引入 .node.json 文件中带上扩展名,会加快速度。

  • 目录和包的处理

    在分析标识符的过程中,require() 通过分析文件扩展名后可能得到一个目录,这个时候 Node 会将目录当做一个包进行处理。

    • 首先,会在当前目录下查找 package.json 文件,通过 JSON.parse() 解析出包描述对象,从中取出 main 属性指定的文件名进行定位,如果文件名缺少扩展名,则进入扩展名分析的步骤。
    • 如果 package.json 文件不存在或 main 属性指定的文件名错误,则 Node 会将 index 当做默认文件名,然后依次查找 index.jsindex.nodeindex.json
    • 如果还是没有定位到文件,则自定义模块进入下一个模块路径进行查找;如果模块路径数组遍历结束后依然没有查找目标文件,则会抛出查找失败异常

编译执行

在 Node 中每个文件模块都是一个对象。定位到具体文件模块后,Node 会新建一个模块对象,然后根据路径载入并编译。对于不同的文件扩展名,载入的方式也不同:

  • .js 模块:通过 fs 模块同步读取后编译执行
  • .node 模块:这是 C/C++ 编写的扩展文件,通过 dlopen() 加载最后编译生成的文件
  • .json 模块:通过 fs 模块同步读取后,用 JSON.parse() 解析并返回解析结果
  • 其他扩展名模块:当做 .js 模块处理

每次编译成功的模块都会将其文件路径作为索引存储在 Module._cache 对象中(核心模块放入 NativeModule._cache 中),以提高多次加载的性能。如果想再次加载模块,需要清除缓存。

1
2
3
4
5
6
7
// 删除指定模块的缓存
delete require.cache[moduleName];

// 删除所有模块的缓存
Object.keys(require.cache).forEach(function(key) {
delete require.cache[key];
})

扩展

  • 如果发生模块的循环加载,即 A 加载 B,B 又加载 A,那么在首次加载过程中 B 将加载 A 的不完整版本

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 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);
    console.log('main.js ', require('./a.js').x);
    console.log('main.js ', require('./b.js').x);
    1
    2
    3
    4
    5
    6
    7
    // node main.js
    b.js a1
    a.js b2
    main.js a2
    main.js b2
    main.js a2
    main.js b2
  • CommonJS 模块的加载机制是输入的是被输出的值的浅拷贝

  • require 并非全局命令,它指向 module.require,后者又调用 Node 内部的 Module._load

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Module._load = function(request, parent, isMain) {
    // 1. 使用 Module._resolveFilename 解析模块标识符,将其转换为绝对路径
    // 2. 检查 Module._cache,是否缓存之中有指定模块
    // 2. 如果缓存之中没有,则新建的 Module 实例并放入 Module._cache
    // 3. 使用 module.load() 尝试加载指定的模块文件,
    // 读取文件内容之后,使用 module.compile() 执行文件代码
    // 5. 如果加载/解析过程报错,就从缓存删除该模块
    // 6. 返回该模块的 module.exports
    };
    1
    2
    3
    4
    5
    6
    7
    // 同步执行
    Module.prototype._compile = function(content, filename) {
    // 1. 生成一个 require 函数,指向 module.require
    // 2. 加载其他辅助方法到 require
    // 3. 将文件内容放到一个函数之中,该函数可调用 require
    // 4. 执行该函数
    };

    require 函数及其辅助方法:

    • require():加载外部模块

    • require.resolve():将模块名解析为绝对路径

    • require.main:指向调用模块,如果直接执行(node moduleName),则指向模块本身

      1
      2
      // 判断模块是否是被调用执行
      require.main === module; // true 表示直接执行,false 表示被其他模块引用
    • require.cache:指向所有缓存的模块

    • require.extensions:根据文件的后缀名,调用不同的执行函数

    require 函数准备完毕后,将模块内容进行封装(Module.wrap())中避免全局污染。

    1
    2
    3
    4
    5
    6
    7
    8
    Module.wrap = function(script) {
    return Module.wrapper[0] + script + Module.wrapper[1];
    };

    Module.wrapper = [
    '(function (exports, require, module, __filename, __dirname) { ',
    '\n});'
    ];

参考链接