跳到主要内容

原理

2023年03月05日
柏拉文
越努力,越幸运

一、认识


Node中引入模块,需要经历如下四个步骤:

  • 路径分析
  • 文件定位
  • 编译执行
  • 加入内存

二、路径分析


Node.js 中模块可以通过文件路径名字获取模块的引用。模块的引用会映射到一个js文件路径。 在Node中模块分为两类:

  • 一是Node提供的模块,称为核心模块(内置模块),内置模块公开了一些常用的API给开发者,并且它们在Node进程开始的时候就预加载了

  • 一类是用户编写的模块,称为文件模块。如通过NPM安装的第三方模块(third-party modules)或本地模块(local modules),每个模块都会暴露一个公开的API。以便开发者可以导入

执行后,Node 内部会载入内置模块或通过 NPM 安装的模块。require函数会返回一个对象,该对象公开的API可能是函数、对象或者属性如函数、数组甚至任意类型的JS对象。

核心模块Node源码在编译过程中编译进了二进制执行文件。在Node启动时这些模块就被加载进内存中,所以核心模块引入时省去了文件定位编译执行两个步骤,并且在路径分析中优先判断,因此核心模块的加载速度是最快的。文件模块则是在运行时动态加载,速度比核心模块慢。

node模块的载入及缓存机制

  1. 载入内置模块: Node的内置模块被编译为二进制形式,引用时直接使用名字而非文件路径。当第三方的模块和内置模块同名时,内置模块将覆盖第三方同名模块。因此命名时需要注意不要和内置模块同名。如获取一个http模块

  2. 载入文件模块

  3. 载入文件目录模块: 可以直接 require 一个目录,假设有一个目录名为folder,如

    const myMod = require('./folder')

    此时,Node 将搜索整个 folder 目录,Node 会假设 folder 为一个包并试图找到包定义文件package.json。如果folder目录里没有包含package.json文件,Node会假设默认主文件为index.js,即会加载index.js。如果index.js也不存在, 那么加载将失败。

  4. 载入node_modules里的模块

    如果模块名不是路径,也不是内置模块,Node将试图去当前目录的node_modules文件夹里搜索。如果当前目录的node_modules里没有找到,Node会从父目录的node_modules里搜索,这样递归下去直到根目录。

  5. 自动缓存已载入模块: 对于已加载的模块 Node 会缓存下来,而不必每次都重新搜索

  6. 优先从缓存加载: 和浏览器会缓存静态js文件一样,Node也会对引入的模块进行缓存,不同的是,浏览器仅仅缓存文件,而nodejs缓存的是编译和执行后的对象(缓存内存)。require()对相同模块的二次加载一律采用缓存优先的方式,这是第一优先级的,核心模块缓存检查先于文件模块的缓存检查。

三、文件定位

**

  1. 文件扩展名分析: 调用 require() 方法时若参数没有文件扩展名,Node会按.js.json.node的顺寻补足扩展名,依次尝试。

    在尝试过程中,需要调用fs模块阻塞式地判断文件是否存在。因为Node的执行是单线程的,这是一个会引起性能问题的地方。如果是.node或者·.json·文件可以加上扩展名加快一点速度。另一个诀窍是: 同步配合缓存。

  2. 目录分析和包: require() 分析文件扩展名后,可能没有查到对应文件,而是找到了一个目录,此时Node会将目录当作一个包来处理。

    首先, Node 在挡墙目录下查找 package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。若main属性指定文件名错误,或者没有pachage.json文件,Node会将index当作默认文件名。

    简而言之,如果require绝对路径的文件,查找时不会去遍历每一个node_modules目录,其速度最快。其余流程如下:

    1. module path 数组中取出第一个目录作为查找基准
    2. 直接从目录中查找该文件,如果存在,则结束查找。如果不存在,则进行下一条查找
    3. 尝试添加.js.json.node后缀后查找,如果存在文件,则结束查找。如果不存在,则进行下一条
    4. 尝试将require的参数作为一个包来进行查找,读取目录下的package.json文件,取得main参数指定的文件
    5. 尝试查找该文件,如果存在,则结束查找。如果不存在,则进行第3条查找
    6. 如果继续失败,则取出module path数组中的下一个目录作为基准查找,循环第1至5个步骤
    7. 如果继续失败,循环第1至6个步骤,直到module path中的最后一个
    8. 如果仍然失败,则抛出异常

    整个查找过程十分类似原型链的查找和作用域的查找。所幸Node.js对路径查找实现了缓存机制,否则由于每次判断路径都是同步阻塞式进行,会导致严重的性能消耗。一旦加载成功就以模块的路径进行缓存,加载过程如图所示:

    Preview

四、编译执行


每个模块文件模块都是一个对象,它的定义如下:

function Module(id, parent) {
   this.id = id;
   this.exports = {};
   this.parent = parent;
   if(parent && parent.children) {
       parent.children.push(this);
  }
   this.filename = null;
   this.loaded = false;
   this.children = [];
}

对于不同扩展名,其载入方法也有所不同:

  • .js通过fs模块同步读取文件后编译执行: 在编译的过程中, Node对获取的javascript文件内容进行了头尾包装,将文件内容包装在一个function中:

    (function (exports, require, module, __filename, __dirname) {
       var math = require(‘math‘);
       exports.area = function(radius) {
          return Math.PI * radius * radius;
      }
    })

    包装之后的代码会通过vm原生模块的runInThisContext()方法执行(具有明确上下文,不污染全局),返回一个具体的function对象,最后传参执行,执行后返回module.exports.

  • .node这是C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件: 核心模块分为C/C++编写和JavaScript编写的两个部分,其中C/C++文件放在Node项目的src目录下,JavaScript文件放在lib目录下。

    1. 转存为C/C++代码: Node采用了V8附带的js2c.py工具,将所有内置的JavaScript代码转换成C++里的数组,生成node_natives.h头文件:

      namespace node {
         const char node_native[] = { 47, 47, ..};
         const char dgram_native[] = { 47, 47, ..};
         const char console_native = { 47, 47, ..};
         const char buffer_native = { 47, 47, ..};
         const char querystring_native = { 47, 47, ..};
         const char punycode_native = { 47, 47, ..};
         ...
         struct _native {
             const char* name;
             const char* source;
             size_t source_len;
        }
         static const struct _native natives[] = {
          { "node", node_native, sizeof(node_native)-1},
          { "dgram", dgram_native, sizeof(dgram_native)-1},
           ...
        };
      }

      在这个过程中,JavaScript代码以字符串形式存储在node命名空间中,是不可直接执行的。在启动Node进程时,js代码直接加载到内存中。在加载的过程中,js核心模块经历标识符分析后直接定位到内存中。

    2. 编译js核心模块: lib目录下的模块文件也在引入过程中经历了头尾包装的过程,然后才执行和导出了exports对象。与文件模块的区别在于: 获取源代码的方式(核心模块从内存加载)和缓存执行结果的位置js核心模块源文件通过process.binding('natives')取出,编译成功的模块缓存到NativeModule._cache上。代码如下:

      function NativeModule() {
         this.filename = id + '.js';
         this.id = id;
         this.exports = {};
         this.loaded = fales;
      }
      NativeModule._source = process.binding('natives');
      NativeModule._cache = {};
  • .json同过fs模块同步读取文件后,用JSON.pares()解析返回结果: .json 文件调用的方法如下:其实就是调用JSON.parse

    //Native extension for .json
    Module._extensions['.json'] = function(module, filename) {
       var content = NativeModule.require('fs').readFileSync(filename, 'utf-8');
       try {
           module.exports = JSON.parse(stripBOM(content));
      } catch(err) {
           err.message = filename + ':' + err.message;
           throw err;
      }
    }

    Module._extensions会被赋值给require()extensions属性,所以可以用:console.log(require.extensions);输出系统中已有的扩展加载方式。 当然也可以自己增加一些特殊的加载:

    require.extensions['.txt'] = function(){
    //code
    };

    但是官方不鼓励通过这种方式自定义扩展名加载,而是期望先将其他语言或文件编译成JavaScript文件后再加载,这样的好处在于不讲烦琐的编译加载等过程引入Node的执行过程。

  • 其他当作.js

每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache对象上。

五、加入内存


六、Require 原理


6.1 require() 加载顺序

  1. 如果 X 是内置模块(比如 require('http'))

    1. 返回该模块
    2. 不再继续执行
  2. 如果 X 以 "./" 或者 "/" 或者 "../" 开头

    1. 根据 X 所在的父模块,确定 X 的绝对路径

    2. 将 X 当成文件,依次查找下面文件,只要其中有一个存在,就返回该文件,不再继续执行。

      X
      X.js
      X.json
      X.node
    3. X 当成目录,依次查找下面文件,只要其中有一个存在,就返回该文件,不再继续执行

      X/package.json(main字段)
      X/index.js
      X/index.json
      X/index.node
  3. 如果 X 不带路径

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

6.2 require 相关源码

require 的源码在 Nodelib/module.js 文件

function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
this.filename = null;
this.loaded = false;
this.children = [];
}

module.exports = Module;

var module = new Module(filename, parent);

上面代码中,Node 定义了一个构造函数 Module,所有的模块都是 Module 的实例。可以看到,当前模块(module.js)也是 Module 的一个实例。

每个实例都有自己的属性。下面通过一个例子,看看这些属性的值是什么。新建一个脚本文件 a.js.

// a.js

console.log('module.id: ', module.id);
console.log('module.exports: ', module.exports);
console.log('module.parent: ', module.parent);
console.log('module.filename: ', module.filename);
console.log('module.loaded: ', module.loaded);
console.log('module.children: ', module.children);
console.log('module.paths: ', module.paths);

运行这个脚本

$ node a.js

module.id: .
module.exports: {}
module.parent: null
module.filename: /home/ruanyf/tmp/a.js
module.loaded: false
module.children: []
module.paths: [ '/home/ruanyf/tmp/node_modules',
'/home/ruanyf/node_modules',
'/home/node_modules',
'/node_modules' ]

可以看到,如果没有父模块,直接调用当前模块,parent 属性就是 nullid 属性就是一个点。filename 属性是模块的绝对路径,path 属性是一个数组,包含了模块可能的位置。另外,输出这些内容时,模块还没有全部加载,所以 loaded 属性为 false

每个模块实例都有一个 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 的关键步骤是两个

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

    下面是 Module._resolveFilename 方法的源码

    Module._resolveFilename = function(request, parent) {

    // 第一步:如果是内置模块,不含路径返回
    if (NativeModule.exists(request)) {
    return request;
    }

    // 第二步:确定所有可能的路径
    var resolvedModule = Module._resolveLookupPaths(request, parent);
    var id = resolvedModule[0];
    var paths = resolvedModule[1];

    // 第三步:确定哪一个路径为真
    var filename = Module._findPath(request, paths);
    if (!filename) {
    var err = new Error("Cannot find module '" + request + "'");
    err.code = 'MODULE_NOT_FOUND';
    throw err;
    }
    return filename;
    };

    上面代码中,在 Module.resolveFilename 方法内部,又调用了两个方法 Module.resolveLookupPaths()Module._findPath() ,前者用来列出可能的路径,后者用来确认哪一个路径为真。

    为了简洁起见,这里只给出 Module._resolveLookupPaths() 的运行结果。

    [   '/home/ruanyf/tmp/node_modules',
    '/home/ruanyf/node_modules',
    '/home/node_modules',
    '/node_modules'
    '/home/ruanyf/.node_modules',
    '/home/ruanyf/.node_libraries'
    '$Prefix/lib/node' ]

    上面的数组,就是模块所有可能的路径。基本上是,从当前路径开始一级级向上寻找 node_modules 子目录。最后那三个路径,主要是为了历史原因保持兼容,实际上已经很少用了

    有了可能的路径以后,下面就是 Module._findPath() 的源码,用来确定到底哪一个是正确路径。

    Module._findPath = function(request, paths) {

    // 列出所有可能的后缀名:.js,.json, .node
    var exts = Object.keys(Module._extensions);

    // 如果是绝对路径,就不再搜索
    if (request.charAt(0) === '/') {
    paths = [''];
    }

    // 是否有后缀的目录斜杠
    var trailingSlash = (request.slice(-1) === '/');

    // 第一步:如果当前路径已在缓存中,就直接返回缓存
    var cacheKey = JSON.stringify({request: request, paths: paths});
    if (Module._pathCache[cacheKey]) {
    return Module._pathCache[cacheKey];
    }

    // 第二步:依次遍历所有路径
    for (var i = 0, PL = paths.length; i < PL; i++) {
    var basePath = path.resolve(paths[i], request);
    var filename;

    if (!trailingSlash) {
    // 第三步:是否存在该模块文件
    filename = tryFile(basePath);

    if (!filename && !trailingSlash) {
    // 第四步:该模块文件加上后缀名,是否存在
    filename = tryExtensions(basePath, exts);
    }
    }

    // 第五步:目录中是否存在 package.json
    if (!filename) {
    filename = tryPackage(basePath, exts);
    }

    if (!filename) {
    // 第六步:是否存在目录名 + index + 后缀名
    filename = tryExtensions(path.resolve(basePath, 'index'), exts);
    }

    // 第七步:将找到的文件路径存入返回缓存,然后返回
    if (filename) {
    Module._pathCache[cacheKey] = filename;
    return filename;
    }
    }

    // 第八步:没有找到文件,返回false
    return false;
    };

    经过上面代码,就可以找到模块的绝对路径了。

    有时在项目代码中,需要调用模块的绝对路径,那么除了 module.filenameNode 还提供一个 require.resolve 方法,供外部调用,用于从模块名取到绝对路径。

    require.resolve = function(request) {
    return Module._resolveFilename(request, self);
    };

    // 用法
    require.resolve('a.js')
    // 返回 /home/ruanyf/tmp/a.js
  2. 加载模块

    有了模块的绝对路径,就可以加载该模块了。下面是 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;
    };

    上面代码中,首先确定模块的后缀名,不同的后缀名对应不同的加载方法。下面是 .js.json 后缀名对应的处理方法。

    Module._extensions['.js'] = function(module, filename) {
    var content = fs.readFileSync(filename, 'utf8');
    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;
    }
    };