22

详解Node模块加载机制

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzIwMTM5MTM1NA%3D%3D&%3Bmid=2649473537&%3Bidx=1&%3Bsn=7605d0c02400afe7f18d0ddbcd1ec3c9
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

YrqMNvr.png!web

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

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

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

yEJvyaF.png!web

  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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK