提高Nodejs加载器性能

Node.js 支持两种不同的模块:EcmaScript 模块和 CommonJS 模块。ES 模块是 JavaScript 中的官方标准模块,受到所有现代浏览器的支持。CommonJS 模块是 Node.js 默认使用的模块系统,它不受浏览器支持,也不是官方标准。然而,它仍然被广泛使用。

Node.js 如何加载入口点?

为了区分使用哪个加载器,Node.js 依赖于几个因素,其中最重要的是文件扩展名。如果文件扩展名是.mjs,Node.js 将使用 ES 模块加载器。如果文件扩展名是.cjs,Node.js 将使用 CommonJS 模块加载器。如果文件扩展名是.js,并且 package.json 文件具有”type”: “commonjs”字段(或者根本没有 type 字段),Node.js 将使用 CommonJS 模块加载器。如果 package.json 文件具有”type”: “module”字段,Node.js 将使用 ES 模块加载器。

这个决定是在lib/internal/modules/run_main.js文件中进行的。以下是该代码的简化版本:

const { readPackageScope } = require("internal/modules/package_json_reader");

function shouldUseESMLoader(mainPath) {
  // Determine the module format of the entry point.
  if (mainPath && mainPath.endsWith(".mjs")) {
    return true;
  }
  if (!mainPath || mainPath.endsWith(".cjs")) {
    return false;
  }

  const pkg = readPackageScope(mainPath);
  switch (pkg.data?.type) {
    case "module":
      return true;
    case "commonjs":
      return false;
    default: {
      // No package.json or no `type` field.
      return false;
    }
  }
}

readPackageScope 遍历目录树向上直到找到一个 package.json 文件。在此帖子的优化之前,readPackageScope 调用了 fs.readFileSync 的内部版本,直到找到一个 package.json 文件。这个同步调用进行了文件系统操作并与 Node.js 的 C++层进行通信。这个操作在性能上存在瓶颈,具体取决于它返回的值/类型,因为数据的序列化/反序列化的成本。这就是为什么我们希望尽量避免在 readPackageScope 内部调用 readPackage(即 fs.readFileSync)的原因。

NodeJs 如何解析 package.json 文件

默认情况下,readPackage 调用内部版本的 fs.readFileSync 来读取 package.json 文件。这个同步调用从 Node.js 的 C++层返回一个字符串,稍后使用 V8 的 JSON.parse()方法进行解析。根据这个 JSON 的有效性,Node.js 检查并创建一个对象,这个对象对于其余的加载器执行操作是必需的。这些字段包括 pkg.name、pkg.main、pkg.exports、pkg.imports 和 pkg.type。如果 JSON 具有错误的语法,Node.js 将抛出一个错误并退出进程。

该函数的输出稍后被缓存到一个内部 Map 中,以避免为相同路径再次调用 readPackageScope。这个缓存在整个进程的生命周期内都会保留。

package.json 字段和阅读器的使用

在我们深入讨论可以进行的优化之前,让我们看看 Node.js 如何使用这些字段。在 Node.js 代码库中,解析和重复使用 package.json 字段的常见用例有:

  • pkg.exportspkg.imports 用于根据输入解析不同的模块。
  • pkg.main 用于解析应用程序的入口点。
  • pkg.type 用于解析文件的模块格式。
  • pkg.name 用于自引用的 require/import。

此外,Node.js 支持一个实验性版本的子资源完整性检查,该检查使用 package.json 的结果验证文件的完整性。

最重要的用途是,对于每个 require/import 调用,Node.js 需要知道文件的模块格式。例如,如果用户在一个 CommonJS (CJS) 应用程序中 require 了一个使用 ESM 的 NPM 模块,Node.js 将需要解析该模块的 package.json 文件,并在 NPM 包是 ESM 时抛出错误。

由于在 ESM 和 CJS 加载器中有许多调用和用途,package.json 读取器是 Node.js 加载器实现中最重要的部分之一。

优化

优化缓存层

为了优化 package.json 读取器的性能,我首先将缓存层移到了 C++ 端,以尽量使实现更接近文件系统调用。这个决定迫使在 C++ 中解析 JSON 文件。在这一点上,我有两个选项:

  1. 使用 V8 的 v8::JSON::Parse() 方法,该方法以 v8::String 作为输入并返回 v8::Value 作为输出。
  2. 使用 simdjson 库来解析 JSON 文件。

由于文件系统返回一个字符串,将该字符串转换为 v8::String,然后仅为了检索键和值而将其作为 std::string 返回似乎没有意义。因此,我将 simdjson 作为 Node.js 的依赖项,并使用它来解析 JSON 文件。这个改变使我们能够在 C++ 中解析 JSON 文件,并提取并返回 JavaScript 端仅需的字段,从而减少了需要序列化/反序列化的输入的大小。

避免序列化成本

为了避免返回不必要的大对象,我改变了 readPackage 函数的签名,只返回必要的字段。这个改变简化了 shouldUseESMLoader,如下所示:

function shouldUseESMLoader(mainPath) {
  // Determine the module format of the entry point.
  if (mainPath && mainPath.endsWith(".mjs")) {
    return true;
  }
  if (!mainPath || mainPath.endsWith(".cjs")) {
    return false;
  }

  const response = getNearestParentPackageJSONType(mainPath);

  // No package.json or no `type` field.
  if (response === undefined || response[0] === "none") {
    return false;
  }

  const { 0: type, 1: filePath, 2: rawContent } = response;

  checkPackageJSONIntegrity(filePath, rawContent);

  return type === "module";
}

将缓存层移至 C++ 使我们能够公开返回枚举(整数)而不是字符串的微函数,以获取 package.json 文件的类型。

将 C++ 调用减少为 1 对 1

在 CommonJS 上,readPackageConfig 是在 ESM 加载器上的 getPackageScopeConfig 函数下实现的。该函数进行了大量 C++ 调用,以便解析和检索适用的 package.json 文件。实施如下:

function getPackageScopeConfig(resolved) {
  let packageJSONUrl = new URL("./package.json", resolved);
  while (true) {
    const packageJSONPath = packageJSONUrl.pathname;
    if (packageJSONPath.endsWith("node_modules/package.json")) {
      break;
    }
    const packageConfig = packageJsonReader.read(
      fileURLToPath(packageJSONUrl),
      {
        __proto__: null,
        specifier: resolved,
        isESM: true,
      }
    );
    if (packageConfig.exists) {
      return packageConfig;
    }

    const lastPackageJSONUrl = packageJSONUrl;
    packageJSONUrl = new URL("../package.json", packageJSONUrl);

    // Terminates at root where ../package.json equals ../../package.json
    // (can't just check "/package.json" for Windows support).
    if (packageJSONUrl.pathname === lastPackageJSONUrl.pathname) {
      break;
    }
  }
  const packageJSONPath = fileURLToPath(packageJSONUrl);
  return {
    __proto__: null,
    pjsonPath: packageJSONPath,
    exists: false,
    main: undefined,
    name: undefined,
    type: "none",
    exports: undefined,
    imports: undefined,
  };
}

总结一下,getPackageScopeConfig 函数从以下函数中调用了 C++ 三次:

  1. new URL(...) 调用了 internalBinding('url').parse() C++ 方法。
  2. path.fileURLToPath() 如果输入是一个字符串,调用了 new URL()
  3. packageJsonReader.read() 调用了 fs.readFileSync() C++ 方法。

将整个函数移到 C++ 使我们能够将 C++ 调用的次数从 3 减少到 1。这个转换还迫使我们在 C++ 中实现了 url.fileURLToPath()

参考资料

总结

通过将缓存层移到 C++端、使用 simdjson 库解析 JSON 文件、减少 C++调用次数等优化措施,Node.js 成功提升了 package.json 读取器的性能,减小了序列化/反序列化的成本,从而改进了加载入口点和模块解析的效率。

原文链接:https://juejin.cn/post/7313979344561766450 作者:Moment

(0)
上一篇 2023年12月19日 下午4:00
下一篇 2023年12月19日 下午4:10

相关推荐

发表回复

登录后才能评论