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

2024年 1月 15日 83.4k 0

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

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

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

  • PostCSS,SVGO 等等
  • 模块解析
  • 使用 eslint
  • npm 脚本
  • draft-js emoji 插件
  • polyfill 暴走
  • 桶装文件崩溃
  • Tailwind CSS
  • 本期共享的是第 2 篇博客 —— 模块解析赋能性能优化。

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

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

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

    捕获堆栈跟踪的成本

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

    图片图片

    单击分析数据中的一系列事件后,可以更清晰地了解正在发生的情况。几乎所有错误的产生都来自调用 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 时,我们需要检查:

  • foo.js -> 不存在
  • foo.jsx -> 不存在
  • foo.cjs -> 不存在
  • foo.mjs -> 不存在
  • 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

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论