详解Node模块加载机制

栏目: IT技术 · 发布时间: 4年前

内容简介:关注「Node.js 中,模块加载过程分为 5 步:

详解Node模块加载机制

关注「 前端向后 」微信公众号,你将收获一系列「用 原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

一.require()时发生了什么?

Node.js 中,模块加载过程分为 5 步:

详解Node模块加载机制

  1. 路径解析(Resolution):根据模块标识找出对应模块(入口)文件的绝对路径

  2. 加载(Loading):如果是 JSON 或 JS 文件,就把文件内容读入内存。如果是内置的原生模块,将其共享库动态链接到当前 Node.js 进程

  3. 包装(Wrapping):将文件内容(JS 代码)包进一个函数,建立模块作用域, exports, require, module 等作为参数注入

  4. 执行(Evaluation):传入参数,执行包装得到的函数

  5. 缓存(Caching):函数执行完毕后,将 module 缓存起来,并把 module.exports 作为 require() 的返回值返回

其中,模块标识(Module Identifiers)就是传入 require(id) 的第一个字符串参数 id ,例如 require('./myModule') 中的 './myModule'无需指定后缀名 (但带上也无碍)

对于 .../ 开头的文件路径,尝试当做文件、目录来匹配,具体过程如下:

  1. 若路径存在并且是个文件,就当做 JS 代码来加载(无论文件后缀名是什么, require(./myModule.abcd) 完全正确)

  2. 若不存在,依次尝试拼上 .js.json.node (Node.js 支持的二进制扩展)后缀名

  3. 如果路径存在并且是个文件夹,就在该目录下找 package.json ,取其 main 字段,并加载指定的模块(相当于一次重定向)

  4. 如果没有 package.json ,就依次尝试 index.jsindex.jsonindex.node

对于模块标识不是文件路径的,先看是不是 Node.js 原生模块( fspath 等)。如果不是,就从当前目录开始,逐级向上在各个 node_modules 下找,一直找到顶层的 /node_modules ,以及一些全局目录:

  • NODE_PATH 环境变量中指定的位置

  • 默认的全局目录: $HOME/.node_modules$HOME/.node_libraries$PREFIX/lib/node

P.S.关于全局目录的更多信息,见Loading from the global folders

找到模块文件后,读取内容,并包一层函数:

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

(摘自The module wrapper)

执行时从外部注入这些模块变量( exports, require, module, __filename, __dirname ),模块导出的东西通过 module.exports 带出来,并将整个 module 对象缓存起来,最后返回 require() 结果

循环依赖

特殊的,模块之间可能会出现循环依赖,对此,Node.js 的处理策略非常简单:

// module1.js
exports.a = 1;
require('./module2');
exports.b = 2;
exports.c = 3;

// module2.js
const module1 = require('./module1');
console.log('module1 is partially loaded here', module1);

module1.js 执行中引用了 module2.jsmodule2 又引了 module1 ,此时 module1 尚未加载完( exports.b = 2; exports.c = 3; 还没执行)。而 在 Node.js 里,只加载了一部分的模块也可以正常引用

When there are circular require() calls, a module might not have finished executing when it is returned.

所以 module1.js 执行结果是:

module1 is partially loaded here { a: 1 }

P.S.关于循环引用的更多信息,见Cycles

二.Node.js 内部是怎么实现的?

实现上,模块加载的绝大多数工作都是由 module 模块来完成的:

const Module = require('module');
console.log(Module);

Module 是个函数/类:

function Module(id = '', parent) {
  this.id = id;
  this.path = path.dirname(id);
  // 即module.exports
  this.exports = {};
  this.parent = parent;
  updateChildren(parent, this, false);
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

每加载一个模块都创建一个 Module 实例,模块文件执行完后,该实例仍然保留,模块导出的东西依附于 Module 实例存在

模块加载的所有工作都是由 module 原生模块来完成的,包括 Module._loadModule.prototype._compile

Module._load

Module._load() 负责加载新模块、管理缓存,具体如下:

Module._load = function(request, parent, isMain) {
  // 0.解析模块路径
  const filename = Module._resolveFilename(request, parent, isMain);
  // 1.优先找缓存 Module._cache
  const cachedModule = Module._cache[filename];
  // 2.尝试匹配原生模块
  const mod = loadNativeModule(filename, request, experimentalModules);
  // 3.未命中缓存,也没匹配到原生模块,就创建一个新的 Module 实例
  const module = new Module(filename, parent);
  // 4.把新实例缓存起来
  Module._cache[filename] = module;
  // 5.加载模块
  module.load(filename);
  // 6.如果加载/执行出错了,就删掉缓存
  if (threw) {
    delete Module._cache[filename];
  }
  // 7.返回 module.exports
  return module.exports;
};

Module.prototype.load = function(filename) {
  // 0.判定模块类型
  const extension = findLongestRegisteredExtension(filename);
  // 1.按类型加载模块内容
  Module._extensions[extension](this, filename);
};

支持的类型有 .js.json.node 3 种:

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
  // 1.读取JS文件内容
  const content = fs.readFileSync(filename, 'utf8');
  // 2.包装、执行
  module._compile(content, filename);
};

// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
  // 1.读取JSON文件内容
  const content = fs.readFileSync(filename, 'utf8');
  // 2.直接JSON.parse()完事
  module.exports = JSONParse(stripBOM(content));
};

// Native extension for .node
Module._extensions['.node'] = function(module, filename) {
  // 动态加载共享库
  return process.dlopen(module, path.toNamespacedPath(filename));
};

P.S. process.dlopen 具体见process.dlopen(module, filename[, flags])

Module.prototype._compile

Module.prototype._compile = function(content, filename) {
  // 1.包一层函数
  const compiledWrapper = wrapSafe(filename, content, this);
  // 2.把要注入的参数准备好
  const dirname = path.dirname(filename);
  const require = makeRequireFunction(this, redirects);
  const exports = this.exports;
  const thisValue = exports;
  const module = this;
  // 3.注入参数、执行
  compiledWrapper.call(thisValue, exports, require, module, filename, dirname);
};

包装部分的实现如下:

function wrapSafe(filename, content, cjsModuleInstance) {
  let compiled = compileFunction(
    content,
    filename,
    0,
    0,
    undefined,
    false,
    undefined,
    [],
    [
      'exports',
      'require',
      'module',
      '__filename',
      '__dirname',
    ]
  );

  return compiled.function;
}

P.S.模块加载的完整实现见node/lib/internal/modules/cjs/loader.js

三.知道这些有什么用?

知道了模块的加载机制,在一些需要 扩展 篡改加载逻辑的场景很有用,比如用来实现虚拟模块、模块别名等

虚拟模块

比如,VS Code 插件通过 require('vscode') 来访问插件 API:

// The module 'vscode' contains the VS Code extensibility API
import * as vscode from 'vscode';

vscode 模块实际上是不存在的,是个运行时扩展出来的虚拟模块:

// ref: src/vs/workbench/api/node/extHost.api.impl.ts
function defineAPI() {
  const node_module = <any>require.__$__nodeRequire('module');
  const original = node_module._load;
  // 1.劫持 Module._load
  node_module._load = function load(request, parent, isMain) {
    if (request !== 'vscode') {
      return original.apply(this, arguments);
    }

    // 2.注入虚拟模块 vscode
    // get extension id from filename and api for extension
    const ext = extensionPaths.findSubstr(parent.filename);
    let apiImpl = extApiImpl.get(ext.id);
    if (!apiImpl) {
      apiImpl = factory(ext);
      extApiImpl.set(ext.id, apiImpl);
    }
    return apiImpl;
  };
}

具体见 API 注入机制及插件启动流程_VSCode 插件开发笔记 2 ,这里不再赘述

模块别名

类似的,可以通过重写 Module._resolveFilename 来实现模块别名,比如把 proj/src 中的 @lib/my-module 模块引用映射到 proj/lib/my-module

// src/index.js
require('./patchModule');

const myModule = require('@lib/my-module');
console.log(myModule);

patchModule 具体实现如下:

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

const _resolveFilename =  Module._resolveFilename;
Module._resolveFilename = function(request) {
  const args = Array.from(arguments);
  // 别名映射
  const LIB_PREFIX = '@lib/';
  if (request.startsWith(LIB_PREFIX)) {
    console.log(request);
    request = path.resolve(__dirname, '../' + request.slice(1));
    args[0] = request;
    console.log(` => ${request}`);
  }
  return _resolveFilename.apply(null, args);
}

P.S.当然,一般不需要这样做,可以通过Webpack等构建 工具 来完成

清掉缓存

默认 Node.js 模块加载过就有缓存,而有些时候可能想要禁掉缓存,强制重新加载一个模块,比如想要读取能被用户频繁修改的 JS 文件(如 webpack.config.js

此时可以手动删掉挂在 require.cache 身上的 module.exports 缓存:

delete require.cache[require.resolve('./b.js')]

然而,如果 b.js 还引用了其它外部(非原生)模块, 也需要一并删除

const mod = require.cache[require.resolve('./b.js')];
// 把引用树上所有模块缓存全都删掉
(function traverse(mod) {
  mod.children.forEach((child) => {
    traverse(child);
  });

  console.log('decache ' + mod.id);
  delete require.cache[mod.id];
}(mod));

P.S.或者采用decache模块

参考资料

  • Node.js, TC-39, and Modules:以及译文

  • The Node.js Way – How require() Actually Works

  • Requiring modules in Node.js: Everything you need to know

  • Deep Dive Into Node.js Module Architecture

  • node.js require() cache – possible to invalidate?

联系我

如果心中仍有疑问,请查看原文并留下评论噢。( 特别要紧的问题,可以直接微信联系 ayqywx


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

深入理解LINUX内核(第三版)

深入理解LINUX内核(第三版)

(美)博韦,西斯特 / 陈莉君;张琼声;张宏伟 / 中国电力出版社 / 2007-10-01 / 98.00元

为了彻底理解是什么使得Linux能正常运行以及其为何能在各种不同的系统中运行良好,你需要深入研究内核最本质的部分。内核处理CPU与外界间的所有交互,并且决定哪些程序将以什么顺序共享处理器时间。它如此有效地管理有限的内存,以至成百上千的进程能高效地共享系统。它熟练地统筹数据传输,这样CPU 不用为等待速度相对较慢的硬盘而消耗比正常耗时更长的时间。 《深入理解Linux内核,第三版》指导你对内核......一起来看看 《深入理解LINUX内核(第三版)》 这本书的介绍吧!

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具