Libon

NodeJS require 机制

NodeJS 中 require 方法的机制,及如何对文件的拦截/修改

usage

在开始之前,可以先看一下基本用法。

// 前提代码
const resolved = require(X);
const Module = require('module');
  1. 如果 X 是内置模块则直接返回模块。
    1. 内置模块可以通过 module.builtinModules 属性来查看。
  console.log(Module.builtinModules);
  1. 如果 X 模块路径是以 ’/’、’./’、‘../’ 开头

    1. 根据 X 所在的父模块,确定 X 的绝对路径。
    2. 将 X 当成文件,依次查找下面文件,只要有一个存在,则直接返回文件不再查找,可以通过 module._extensions 属性查看受支持的文件类型列表
      • X.js
      • X.json
      • X.node
    3. 将 X 当成目录,依次查找下面文件。只要有一个存在就返回,不再继续执行
      • X/package.json(main字段)
      • X/index.js
      • X/index.json
      • X/index.node
  2. 如果 X 不带路径,比如 ”、 ’.’、’..’ 等。

    1. 根据 X 所在的父模块,确定 X 可能的安装目录。
    2. 依次在每个目录中,将 X 当成文件名或目录名加载。
  3. throw module not found.

require

每个实例都有一个 require 方法。

Module.prototype.require = function(path) {
  return Module._load(path, this);
}

由此可知,require 并不是全局性命令,而是每个模块提供的一个内部方法,也就是说,只有在模块内部才能使用 require 命令(唯一的例外是 REPL 环境)。另外,require 其实内部调用 Module._load 方法。接下来是 Module._load 的源码:

Module._load = function(request, parent, isMain) {

  //  计算绝对路径
  var filename = Module._resolveFilename(request, parent);

  //  第一步:如果有缓存,取出缓存
  var cachedModule = Module._cache[filename];
  if (cachedModule) {
    return cachedModule.exports;

  // 第二步:是否为内置模块
  if (NativeModule.exists(filename)) {
    return NativeModule.require(filename);
  }

  // 第三步:生成模块实例,存入缓存
  var module = new Module(filename, parent);
  Module._cache[filename] = module;

  // 第四步:加载模块
  try {
    module.load(filename);
    hadException = false;
  } finally {
    if (hadException) {
      delete Module._cache[filename];
    }
  }

  // 第五步:输出模块的exports属性
  return module.exports;
}

上面代码中,首先解析出模块的绝对路径(filename),以它作为模块的识别符。然后,如果模块已经在缓存中,就从缓存取出;如果不在缓存中,就加载模块。所以 Module._load 的关键步骤是两个:

  1. Module._resolveFilename():确定模块的绝对路径
  2. module.load():加载模块

load

确定了模块的绝对路径以后,就可以开始加载模块了。以下是 Module.load 方法的源码:

Module.prototype.load = function(filename) {
  var extension = path.extname(filename) || '.js';
  if (!Module._extensions[extension]) extension = '.js';
  Module._extensions[extension](this, filename);
  this.loaded = true;
}

上面的实例代码中,首先确定了模块的后缀名,不同的后缀对应不同的加载方法。

Module._extensions['.js'] = function(module, filename) {
  // 将模块文件读取成字符串
  var content = fs.readFileSync(filename, 'utf8');
  // 然后剥离 utf8 编码特有的BOM文件头,最后编译该模块。
  module._compile(stripBOM(content), filename);
}

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

_compile

Module.prototype._compile = function(content, filename) {
  var self = this;
  var args = [self.exports, require, self, filename, dirname];
  return compiledWrapper.apply(self.exports, args);
}

代码等同于:

(function (exports, require, module, __filename, __dirname) {
  // do something
})

也就是说,模块的加载实质上就是,注入exports、require、module三个全局变量,然后执行模块的源码,然后将模块的 exports 变量的值输出。那么可以根据编译的过程得出 require 方法加载模块时调用的钩子函数。

  1. _load
  2. load
  3. _extensions
  4. _compile

从上面的执行流程可以知道,在引入了模块以后,是在 _extensions 方法内读取的文件内容,那么在这里面做一点点操作,那么就可以达到修改模块引入结果的效果,就像 ts-node 一样。

const Module = require('module');
const fs = require('fs');

Module._extensions['.js'] = function (module, filename) {
  let content = fs.readFileSync(filename, 'utf8');

  // 你可以只针对包含特定文件名的文件进行更改
  if (filename.includes('input')) {
    // 修改返回的内容
      content = content.replace('```code', '```demo');
  }
  module._compile(content, filename);
}

  1. require() 源码解读
  2. 给大家变个 Node.js 的小魔术
cd ../