加速JavaScript - 优化模块解析

本系列的第一部分中,我们找到了一些加速JavaScript工具中使用的各种库的方法。虽然这些低级别的补丁使总体构建时间大幅提升,但我想知道是否有一些更基本的工具可以改进。这些工具对于常见的JavaScript任务(如打包、测试和代码检查)的总时间有更大的影响。

因此,在接下来的几天里,我收集了大约十几个来自我们行业常用工具和任务的CPU性能分析。经过一番检查,我发现了一个在我查看的每个性能分析中都存在的重复模式,它对这些任务的总运行时间产生了多达30%的影响。它是我们基础设施中如此关键和有影响力的一部分,值得拥有自己的博客文章。

这个关键的部分被称为模块解析。在我查看的所有追踪中,它的总时间超过了解析源代码的时间。

捕获堆栈消耗

一切都始于我注意到这些追踪中最耗时的部分是在 captureLargerStackTrace 中花费的,这是一个内部的 Node 函数,负责将堆栈跟踪附加到 Error 对象上。鉴于这两个任务都成功完成且没有显示出任何错误被抛出的迹象,这似乎有点不寻常。

在点击了一堆性能数据中的多个发生地点后,关于发生了什么情况的画面变得更加清晰。几乎所有的错误创建都来自对 Node 的本地 fs.statSync() 函数的调用,而这反过来又是在一个名为isFile的函数内调用的。文档提到fs.statSync()基本上相当于POSIX fstat 命令,通常用于检查磁盘上的路径是否存在,是文件还是目录。考虑到这一点,我们只应该在文件不存在、我们缺少读取权限或类似情况下才会在这里收到错误。是时候来看看isFile的源代码了。

1
2
3
4
5
6
7
8
9
10
11
function isFile(file) {
try {
const stat = fs.statSync(file);
return stat.isFile() || stat.isFIFO();
} catch (err) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return false;
}
throw err;
}
}

这是一个看起来无害的函数,但在性能追踪中仍然出现了问题。值得注意的是,我们忽略了某些错误情况,而是返回false而不是将错误传递下去。ENOENT ENOTDIR 错误代码都最终意味着磁盘上不存在该路径。也许这就是我们看到的额外开销?我的意思是我们在这里立即忽略了这些错误。为了测试这个理论,我记录了try/catch块捕获到的所有错误。结果,每个抛出的错误都是 ENOENT 代码或 ENOTDIR 代码。

查看 Node fs.statSync 文档,发现它支持传递throwIfNoEntry选项,该选项可以在没有文件系统条目存在时防止错误被抛出。相反,它会在这种情况下返回 undefined

1
2
3
4
function isFile(file) {
const stat = fs.statSync(file, { throwIfNoEntry: false });
return stat !== undefined && (stat.isFile() || stat.isFIFO());
}

应用该选项使我们能够摆脱 catch 块中的if语句,进而使try/catch多余,并允许我们进一步简化函数。

这一变更使得整个项目的lint时间减少了 7%。更令人惊奇的是,通过相同的变更,测试也获得了类似的加速。

文件系统的开销很大

通过消除该函数堆栈跟踪的开销,我觉得还有更多的优化空间。你知道的,抛出几个错误实际上不应该在几分钟内的跟踪中显示出来。因此,我在该函数中注入了一个简单的计数器,以了解它的调用频率。很明显,它被调用了大约15,000次,约是项目中文件数的10倍。这就是一个改进的机会。

模块化or非模块化

默认情况下,工具了解三种类型的导入规范:

  • 相对模块导入:./foo, ../bar/boof
  • 绝对模块导入:/foo, /foo/bar/bob
  • 包导入:foo, @foo/bar

从性能的角度来看,这三种导入规范中最有趣的是最后一种。光秃秃的导入规范,不以点 . 或斜杠 / 开头的那些,通常是一种特殊的导入,通常指的是npm包。这个算法在node的文档中有详细描述。其核心思想是尝试解析包名称,然后向上遍历检查是否存在一个包含该模块的特殊node_modules目录,直到达到文件系统的根目录。让我们通过一个例子来说明。

假设我们有一个位于/Users/marvinh/my-project/src/features/DetailPage/components/Layout/index.js的文件,试图导入一个名为foo的模块。该算法然后会检查以下位置。

1
2
3
4
5
6
7
8
/Users/marvinh/my-project/src/features/DetailPage/components/Layout/node_modules/foo/
/Users/marvinh/my-project/src/features/DetailPage/components/node_modules/foo/
/Users/marvinh/my-project/src/features/DetailPage/node_modules/foo/
/Users/marvinh/my-project/src/features/node_modules/foo/
/Users/marvinh/my-project/src/node_modules/foo/
/Users/marvinh/my-project/node_modules/foo/
/Users/marvinh/node_modules/foo/
/Users/node_modules/foo/

这是大量的文件系统调用。简而言之,将检查每个目录是否包含一个模块目录。检查的次数直接与导入文件所在的目录数成正比。问题在于,这对foo在每个文件中被导入的每个文件都会发生。也就是说,如果foo在另一个地方的文件中被导入,我们将再次向上爬整个目录树,直到找到包含该模块的node_modules目录。而这正是缓存解析模块非常有帮助的方面。

但情况更好!许多项目使用路径映射别名来节省一点输入,这样你就可以在任何地方使用相同的导入规范,避免使用大量的点 ../../../。这通常是通过TypeScriptpaths编译器选项或打包器中的resolve别名来完成的。问题在于这些通常无法与包导入区分开。如果我向features目录添加一个路径映射,位于/Users/marvinh/my-project/src/features/,以便我可以使用import {...} from "features/DetailPage"这样的导入声明,那么每个工具都应该了解这一点。

但如果它不了解呢?由于没有一个集中的模块解析包是每个JavaScript工具都使用的,它们是多个具有不同功能支持水平的竞争对手。在我的案例中,项目大量使用路径映射,并包含一个不知道TypeScript tsconfig.json中定义的路径映射的linting插件。当然,它认为features/DetailPage指的是一个节点模块,这导致它进行整个递归向上遍历操作,希望找到该模块。但它从未找到,所以它抛出了一个错误。

缓存所有内容

接下来,我增强了日志记录,以查看该函数被调用的唯一文件路径数量以及它是否总是返回相同的结果。isFile函数大约有2.5k次调用具有唯一的文件路径,传递的文件参数与返回值之间存在强烈的一对一映射关系。虽然这仍然超过了项目中的文件数量,但比总共15k次调用要低得多。如果我们在这周围添加一个缓存,以避免访问文件系统会怎么样呢?

1
2
3
4
5
6
7
8
9
10
11
12
const cache = new Map();

function resolve(file) {
const cached = cache.get(file);
if (cached !== undefined) return cached;

// ...existing resolution logic here

const resolved = isFile(file);
cache.set(file, resolved);
return file;
}

添加缓存将总体linting时间提速了15%。不错!不过,关于缓存的一个风险是它们可能变得陈旧。它们通常需要在某个时间点失效。为了保险起见,我最终采取了一种更为保守的方法,检查缓存的文件是否仍然存在。这是一个很常见的情况,尤其是在观察模式下运行工具时,人们期望尽可能缓存并仅使已更改的文件失效。

我实际上预期这会抵消首次添加缓存的好处,因为即使在缓存的情况下,我们仍然要访问文件系统。但查看数据,这只增加了总linting时间的0.05%。与此相比,这是一个非常小的损失,但额外的文件系统调用不应该更为重要吗?

文件扩展名匹配

JavaScript模块的一个问题是,该语言一开始并没有模块系统。当Node.js出现时,它推广了CommonJS模块系统。该系统有一些“巧妙”的特性,比如允许省略正在加载的文件的扩展名。当你编写类似require("./foo")的语句时,它会自动添加.js扩展名,并尝试读取./foo.js文件。如果不存在该文件,它将检查./foo.json文件,如果也不可用,则将检查./foo/index.js文件。

实际上,这里涉及到歧义,工具必须弄清楚./foo应该解析到哪里。因此,在预先知道要将文件解析到何处之前,存在浪费文件系统调用的可能性很高。工具实际上必须尝试每种组合,直到找到匹配项。如果我们看一下当今可能的扩展名的总数,情况会变得更糟。工具通常有一个要检查的潜在扩展名数组。如果考虑到TypeScript,截至我写这篇文章时,典型前端项目的完整列表如下:

1
2
3
4
5
6
7
8
9
10
const extensions = [
".js",
".jsx",
".cjs",
".mjs",
".ts",
".tsx",
".mts",
".cts",
];

这是8个潜在的扩展名需要检查。而且这还不是全部。你基本上必须将该列表加倍,以考虑到索引文件也可能解析到所有这些扩展名!这意味着我们的工具别无选择,只能循环遍历扩展名列表,直到找到一个在磁盘上存在的扩展名。当我们想解析./foo而实际文件是foo.ts时,我们需要检查:

foo.js -> 不存在
foo.jsx -> 不存在
foo.cjs -> 不存在
foo.mjs -> 不存在
foo.ts -> 中奖!

这是四次不必要的文件系统调用。当然,你可以更改扩展名的顺序,将项目中最常见的扩展名放在数组的开头。这会增加找到正确扩展名的机会,但不能完全消除问题。

作为ES2015规范的一部分,提出了一个新的模块系统。虽然没有及时完善所有细节,但已经确定了语法。由于其静态性,它为诸多增强功能提供了更多的可能性,最著名的是无用模块或者甚至可以轻松检测并从生产构建中删除的模块中的未使用函数,这使其成为工具的理想选择。自然地,所有人都转向了新的导入语法。

然而,有一个问题:只有语法被最终确定,实际模块加载或解析应该如何工作并没有被确定。为了填补这个空白,工具重新使用了CommonJS的现有语义。这对于采用来说是好事,因为大多数代码库的移植只需要进行语法更改,而这些更改可以通过代码转换自动完成。从采用的角度来看,这是一个梦幻般的方面!但这也意味着我们继承了导入说明符应该解析到哪个文件扩展名的猜测游戏。

多年后,有关模块加载和解析的实际规范才最终确定,它通过使扩展名成为强制项来纠正了这个错误。

1
2
3
4
5
// Invalid ESM, missing extension in import specifier
import { doSomething } from "./foo";

// Valid ESM
import { doSomething } from "./foo.js";

通过消除这种不确定性源,并始终添加扩展名,我们避免了整个一类问题。工具的速度也变得更快。但是,要等到整个生态系统朝着这个方向前进,或者根本不可能前进,因为工具已经适应了处理这种不确定性。

接下来该怎么办?

在整个调查过程中,我对于在优化模块解析方面有这么大的改进空间感到有些惊讶,因为它在我们的工具中是如此核心。本文中描述的少量更改将 linting 时间减少了 30%!

这里所做的少量优化也并不局限于 JavaScript。这些是可以在其他编程语言的工具中找到的相同优化。在涉及模块解析时,主要的四个要点是:

  • 尽量避免对文件系统的调用
  • 尽量缓存以避免对文件系统的调用
  • 在使用 fs.stat 或 fs.statSync 时,始终设置 throwIfNoEntry: false
  • 尽量限制向上遍历

我们工具的慢并不是由 JavaScript 这门语言引起的,而是因为某些事情根本没有被优化。JavaScript 生态系统的分散也没有帮助,因为没有单一的标准包用于模块解析。相反,有多个包,它们都共享不同的功能子集。然而,这并不令人意外,因为支持的功能列表多年来已经增长,但还没有单一的库支持所有这些功能。如果有一个每个人都使用的单一库,将会使解决这个问题变得更加容易。