服务器之家:专注于VPS、云服务器配置技术及软件下载分享
分类导航

node.js|vue.js|jquery|angularjs|React|json|js教程|

服务器之家 - 编程语言 - JavaScript - js教程 - 加速 JS 生态系统:模块解析

加速 JS 生态系统:模块解析

2024-01-15 14:38人猫神话 js教程

我们工具的缓慢并不是由 JS 这种语言造成的,而是因为根本没有优化。JS 生态系统的碎片化也无济于事,因为没有一个用于模块解析的标准包。相反,有很多个包,并且它们都共享不同的功能子集。

大家好,这里是大家的林语冰。

长话短说:无论您是在构建、测试或检查 JS,模块解析始终是这一切的核心。尽管模块解析在前端工具链中占据核心地位,但我们并没有花太多时间来优化它。通过本文讨论的变更,工具的速度优化 30%。

本期《前端翻译计划》共享的是“加速 JS 生态系统系列博客”,包括但不限于:

  1. PostCSS,SVGO 等等
  2. 模块解析
  3. 使用 eslint
  4. npm 脚本
  5. draft-js emoji 插件
  6. polyfill 暴走
  7. 桶装文件崩溃
  8. Tailwind CSS

本期共享的是第 2 篇博客 —— 模块解析赋能性能优化。

在本系列的第 1 篇博客中,我们发现了若干种加速 JS 工具库的方法。虽然这些低层补丁使总构建时间大大加速,但我们的工具中是否还有某些基建可以优化。那些对常见 JS 任务(比如打包、测试和 linting)的总时间影响更大的东东。

因此,我从我们行业常用的各种工具中收集了大约十几个 CPU 配置文件。经过一番检查后,我发现每个配置文件中都存在重复模式,该模式对这些任务的总运行时间的影响高达 30%。它是我们基建中兹事体大的部分,值得深入探讨。

这个关键部分称为模块解析。在我所有调试中,它花费的时间比解析源码还要多。

捕获堆栈跟踪的成本

当我注意到这些跟踪中最耗时的方面花费在 captureLargerStackTrace 负责将堆栈跟踪附加到 Error 对象的内部 Node 函数时,好戏就开始了。这似乎有点不同寻常,因为这两项任务都成功了,并且没有显示任何抛出错误的迹象。

加速 JS 生态系统:模块解析图片

单击分析数据中的一系列事件后,可以更清晰地了解正在发生的情况。几乎所有错误的产生都来自调用 Node 原生的 fs.statSync() 函数,而该函数又在名为 isFile 的函数内调用。文档提到 fs.statSync() 基本上相当于 POSIX 的 fstat 命令,通常用于检查磁盘上是否存在路径,是文件还是目录。考虑到这一点,我们应该只在文件不存在、缺乏文件读取权限或类似特殊用例中获取错误。是时候瞄一下 isFile 的源码了。

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。

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

应用此选项允许我们避免 catch 区块中的 if 语句,这反过来又使 try/catch 变得多余,并允许我们进一步简化函数。

这一简单更改将项目的 lint 时间缩短了 7%。更棒的是,测试也从相同的更改中受益。

文件系统很昂贵

随着该函数的堆栈跟踪开销被消除,我觉得还有一大坨优化空间。您知道的,在几分钟内捕获的跟踪中根本不应该出现几个错误。因此,我在该函数中注入了一个简单的计数器,了解其调用频率。显而易见,它被调用了大约 15k 次,大约是项目中文件数量的 10 倍。这听起来像是一个优化的机会。

模块与否,这是一个问题

默认情况下,工具需要了解三种说明符:

  • 相对模块导入:./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,然后该算法会检查以下位置。

  • /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 目录。这就是缓存已解析模块有很大帮助的一个方面。

但它会变得更好!一大坨项目都使用路径映射别名来节省一点输入,以便您可以在任何地方使用相同的导入说明符,并避免一大坨点 ../../../。这通常是通过 TS 的 paths 编译器选项或打包器中的解析别名来完成的。问题在于,这些通常与包导入无法区分。如果我在 /Users/marvinh/my-project/src/features/ 处添加到功能目录的路径映射,以便我可以使用诸如 import {...} from “features/DetailPage” 之类的导入声明,那么每个工具都应该知道这一点。

但如果没有呢?由于没有每个 JS 工具都使用的集中模块解析包,因此它们是多个相互竞争的工具,支持不同级别的功能。就本人而言,该项目大量使用路径映射,并且它包含一个 linting 插件,该插件不知道 TS 的 tsconfig.json 中定义的路径映射。自然地,它假设 features/DetailPage 指的是一个 Node 模块,这导致它执行整个递归向上遍历,希望找到该模块。但它从未这样做过,所以它会报错。

缓存所有的东西

接下来,我增强了日志记录,查看调用该函数的唯一文件路径有多少个,以及它是否始终返回相同的结果。只有大约 2.5k 个对 isFile 的调用具有唯一的文件路径,并且传递的文件参数和返回值之间存在强大的 1:1 映射。它仍然比项目中的文件数量多,但比总共被调用的 15k 次要低得多。如果我们在其周围添加一个缓存,避免访问文件系统,那会如何?

const cache = new Map()

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

  // 这里存在解析逻辑......

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

添加缓存使总 linting 时间又加快了 15%。不过,缓存的风险在于,它们可能会变得过时。它们通常必须在某个时间点失效。为了安全起见,我最终选择了一种更保守的方法来检查缓存的文件是否仍然存在。如果您认为工具经常在监视模式下运行,那这种情况并不罕见,在监视模式下,期望尽可能多地缓存,并且仅使更改的文件无效。

const cache = new Map()

function resolve(file) {
  const cached = cache.get(file)

  // 保守策略:检查缓存文件是否存在硬盘上
  // 避免监测模式下稳定缓存时文件可能移动或重命名
  if (cached !== undefined && isFile(file)) {
    return cached
  }

  // 这里存在解析逻辑......
  for (const ext of extensions) {
    const filePath = file + ext

    if (isFile(filePath)) {
      cache.set(file, filePath)
      return filePath
    }
  }

  throw new Error(`Could not resolve ${file}`)
}

老实说,我本来希望它首先会抵消添加缓存的好处,因为即使在缓存场景中我们也会访问文件系统。但从数字来看,这只使总 linting 时间恶化了 0.05%。相比之下,这是一个非常小的影响,但额外的文件系统调用不是更重要吗?

文件扩展名猜谜游戏

JS 中的模块问题在于,该语言从一开始就没有模块系统。当 Node 横空出世时,它普及了 CommonJS 模块系统。该系统有若干“可爱”的功能,比如能够省略正在加载的文件的扩展名。当您编写诸如 require("./foo") 之类的语句时,它会自动添加 .js 扩展名,并尝试读取 ./foo.js 处的文件。如果不存在,它将检查 json 文件 ./foo.json ,如果也不可用,它将检查 ./foo/index.js 处的 index 文件。

实际上,我们在这里处理的是歧义,工具必须能够理解 ./foo 的解析结果。这样,很可能会产生浪费的文件系统调用,因为无法提前知道将文件解析到哪里。工具实际上必须尝试每种组合,直到找到匹配项。如果我们看看目前存在的可能扩展的总数,情况会变得更糟。工具通常有一系列潜在的扩展需要检查。如果您包含 TS,那么在撰写本文时,典型前端项目的完整列表为:

const extensions = [
  '.js',
  '.jsx',
  '.cjs',
  '.mjs',
  '.ts',
  '.tsx',
  '.mts',
  '.cts'
]

有 8 个需要检查的潜在扩展。这还不是全部。您实际上必须将该列表加倍才能考虑 index 文件,这些文件也可以解析为所有这些扩展名!这意味着,我们的工具除了循环浏览扩展列表,直到找到磁盘上存在的扩展之外,没有其他选择。当我们想要解析 ./foo,并且实际文件是 foo.ts 时,我们需要检查:

  1. foo.js -> 不存在
  2. foo.jsx -> 不存在
  3. foo.cjs -> 不存在
  4. foo.mjs -> 不存在
  5. foo.ts -> 终于找到了!

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

作为 ES2015 规范的一部分,提出了一个新的模块系统。所有细节都没有及时充实,但语法却充实了。import 语句很快就占据了主导地位,因为它们在工具方面比 CommonJS 有优势。由于其静态性,它为更多工具增强功能开辟了空间,比如最著名的 tree-shaking(树摇优化),其中未使用的模块甚至模块中的功能可以轻松检测到,并从生产版本中删除。自然而然地,每个人都接受了新的导入语法。

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

模块加载和解析的实际规范在几年后最终确定,并通过强制扩展纠正了这个错误。

// 非法 ESM,导入说明符缺失扩展名
import { doSomething } from './foo'

// 合法 ESM
import { doSomething } from './foo.js'

通过消除这种歧义源并始终添加扩展,我们可以避免一整类问题。工具也变得更快。但生态系统在这方面取得进展(甚至根本没有取得进展)还需要时间,因为工具已经适应了处理模糊性的问题。

路在何方?

在整个调查过程中,我有点惊讶地发现在优化模块分辨率方面还有一大坨优化空间,因为它是我们工具的核心。本文中描述的若干更改将 linting 时间减少了 30%!

我们在这里所做的若干优化也不是 JS 独有的。这些优化与其他编程语言的工具中可以找到的优化相同。当谈到模块分辨率时,四个主要要点是:

  • 尽可能避免调用文件系统
  • 尽可能缓存,避免调用文件系统
  • 当使用 fs.stat 或 fs.statSync 时,请始终设置 throwIfNoEntry: false
  • 尽可能限制向上遍历

我们工具的缓慢并不是由 JS 这种语言造成的,而是因为根本没有优化。JS 生态系统的碎片化也无济于事,因为没有一个用于模块解析的标准包。相反,有很多个包,并且它们都共享不同的功能子集。这并不奇怪,因为多年来支持的功能列表不断增长,并且在撰写本文时还没有一个库可以支持所有这些功能。拥有一个每个人都使用的单一库能使每个人一劳永逸地解决此问题。

免责声明:本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 Speeding up the JavaScript ecosystem - module resolution[1]。

[1]Speeding up the JavaScript ecosystem - module resolution: https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-2

原文地址:https://mp.weixin.qq.com/s/ph4HZxTD284pRt7J9B1dQQ

延伸 · 阅读

精彩推荐
  • js教程原生js canvas实现简单贪吃蛇

    原生js canvas实现简单贪吃蛇

    这篇文章主要为大家详细介绍了原生js canvas实现简单贪吃蛇,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下...

    深夜猿6742022-02-15
  • js教程JS小知识,分享一些让我迷惑的前端面试题

    JS小知识,分享一些让我迷惑的前端面试题

    JavaScript 语言最神奇的地方总是让我感到惊讶,那就是它总是有些怪异的地方。无论您对它了解多少,您总能从中学到新的东西。 这些问题我收集了很长时...

    前端达人3992023-12-12
  • js教程浅谈js二维码扫码登录是什么原理

    浅谈js二维码扫码登录是什么原理

    这篇文章主要介绍了浅谈js二维码扫码登录是什么原理,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友...

    大古同学7702022-03-01
  • js教程看完还不会用TypeScript 泛型,你来找我

    看完还不会用TypeScript 泛型,你来找我

    本篇文章主要介绍 typeScript 中新增的泛型概念、泛型使用、泛型与接口结合等内容。...

    前端人5222022-02-22
  • js教程js实现弹框效果

    js实现弹框效果

    这篇文章主要为大家详细介绍了js实现弹框效果,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下...

    程序猿余某人4122022-02-20
  • js教程Javascript实现打鼓效果

    Javascript实现打鼓效果

    这篇文章主要为大家详细介绍了Javascript实现打鼓效果,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下...

    qq_413093507682022-01-11
  • js教程前端高效开发必备的 JS 库梳理

    前端高效开发必备的 JS 库梳理

    之前有很多人问学好前端需要学习哪些 js 库, 主流框架应该学 vue 还是 react ? 针对这些问题, 笔者来说说自己的看法和学习总结....

    前端大全6112022-01-22
  • js教程JavaScript逐点突破系列之this是什么

    JavaScript逐点突破系列之this是什么

    本章将专门介绍与执行上下文创建阶段直接相关的最后一个细节——this是什么?以及它的指向到底是什么,感兴趣的朋友跟随小编一起看看吧...

    蛋黄酥要不要来一口阿7492022-03-03