调试理解 NodeJS 模块机制

前言

通过断点调试理解 NodeJS & CommonJS 的模块机制,先说结论:

  1. NodeJS 中每个文件视作一个模块,每个模块默认可以访问 moduleexportsrequire__filename__dirname 变量
  2. NodeJS 中通过将模块源码包裹在 Wrapper 函数中,并通过调用函数传递参数的方式传递默认变量
  3. 通过 vm 模块 的 runInThisContext 方法 生成 Wrapper 函数,使用 evalnew Function 的方式生成都会出现作用域问题,eval 的作用域为当前上下文,new Function 的作用域为全局上下文

示例代码:

const path = require("path");
const fs = require("fs");
const vm = require("vm");

function myRequire(_path) {
  // 计算决定路径
  const absolatePath = /* ... */;
  // 计算文件名
  const filename = /* ... */;

  // 读取文件源码
  const sourceCode = fs.readFileSync(absolatePath, 'utf-8');
  // 生成 Wrapper 函数
  const fn = vm.runInThisContext(
    `(function (exports, module, require, __filename, __dirname) { ${sourceCode} });`
  );

  // 定义 module
  const module = { exports: {} };

  // 调用 Wrapper 函数,此时 module 代码执行,并为 module.exports 赋值
  fn.apply(module.exports, [module.exports, module, myRequire, filename, absolatePath]);

  return module.exports;
}

大致的流程如此,后面是调试流程,长文警告。

准备调试

调试流程:

新建目录,在目录根路径执行:

npm init -y

新建 index.js 文件,写点代码,并在首行代码处打上断点:

image

新建 /.vscode/launch.json vscode 调试配置文件:

image

简单说下调试配置项:

  • type:调试类型,node 表示 node 程序
  • request:请求类型,这里是启动
  • name:调试配置名称
  • skipFiles:配置调试过程中跳过的文件,默认跳过 node 内部代码文件,我们需要调试 node 的启动流程,所以这里将他注释
  • program:程序所在的位置

在 vscode 左侧调试面板中,选择我们的配置并运行调试,默认会跳过 node 的启动流程,直接断到我们编写的程序中:

image

但是可以看到左下角列出来经过的全部程序,我们直接在首先启动的程序文件中打上断点:

image

image

接下来就可以开始愉快的调试了。

流程调试

查看最先执行的程序代码:

/**
 * other code in here...
 */

if (getOptionValue('--experimental-default-type') === 'module') {
  require('internal/modules/run_main').executeUserEntryPoint(mainEntry);
} else {
  require('internal/modules/cjs/loader').Module.runMain(mainEntry);
}

getOptionValue 获取到 nodejs 对应选项的配置值,这里判断 --experimental-default-type 属性值是否是 module,如果是则使用 esm 加载器来执行,可以通过 node --experimental-default-type=module 来进入这个条件。

在调试时可以在调试配置中添加 "runtimeArgs": ["--experimental-default-type=module"] 来配置运行时参数。

我们不关注 type=module 的情况,所以这里直接忽略,进入 else 的判断。

接下来进入 runMain 方法,代码如下:

/**
 * other code in here...
 */

function executeUserEntryPoint(main = process.argv[1]) {
  /** 获取入口代码的路径 */
  const resolvedMain = resolveMainPath(main);

  /** 判断是否应该使用 esm loader */
  const useESMLoader = shouldUseESMLoader(resolvedMain);

  let mainURL;

  if (!useESMLoader) {
    /** 使用 commonjs loader */
    const cjsLoader = require('internal/modules/cjs/loader');
    const { wrapModuleLoad } = cjsLoader;
    wrapModuleLoad(main, null, true);
  } else {
    /** 使用 esm loader */
    const mainPath = resolvedMain || main;
    if (mainURL === undefined) {
      mainURL = pathToFileURL(mainPath).href;
    }

    runEntryPointWithESMLoader((cascadedLoader) => {
      return cascadedLoader.import(mainURL, undefined, { __proto__: null }, true);
    });
  }
}

resolveMainPath 函数比较简单,就是用一堆工具函数来找到真实的入口文件路径,这里不进行说明,来看看 shouldUseESMLoader 的函数的代码:

/** 判断是否应该使用 esm loader */
function shouldUseESMLoader(mainPath) {
  if (getOptionValue('--experimental-default-type') === 'module') {
    return true;
  }

  /** 用户注册的自定义 loader 列表 */
  const userLoaders = getOptionValue('--experimental-loader');

  /** 用户注册的预加载模块列表 */
  const userImports = getOptionValue('--import');

  if (userLoaders.length > 0 || userImports.length > 0) {
    return true;
  }

  /** 解析入口文件的文件后缀,如果为 mjs 则是 esm,如果是 cjs 则是 commonjs */
  if (mainPath && StringPrototypeEndsWith(mainPath, '.mjs')) {
    return true;
  }
  if (!mainPath || StringPrototypeEndsWith(mainPath, '.cjs')) {
    return false;
  }

  /** 这里是获取到 package.json 中的 type 定义 */
  const type = getNearestParentPackageJSONType(mainPath);

  if (type === undefined || type === 'none') {
    return false;
  }

  /** type 为 module 则为 esm 模块 */
  return type === 'module';
}

这里可以看到满足以下几个条件之一则使用 esm loader:

  • 运行时设置 --experimental-default-type=module
  • 运行时指定了自定义 loader
  • 运行时指定了预加载的模块
  • 入口文件后缀为 .mjs
  • 当前项目的 package.json 中 type 指定为 module

我们依然不关心 esm loader 的相关逻辑,走到 wrapModuleLoad 方法,代码如下:

/**
 * other code in here...
 */

/** 这是对内部方法 Module._load 的包装,request 是加载的模块,parent 是父模块,isMain 表示入口模块 */
function wrapModuleLoad(request, parent, isMain) {
  /* ... */
  try {
    return onRequire().traceSync(
      Module._load,
      {
        __proto__: null,
        parentFilename: parent?.filename,
        id: request
      },
      Module,
      request,
      parent,
      isMain
    );
  } finally {
    /* ... */
  }
}

traceSync 方法就是对函数的一层封装,在执行函数前后添加了 hook 代码,主要用于跟踪函数执行,这里执行的是 Module._load 方法,我们继续查看他的代码:

Module._load = function (request, parent, isMain) {
  /** TIPS: 省略了大部分代码,完整的代码建议自己调试查看 */

  /** 判断请求的模块路径是否以 node: 开头,node: 开头的是 nodejs 内置模块 */
  if (StringPrototypeStartsWith(request, 'node:')) {
    const id = StringPrototypeSlice(request, 5);

    if (!BuiltinModule.canBeRequiredByUsers(id)) {
      throw new ERR_UNKNOWN_BUILTIN_MODULE(request);
    }

    const module = loadBuiltinModule(id, request);

    return module.exports;
  }

  /** 获取模块的决定路径 */
  const filename = Module._resolveFilename(request, parent, isMain);
  /** 获取缓存 */
  const cachedModule = Module._cache[filename];

  /** 返回被缓存且已加载完成的模块 */
  if (cachedModule !== undefined) {
    if (cachedModule.loaded) {
      return cachedModule.exports;
    }
  }

  /** 获取缓存模块或构建新模块 */
  const module = cachedModule || new Module(filename, parent);

  /** 标识模块信息 */
  if (!cachedModule) {
    if (isMain) {
      setOwnProperty(process, 'mainModule', module);
      setOwnProperty(module.require, 'main', process.mainModule);
      module.id = '.';
      module[kIsMainSymbol] = true;
    } else {
      module[kIsMainSymbol] = false;
    }

    /** 缓存模块 */
    Module._cache[filename] = module;
  }

  let threw = true;

  try {
    /** 尝试加载模块 */
    module.load(filename);

    threw = false;
  } finally {
    /** 模块加载失败需要清理残留 */
    if (threw) {
      delete Module._cache[filename];
    }
  }

  /** 返回模块 */
  return module.exports;
};

核心的加载代码在 module.load 方法中,代码如下:

Module.prototype.load = function (filename) {
  this.filename = filename;
  this.paths = Module._nodeModulePaths(path.dirname(filename));

  /** 通过文件后缀查找已注册的扩展 */
  const extension = findLongestRegisteredExtension(filename);

  /** 如果文件以 .mjs 为后缀,且没有注册 .mjs 的扩展,则抛出错误 */
  if (StringPrototypeEndsWith(filename, '.mjs') && !Module._extensions['.mjs']) {
    throw new ERR_REQUIRE_ESM(filename, true);
  }

  /** 调用对应文件后缀的扩展,将 module 实例和模块路径传递 */
  Module._extensions[extension](this, filename);

  /** 标识已被加载 */
  this.loaded = true;

  const exports = this.exports;
  this[kModuleExport] = exports;
};

load 方法内部通过 findLongestRegisteredExtension 方法查找对应的扩展名,最后调用指定扩展来加载模块,这里的扩展名为 .js,默认在 Module._extension 中注册,他的代码如下:

Module._extensions['.js'] = function (module, filename) {
  /** 获取模块源码 */
  const content = getMaybeCachedSource(module, filename);

  let format;
  if (StringPrototypeEndsWith(filename, '.js')) {
    /** 获取 package.json 配置 */
    const pkg = packageJsonReader.getNearestParentPackageJSON(filename);

    if (pkg?.data.type === 'module') {
      /** 如果启用了 --experimental-require-module,则允许在 type=module 的情况下使用 require */
      if (getOptionValue('--experimental-require-module')) {
        /** 以 esm 模式编译模块 */
        module._compile(content, filename, 'module');
        return;
      }

      /** 构造错误信息 */
      const parent = module[kModuleParent];
      const parentPath = parent?.filename;
      const packageJsonPath = path.resolve(pkg.path, 'package.json');
      const usesEsm = containsModuleSyntax(content, filename);
      const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath, packageJsonPath);
      // Attempt to reconstruct the parent require frame.
      if (Module._cache[parentPath]) {
        let parentSource;
        try {
          parentSource = fs.readFileSync(parentPath, 'utf8');
        } catch {
          // Continue regardless of error.
        }
        if (parentSource) {
          const errLine = StringPrototypeSplit(
            StringPrototypeSlice(err.stack, StringPrototypeIndexOf(err.stack, '    at ')),
            '\n',
            1
          )[0];
          const { 1: line, 2: col } = RegExpPrototypeExec(/(\d+):(\d+)\)/, errLine) || [];
          if (line && col) {
            const srcLine = StringPrototypeSplit(parentSource, '\n')[line - 1];
            const frame = `${parentPath}:${line}\n${srcLine}\n${StringPrototypeRepeat(
              ' ',
              col - 1
            )}^\n`;
            setArrowMessage(err, frame);
          }
        }
      }
      throw err;
    } else if (pkg?.data.type === 'commonjs') {
      format = 'commonjs';
    }
  } else if (StringPrototypeEndsWith(filename, '.cjs')) {
    format = 'commonjs';
  }

  /** 以 commonjs 模式编译源码 */
  module._compile(content, filename, format);
};

继续查看 module._compile 方法,代码如下:

Module.prototype._compile = function (content, filename, format) {
  /** 删除大部分代码,建议自行调试查看 */

  let redirects;

  let compiledWrapper;
  if (format !== 'module') {
    /** wrapSafe 方法包装模块源码并生成函数,函数作用域与外界隔离 */
    const result = wrapSafe(filename, content, this, format);

    /** compiledWrapper 即是生成的函数 */
    compiledWrapper = result.function;
  }

  /** 获取模块所在目录 */
  const dirname = path.dirname(filename);
  /**
   *
   * 构建 require 函数,对 module.require 方法的包装, module.require 方法又是对开头介绍的 wrapModuleLoad 函数的包装
   * */
  const require = makeRequireFunction(this, redirects);

  let result;

  const exports = this.exports;
  const thisValue = exports;
  const module = this;

  /** 执行生成的包装函数并传递参数 */
  result = ReflectApply(compiledWrapper, thisValue, [exports, require, module, filename, dirname]);

  return result;
};

当执行到 ReflectApply 函数内部时,会执行生成的包装函数,继续向下执行会发现断到了我们编写的模块内部:

image

此时流程基本已经结束,可以看到核心的逻辑基本与 Module 类相关,下面我用 ts 给出 Module 类的主要定义(部分内容忽略)。

Module 类型定义

declare type Exports = { [key: string | symbol]: any };

declare class Module {
  constructor(id: string, parent: Module);

  /** 模块 id,一般为模块绝对路径 */
  id: string;

  /** 模块路径 */
  path: string;

  /** module.exports,模块中通过给这个对象新增属性来达到导出目的 */
  exports: Exports;

  /** 文件名 */
  filename: string | null;

  /** 是否加载完成 */
  loaded: boolean;

  /** 外层包裹函数字符串的代理 ["function(module, exports, require, __filename, __dirname) {", "};"] */
  wrapper: [string, string];

  /** 父模块 */
  parent?: Module;

  /** 子模块 */
  children?: Module[];

  /** 缓存模块 */
  static _cache: Record<string, Module>;
  /** 缓存路径 */
  static _pathCache: Record<string, Module>;
  /** 注册扩展 */
  static _extensions: Record<string, (module: Module, filename: string) => void>;
  /** 全局路径 */
  static globalPaths: string[];

  /** 生成包裹函数 */
  wrap(script: string): string;

  /** 加载模块 */
  load(filename: string): void;

  /** 请求模块 */
  require(id: string): Exports;

  private _compile(
    content: string,
    filename: string,
    format: 'module' | 'commonjs' | undefined
  ): Exports;

  /** 创建 require 函数 */
  static createRequire(filename: string | URL): (id: string) => Exports;

  /**
   * 如果用户覆盖了内置模块的导出,此函数可以确保覆盖用于 CommonJS 和 ES 模块上下文
   */
  static syncBuiltinESMExports(): void;

  /** 查找路径 */
  private static _findPath(request: string, paths: string[], isMain: boolean): string | false;

  /** 根据给定路径查找 node_modules 路径 */
  private static _nodeModulePaths(from: string): string[];

  /** 获取模块解析路径 */
  private static _resolveLookupPaths(request: string, parent: Module): string[];

  /** 加载模块 */
  private static _load(request: string, parent: Module, isMain: boolean): Exports;

  /** 解析模块绝对路径 */
  private static _resolveFilename(
    request: string,
    parent: Module,
    isMain: boolean,
    options: object,
    paths: string[]
  ): string;

  /** 定义用于解析模块的路径 */
  private static _initPaths(): void;

  /** 处理通过 “--require” 加载的模块 */
  private static _preloadModules(requests: string[]): void;
}

–end

  • 24
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值