CommonJS 概述

CommonJS 是一种模块化的标准,而 Node.js 是这种标准的实现,每个文件就是一个模块,有自己的作用域,在一个模块里面定义的变量、函数、类,都是私有的,不会和其他模块相互污染。

Node.js 模块化的简易实现

在实现模块加载之前,我们需要清除模块的加载过程:

  • 假设 A 文件夹下有一个 a.js,我们要解析出一个绝对路径来;
  • 我们写的路径可能没有后缀名 .js.json
  • 得到一个真实的加载路径(模块会被缓存)先去缓存中看一下这个文件是否存在,如果存在返回缓存 没有则创建一个模块;
  • 得到对应文件的内容,加一个闭包,把内容塞进去,之后执行即可。

提前加载需要用到的模块

因为我们只是实现 CommonJS 的模块加载方法,并不会去实现整个 Node.js,但在这里我们需要依赖一些 Node.js 的模块,所以我们就暂且使用 Node 自带的 require 方法把模块加载进来(忽略引入核心模块的 require)。

/* 依赖模块 */
// 操作文件的模块
const fs = require('fs');

// 处理路径的模块
const path = require('path');

// 虚拟机,帮我们创建一个黑箱执行代码,防止变量污染
const vm = require('vm');

创建 Module 构造函数

其实 CommonJS 中引入的每一个模块我们都需要通过 Module 构造函数创建一个实例。

/* 创建 Module 构造函数 */
/*
* @param {String} p
*/
function Module(p) {
  this.id = p; // 当前文件的表示(绝对路径)
  this.exports = {}; // 每个模块都有一个 exports 属性,用来存储模块的内容
  this.loaded = false; // 标记是否被加载过
}

定义静态属性存储我们需要使用的一些值

/* Module 静态变量 */
// 函数后面需要使用的闭包的字符串
Module.wrapper = [
  '(function (exports, require, module, __dirname, __filename) {',
  '\n})'
];

// 根据绝对路径进行缓存的模块的对象
Module._cacheModule = {};

// 处理不同文件后缀名的方法
Module._extensions = {
  '.js': function () {},
  '.json': function () {}
};

创建引入模块的 req 方法

为了防止和 Node.js 自带的 require 方法重名,我们将模拟的方法重命名为 req

/* 引入模块方法 req */
/*
* @param {String} moduleId
*/
function req(moduleId) {
  // 将 req 传入的参数处理成绝对路径
  const p = Module._resolveFileName(moduleId);

  // 生成一个新的模块
  const module = new Module(p);
}

在上面代码中,我们先把传入的参数通过 Module._resolveFileName 处理成了一个绝对路径,并创建模块实例把绝对路径作为参数传入,我们现在实现一下 Module._resolveFileName 方法。

返回文件绝对路径 Module._resolveFileName 方法的实现

这个方法的功能就是将 req 方法的参数根据是否有后缀名两种方式处理成带后缀名的文件绝对路径,如果 req 的参数没有后缀名,会去按照 Module._extensions 的键的后缀名顺序进行查找文件,直到找到后缀名对应文件的绝对路径,优先 .js,然后是 .json,这里我们只实现这两种文件类型的处理。

/* 处理绝对路径 _resolveFileName 方法 */
/*
* @param {String} moduleId
*/
Module._resolveFileName = function (moduleId) {
  // 将参数拼接成绝对路径
  const p = path.resolve(moduleId);

  // 判断是否含有后缀名
  if (!/\.\w+$/.test(p)) {
    // 创建规范规定查找文件后缀名顺序的数组 .js .json
    const arr = Object.keys(Module._extensions);

    // 循环查找
    for (let i = 0; i < arr.length; i++) {
      // 将绝对路径与后缀名进行拼接
      const file = p + arr[i];
      // 查找不到文件时捕获异常
      try {
        // 并通过 fs 模块同步查找文件的方法对改路径进行查找
        // 文件未找到会直接进入 catch 语句
        fs.accessSync(file);

        // 如果找到文件将该文件绝对路径返回
        return file;
      } catch (e) {
        // 当后缀名循环完毕都没有找到对应文件时,抛出异常
        if (i >= arr.length) throw new Error('not found module');
      }
    }
  } else {
    // 有后缀名直接返回该绝对路径
    return p;
  }
};

加载模块的 load 方法

/* 完善 req 方法 */
/*
* @param {String} moduleId
*/
function req(moduleId) {
  // 将 req 传入的参数处理成绝对路径
  const p = Module._resolveFileName(moduleId);

  // 生成一个新的模块
  const module = new Module(p);

  // ********** 下面为新增代码 **********
  // 加载模块
  const content = module.load(p);

  // 将加载后返回的内容赋值给模块实例的 exports 属性上
  module.exports = content;

  // 最后返回 模块实例的 exports 属性,即加载模块的内容
  return module.exports;
  // ********** 上面为新增代码 **********
}

上面代码实现了一个实例方法 load,传入文件的绝对路径,为模块加载文件的内容,在加载后将值存入模块实例的 exports 属性上最后返回,其实 req 函数返回的就是模块加载回来的内容。

/* load 方法 */
// 模块加载的方法
Module.prototype.load = function (filepath) {
  // 判断加载的文件是什么后缀名
  const ext = path.extname(filepath);

  // 根据不同的后缀名处理文件内容,参数是当前实例
  const content = Moudule._extensions[ext](this);

  // 将处理后的结果返回
  return content;
};

实现加载 .js 文件和 .json 文件的方法

还记得前面准备的静态属性中有 Module._extensions 就是用来存储这两个方法的,下面我们来完善这两个方法。

/* 处理后缀名方法的 _extensions 对象 */
Module._extensions = {
  '.js': function (module) {
    // 读取 js 文件,返回文件的内容
    const script = fs.readFileSync(module.id, 'utf8');

    // 给 js 文件的内容增加一个闭包环境
    const fn = Module.wrap(script);

    // 创建虚拟机,将我们创建的 js 函数执行,将 this 指向模块实例的 exports 属性
    vm.runInThisContext(fn).call(
      module.exports,
      module.exports,
      req,
      module
    );

    // 返回模块实例上的 exports 属性(即模块的内容)
    return module.exports;
  },
  '.json': function (module) {
    // .json 文件的处理相对简单,将读出的字符串转换成对象即可
    return JSON.parse(fs.readFileSync(module.id, 'utf8'));
  }
};

我们这里使用了 Module.wrap 方法,代码如下,其实帮助我们加了一个闭包环境(即套了一层函数并传入了我们需要的参数),里面所有的变量都是私有的。

/* 创建闭包 wrap 方法 */
Module.wrap = function (content) {
  return Module.wrapper[0] + content + Module.wrapper[1];
};

Module.wrapper 的两个值其实就是我们需要在外层包了一个函数的前半段和后半段。

这里我们要划重点了,非常重要:

  • 我们在虚拟机中执行构建的闭包函数时利用执行上/下文 callthis 指向了模块实例的 exports 属性上,所以这也是为什么我们用 node 启动一个 js 文件,打印 this 时,不是全局对象 global,而是一个空对象,这个空对象就是我们的 module.exports,即当前模块实例的 exports 属性。
  • 还是第一条的函数执行,我们传入的第一个参数是改变 this 指向,那第二个参数是 module.exports,所以在每个模块导出的时候,使用 module.exports = xxx,其实直接替换了模块实例的值,即直接把模块的内容存放在了模块实例的 exports 属性上,而 req 最后返回的就是我们模块导出的内容。
  • 第三个参数之所以传入 req 是因为我们还可能在一个模块中导入其他模块,而 req 会返回其他模块的导出在当前模块使用,这样整个 CommonJS 的规则就这样建立起来了。

对加载过的模块进行缓存

我们现在的程序是有问题的,当重复加载了一个已经加载过得模块,当执行 req 方法的时候会发现,又创建了一个新的模块实例,这是不合理的,所以我们下面来实现一下缓存机制。

还记得之前的一个静态属性 Module._cacheModule,它的值是一个空对象,我们会把所有加载过的模块的实例存储到这个对象上。

/* 完善 req 方法(处理缓存) */
/*
* @param {String} moduleId
*/
function req(moduleId) {
  // 将 req 传入的参数处理成绝对路径
  const p = Module._resolveFileName(moduleId);

  // ********** 下面为新增代码 **********
  // 判断是否已经加载过
  if (Module._cacheModule[p]) {
    // 模块存在,如果有直接把 exports 对象返回即可
    return Module._cacheModule[p].exprots;
  }
  // ********** 上面为新增代码 **********

  // 生成一个新的模块
  const module = new Module(p);

  // 加载模块
  const content = module.load(p);

  // ********** 下面为新增代码 **********
  // 存储时是拿模块的绝对路径作为键与模块内容相对应的
  Module._cacheModule[p] = module;

  // 是否缓存表示改为 true
  module.loaded = true;
  // ********** 上面为新增代码 **********

  // 将加载后返回的内容赋值给模块实例的 exports 属性上
  module.exports = content;

  // 最后返回 模块实例的 exports 属性,即加载模块的内容
  return module.exports;
}

试用 req 加载模块

在同级目录下新建一个文件 a.js,使用 module.exports 随便导出一些内容,在我们实现模块加载的最下方尝试引入并打印内容。

/* 导出自定义模块 */
// a.js
module.exports = 'Hello world';
/* 检测 req 方法 */
const a = req('./a');
console.log(a); // Hello world

CommonJS 模块查找规范

其实我们只实现了 CommonJS 规范的一部分,即自定义模块的加载,其实在 CommonJS 的规范当中关于模块查找的规则还有很多,具体的我们就用下面的流程图来表示。

CommonJS 模块加载流程图
CommonJS 模块加载流程图

这篇文章让我们了解了 CommonJS 是什么,主要目的在于理解 Node.js 模块化的实现思路,想要更深入的了解 CommonJS 的实现细节,建议看一看 Node.js 源码对应的部分,如果觉得源码比较多,不容易找到模块化实现的代码,也可以在 VSCode 中通过调用 require 方法引入模块时,打断点调试,一步一步的跟进到 Node.js 源码中查看。