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.exports
和pkg.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 文件。在这一点上,我有两个选项:
- 使用 V8 的
v8::JSON::Parse()
方法,该方法以v8::String
作为输入并返回v8::Value
作为输出。 - 使用 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++ 三次:
new URL(...)
调用了internalBinding('url').parse()
C++ 方法。path.fileURLToPath()
如果输入是一个字符串,调用了new URL()
。packageJsonReader.read()
调用了fs.readFileSync()
C++ 方法。
将整个函数移到 C++ 使我们能够将 C++ 调用的次数从 3 减少到 1。这个转换还迫使我们在 C++ 中实现了 url.fileURLToPath()
。
参考资料
总结
通过将缓存层移到 C++端、使用 simdjson 库解析 JSON 文件、减少 C++调用次数等优化措施,Node.js 成功提升了 package.json 读取器的性能,减小了序列化/反序列化的成本,从而改进了加载入口点和模块解析的效率。