你的第一个 Nodejs 模块编译器 - (2)

987 阅读5分钟

How 模块?

在前文的 What?Why?之后就是我们最直接的如何实现我们的模块编译器了。

在 Nodejs 中其自带的模块化方法 require 是最常见的一种引入其他模块的方式。我们来简单回顾一下 Nodejs 中如何使用require来引入其他模块。

题目:实现一个简单的缓存模块cache,实现 getset 方法来获取和设置key:value 键值对。

实现功能需要两个文件:

cache
├── cache.js
└── index.js
  • 缓存模块文件cache.js,用于实现缓存方法:
/**
 * cache/cache.js
 */
class Cache {
  constructor() {
    this.cache = {};
  }
  get(key) {
    return this.cache[key];
  }
  set(key, value) {
    return (this.cache[key] = value);
  }
}

module.exports = new Cache(); // 导出一个实例化的 Cache 对象。
  • 入口文件:index.js,用于描述如何使用缓存模块:
/**
 * cache/index.js
 */
const cache = require('./cache'); // 导入 Cache 模块中实例化的 Cache 对象。

cache.set('name', 'Herb');
console.log(cache.get('name')); // Herb

到现在为止都是使用了标准的 Nodejs 模块函数require。那么如果仅仅是知道require是怎么用的其实只是满足了一个初级程序员的需求。那么从想完成从初级->高级这一跨越,就至少要了解require的机制,Nodejs 如何通过require来完成不同模块之间的引用的呢???

1. 探寻require的工作原理

当我们遇到问题需要分析一些工具的底层原理的时候,往往会走两条路:

  • Google/Baidu 上搜索相关原理分析文章,看别人理解后提炼的内容
  • 阅读工具的官方文档甚至源码

这两条路没有优劣之分,也不要纠结哪种方式更好。我认为这两种方式是属于不同层次的,第一种看别人理解后的内容是一条捷径,往往是从更高抽象层次去理解其原理。第二种应该是建立在对原理有大致了解之后去细化的一个过程。

这里作者就不分析源码了,把require最精华的部分梳理出来,帮助我们做出 MVP(Minimum Viable Product)就可以。

如果我们仔细观察现有的代码,会注意到:

  • module.exports是一个对象,代表了从当前模块要导出的对象
  • require是一个函数,函数的返回值是其模块导出的对象
  • 未被导出的对象是无法被其他模块使用的。
  • modulerequire是直接使用的,并不知道其在哪里声明的。

从上面几个特征可以得出其重要的两个点:

  • module顾名思义代表了当前模块,module有一个exports属性用于存放当前模块要导出的对象。所以module是一个对象。
  • require方法会根据文件路径寻找到其对应的模块,并返回其模块导出的对象module.exports

2. 声明一个模块

从上面require的原理中可以得出一个模块module有一个重要属性exports,还有require是根据文件路径寻找到这个模块的,最容易想到的是每个模块可以通过其文件路径唯一标识它。那么一个模块至少就拥有两个属性:

  • id: 使用文件路径代表
  • exports: 模块要导出的对象
├── cache
│   ├── cache.js
│   └── index.js
├── module.js
/**
 * module.js
 */
class Module {
  constructor(id, exports = {}) {
    this.id = id;
    this.exports = exports;
  }
}

3. 为cache.js 创建一个模块

通过阅读 Nodejs 官方文档关于 module 部分的解释时可以发现,每个模块都是被一个函数包裹着的: nodejs.org/api/modules…

(function(exports, require, module, __filename, __dirname) {
  // Module code actually lives in here
});

那么这就解释了在分析require原理的时候的一个疑惑,requiremodule的来源问题。其实就是包裹模块的函数的两个参数。

同样的我们可以简单实现最基础的模块,只需要包裹模块的函数有两个参数就足够了requiremodule,为了更清晰的展示其工作原理,我们将cache.js的文件内容拷贝直接拷贝到module.js中,并包裹在一个函数里:

/**
 * module.js
 */
class Module {
  constructor(id, exports = {}) {
    this.id = id;
    this.exports = exports;
  }
}

function cache(module, require) {
  /**
   * cache/cache.js
   */
  class Cache {
    constructor() {
      this.cache = {};
    }
    get(key) {
      return this.cache[key];
    }
    set(key, value) {
      return (this.cache[key] = value);
    }
  }

  module.exports = new Cache(); // 导出一个实例化的 Cache 对象。
}

那么如何才能创建并导出正确的cache模块呢?

首先是先创建出我们的cacheModule模块对象:

const cacheModule = new Module('./cache');

此时我们给模块的id暂时设为./cache,并且exports初始为一个空对象{}

然后调用cache方法来加载我们的模块, 在模块内部暂时并没有使用require方法,所以先忽略掉它:

cache(cacheModule);

很显然运行完cache方法之后,我们的cacheModuleexports属性就会包含我们导出的new Cache()这一对象了:

console.log(cacheModule); // Module { id: 'cache.js', exports: Cache { cache: {} } }

到此我们完成了cacheModule的创建工作。

4. 为index.js创建一个模块

同样的我们以相同的方式包裹index.js的文件内容,然后加载我们的index.js模块,唯一不同的是我们需要实现require方法来引入cache.js模块。

由于我们每个模块都有一个唯一标识id,那么我们可以创建一个modules的对象来索引我们的模块:

const modules = {
  'cache.js': cacheModule,
};

那么根据require函数的需求:

根据模块id找到对应模块,并返回模块的导出对象exports

实现一个简单require函数:

const __require__ = (id) => modules[id].exports; // 避免与 Nodejs 自身的 require 冲突。

包裹并加载我们的index.js模块:

function index(module, require) {
  /**
   * cache/index.js
   */
  const cache = require('./cache'); // 导入 Cache 模块中实例化的 Cache 对象。

  cache.set('name', 'Herb');
  console.log(cache.get('name'));
}
const indexModule = new Module('index.js');
index(indexModule, __require__); // Herb

其实index.js模块就是我们程序的入口文件,加载入口文件就等同于运行了我们的程序。此时我们通过运行node module.js之后就能看到终端打印出了Herb

引用

资源

文章完整源码在我的 github 仓库中: github.com/xahhy/nodej…