提高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文件中进行的。以下是该代码的简化版本:

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,如下所示:

js 复制代码
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 文件。实施如下:

js 复制代码
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 读取器的性能,减小了序列化/反序列化的成本,从而改进了加载入口点和模块解析的效率。

相关推荐
想用offer打牌4 小时前
MCP (Model Context Protocol) 技术理解 - 第二篇
后端·aigc·mcp
崔庆才丨静觅5 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60616 小时前
完成前端时间处理的另一块版图
前端·github·web components
KYGALYX6 小时前
服务异步通信
开发语言·后端·微服务·ruby
掘了6 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅6 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
爬山算法6 小时前
Hibernate(90)如何在故障注入测试中使用Hibernate?
java·后端·hibernate
崔庆才丨静觅7 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment7 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端