从构建进程间缓存设计 谈 Webpack5 优化和工做原理

从构建进程间缓存设计 谈 Webpack5 优化和工做原理

翻看日历,发现今天是 2020 庚子年第一个节气——立春。上古以“斗柄指向”法,用北斗星斗柄指向寅位时为立春。干支纪元,以立春为岁首,意味着新的一个轮回已开启,万物起始、一切更生。尽管咱们今天仍然面临险峻疫情的挑战,但今日就表明了温暖、生长,我也愿意在在此刻梳理计划本年度的技术发展,作一些春耕播种的工做。前端

让咱们把目光先聚焦到即将破土而出的 Webpack 5 上,尽管国内外已经有抢鲜试水的尝试,其中也不乏好的文章:Webpack 5 升级实验,讲述升级路径和体会,可是尚没发现从技术原理角度的设计解析。vue

这篇文章,我就以 Spec: A module disk cache between build processes 为方向,介绍一下 Webpack 5 最使人期待的「长效缓存」功能的前世此生,技术背景以及落地方案。同时但愿“管中窥豹”,介绍一下总体 Webpack 的构建流程。 整篇文章将会设计大量 Webpack 实现原理和体系设计,阅读须要必定的前置知识和理解成本。 总之:“你认为的缓存不只仅是简单的「空间换时间」,同时,你认为的「Webpack 工程师」偏偏是前端体系中最具功力的拼图板块”。node

关于现状和深度场景 关于问题和解决方向

这一部分咱们将从两个方面,介绍现有 webpack 构建的痛点和已有解决方案,咱们逐一分析这些解决方案的不足,并以 Webpack 官方视角,来探讨是否存在更可行、更优雅的官方解决方案。react

不间断进程(continuous processes)和缓存

对于大型复杂项目应用,在开发阶段,开发者通常习惯使用 Webpack --watch 选项或者 webpack-dev-server 启动一个不间断的进程(continuous processes)以达到最佳的构建速度和效率。Webpack --watch 选项和 webpack-dev-server 都会监听文件系统,进而在必要时,触发持续编译构建动做。此中细节值得研究,源码涉及到文件系统的监听、内存读写、不一样操做系统的兼容、插件化和分层缓存设计等,也有不少著名的性能优化 issues,这里再也不一一介绍,但提炼出关键点须要读者优先了解,以帮助对后文的理解消化:webpack

  • 正常启动 Webpack 构建流程会调用 compiler.run 方法
  • --watch 模式启动的 Webpack 构建流程会调用 compiler.watch 方法,并启动一个构建 watch 服务
  • webpack-dev-server 是一个小型的 NodeJS 服务器,它使用 webpack-dev-middleware 这个包,webpack-dev-middleware 也是最终调用了 compiler.watch 方法
  • --watch 模式依靠各层级的缓存提升后续构建速度
  • --watch 模式下,完成第一次构建后,为了后续再也不重复启动构建进程,Webpack 会在构造函数 Watching 的原型方法 _done 上(Watching.prototype._done)监听文件的变更,实时进行构建
  • 所以,watch 服务进程会处在:「构建 -> 监听文件变更 -> 触发从新构建 -> 构建」的循环当中
  • Webpack 采用 graceful-fs 这个包来实现文件的读写,它对 Node.js 中的 fs 模块进行了扩展和封装,优化了使用方式
  • 除了 graceful-fs,业界(好比 webpack-dev-middleware)使用 memory-fs,借助 memory-fs,能够将 compiler 的 outputFileSystem 设置成 MemoryFileSystem,这样之内存读写的方式,将资源编译文件不落地输出,大大提升构建性能
  • 截止到此,对文件的监听逻辑源码重点在 compiler.watchFileSystem 对象的 watch 方法,具体以 NodeEnvironmentPlugin 插件辅助 Webpack 的 watchpack 模块进行加载
  • 上述监听文件(夹)的底层采用的是 chokidar 包执行
  • 文件(夹)发生变化时,除了进行实例事件触发之外,还有进行文件变动数据的更新,以及 FS_ACCURENCY 的校准逻辑。FS_ACCURENCY 校准是为了平衡「文件系统数据低精确度而致使 mtime 相同但确实发生了变化」的状况
  • 底层文件(夹)监听触发的功能依赖于对 EventEmitter 的继承,并最终完成上层 Webpack 从新构建流程
  • Webpack 的 --watch 选项内置了相似 batching 的能力,咱们称之为 aggregateTimeout。意思是说:在触发 Watchpack 实例监听的文件(夹)的 change 事件后,会将修改的内容暂存的 aggregatedChanges 数组中,并在最后一次文件(夹)没有变动的 200ms 后,将聚合事件 emit 给上层

了解这些内容,你们应该能大体明白「Webpack --watch 模式的背后发生了什么」以及「Webpack --watch 模式下第一次构建耗时较多,后续的构建速度却大幅度提高」——这一现象背后的实现原理了。 原理虽然就是你们都知道的“缓存”这么简单,可是咱们还要刨根问底:--watch 流程是如何利用事件模型,如何采用多个逻辑层设计,如何对触发流程进行解耦,最终实现清晰而可靠的代码的。各类细节须要结合源码逐一分析,这里不是咱们的重点,暂不展开。git

业界构建优化方案梳理和分析

尽管如此,并非全部的 Webpack 使用都须要开启一个不间断的可持续进程(continuous processes,下文用可持续进程表达),好比在 CI(Continuous Integration)持续集成阶段以及构建线上应用包(Production Build)的阶段。这些阶段的构建成本甚至更高,由于开发者须要在 CI/CD pipeline 和构建线上应用时,对 Webpack 配置加入代码优化、代码压缩等插件。github

为此社区上诞生了不少伟大的,励志于缩短 Webpack 构建时间以及减少成本的解决方案,他们包括但不限于:web

  • cache-loader
  • DllReferencePlugin
  • auto-dll-plugin
  • thread-loader
  • happypack
  • hard-source-webpack-plugin

这里简要进行说明:cache-loader 能够在一些性能开销较大的 loader 以前添加,目的是将结果缓存到磁盘里;DLLPlugin 和 DLLReferencePlugin 实现了拆分 bundles,同时节约了反复构建 bundles 的成本,大大提高了构建的速度;thread-loader 和 happypack 实现了单独的 worker 池,用于多进程/多线程运行 loaders;不过有趣的是,vue-cli 和create-react-app 并无使用到 dll 技术,而是使用了更好的代替着:hard-source-webpack-plugin。算法

这些社区方法优化的实现是以牺牲部分文件体积和后续优化空间为代价的。全部这些方法的使用也须要必定的学习成本,更别说普通开发者参与实现和开发成本了。vue-cli

再谈文件监听和缓存:unsafe cache

上面咱们更多提到了模块缓存的概念。除了模块以外,还有个不可忽视的缓存目标,咱们称之为 resolver's unsafe cache。

什么是 resolver's unsafe cache 呢?咱们先要从 Webpack 中 resolver 这个概念提及。Webpack 带来的一大理念是:一切皆模块。在项目中咱们可使用 ESM 的方式 import './xxx/xxx' 或者 import 'some_pkg_in_nodemodules',甚至使用 alias:import '@/xxx' 来实现模块化。Webpack 在处理这些引用时,是经过 resolve 过程,找到正确的目标文件。其实不光是项目代码中的引入声明,在 Webpack 的总体处理流程,包括 loaders 的寻找等,只要涉及到文件路径的,都离不开 resolve 过程。所以 resolve 能够简单地理解为“文件路径查找”。Webpack 对使用者也暴露了 resolve 的配置,咱们能够对文件路径查找过程进行适当的配置,好比设置文件扩展名,查找搜索的目录等。所以,resolve 过程也会涉及到不少耗时的操做。

Webpack 源码中对于 resolver 的实现主要依赖 enhanced-resolve 的 ResolverFactory,它一共建立了三种类型的 resolver:

  • normalResolver:提供文件路径解析功能,用于普通文件导入
  • contextResolver:提供目录路径解析功能,用于动态文件导入
  • loaderResolver:提供文件路径解析功能,用于 loader 文件导入

在 Webpack 构建运行时,对于每一种类型模块,都会使用 Resolver 预先判断路径是否存在,并获取路径的完整地址供后续加载文件使用。固然对于这三种类型 resolver,也设置了缓存:Webpack 自己经过 UnsafeCachePlugin 对 resolve 结果进行缓存,对于相同引用,返回缓存路径结果。UnsafeCachePlugin 插件原理很简单:它经过 UnsafeCachePlugin.prototype.apply 方法,覆盖原有 Resolver 实例的 resolve 方法,新的方法上会包装一层路径结果 cache,以及包装了在完成原有方法后进行 cache 更新的逻辑。

听上去也很简单,可是这个设计和实现过程关联到「是否须要从新构建」的决断,这就值得深究一下了。咱们来具体分析:

在经过 UnsafeCachePlugin 插件完成了必备文件路径查找以后,若是编辑过程没有出错,且当前 loader 调用了 this.cacheable(),且存在上一次构建的结果集合,那么即将进入「是否须要从新构建」的决断(needRebuild),决断策略根据当前模块的 this.fileDependenciesthis.contextDependencies 这两个关键因素来肯定。this.fileDependencies 表示当前模块所关联的文件依赖;this.contextDependencies 表示模块关联的文件夹依赖。咱们先获取这两类依赖的最后变动时间(contextTimestampsfileTimestamps)的最大值 timestamp,再和上一次构建时间 buildTimestamp 进行比对,若是 timestamp >= buildTimestamp,则表示须要从新编译。若是不须要从新编译,直接读取 compilation 对象中的 cache 属性相关值。

请思考,为何 UnsafeCachePlugin 这个插件的名字须要加上一个 unsafe 前缀呢? 事实上,这类缓存(unsafe cache)默认在 Webpack core 中打开,可是它牺牲了必定的 resolving 准确度,同时它意味着持续性构建过程须要反复从新启动决断策略,这就要收集文件的寻找策略(resolutions)的变化,要识别判断文件 resolutions 是否变化,这一系列过程也是有成本的,只不过对于大多数应用,应用缓存 resolutions 性价比更高,是可以显著提高应用构建性能的。

Webpack 5 新的设计提案

了解了上述知识,咱们继续探讨已有方案的缺陷以及 Webpack 5 持久化缓存设计的“台前幕后”。Webpack 5 使人期待的持久缓存优化了整个构建流程,原理依然仍是那一套:当检测到某个文件变化时,根据依赖关系,只对依赖树上相关的文件进行编译,从而大幅提升了构建速度。官方通过测试,16000 个模块组成的单页应用,速度居然能够提升 98%!其中值得注意的是持久缓存会将缓存存储到磁盘。

对于一个持续化构建过程来讲,第一次构建是一次全量构建,它会利用磁盘模块缓存,使得后续的构建从中获利。后续构建具体流程是:读取磁盘缓存 -> 校验模块 -> 解封模块内容。由于模块之间的关系并不会被显式缓存,所以模块之间的关系仍然须要在每次构建过程当中被校验,这个校验过程和正常的 webpack 进行分析依赖关系时的逻辑是彻底一致的。对于 resolver 的缓存一样能够持久化缓存起来,一旦 resolver 缓存通过校验后发现准确匹配,就能够用于快速寻找依赖关系。对于 resolver 缓存校验失败的状况,将会直接执行 resolver 的常规构建逻辑。正常来说,resolver 的变化也将会引发持续构建过程当中文件路径变化的钩子触发。

缓存设计和安全性校验

那么如何设计这样一个持久化缓存呢?从数据类型和结构上来讲,JSON 无疑是一个最好的选择。配合 JSON 数据,咱们实现读写磁盘上的模块缓存数据以及每一个模块的状态字段,这个模块状态字段将会在校验缓存可用性的阶段派上用场。

对于这样的缓存设计,很是重要的一点是缓存的安全性和可用性,也就是说咱们须要保证持久化缓存是一个 safe cache。 任何被缓存的数据都要有一个对应校验可用性的逻辑。如何来保障校验的准确,是很是核心重要的课题。具体来讲:对于每一个模块,咱们根据时间戳 timestamps 和内容 hashes 来作可用性校验。可是这里的 timestamps 并不能彻底保证准确性, 由于实际状况中,会存在文件内容改变,可是 timestamps 并无变化或者甚至 timestamps 变小的状况(好比该文件在依赖关系上下文中被删除,或有被重命名的状况)。所以使用文件内容的 hashes(或其余内容比较算法)就靠谱不少。这里须要注意的是根据文件系统的 metadata 的比较算法(Filesystem metadata comparisons)也是不安全的,由于这个 metadata 每每都是经过文件大小和最近修改时间混淆获得的。

缓存校验的安全性将是 Webpack 5 不一样于以往版本的一大关键,咱们总结一下:

  • 模块内容基于 timestamps 或 filesystem metadata 的校验须要被基于 hash 算法或其余基于内容的比对算法取代
  • 文件依赖关系(File dependency)的 timestamps 的校验须要被文件内容 hashes 算法取代
  • 上下文依赖(Context dependency)的 timestamps 的校验须要被文件路径的 hashes 算法取代

这里提到的 File dependency 和 Context dependency 是 Webpack 内的重要概念,这里再也不展开,只须要读者了解缓存校验设计的思路便可。

除了模块缓存,前面提到过的 resolver 缓存一样须要有相似的缓存校验过程。那么这两种校验过程也一样须要被优化,以达到更好的性能和构建速度。

兄弟缓存(cache sibling)和缓存集合概念

文件依赖关系的变更,文件内容的变更都会触发构建,进而对每一个模块的缓存进行校验和应用。一样值得思考的是:不一样的 Webpack 配置(好比对 Webpack 配置的修改),不该该直接致使模块缓存失效,而是应该对应不一样的缓存集合。

Webpack 配置可能会在开发期间频繁地被改动,对于不一样配置的缓存集合,咱们可使用配置内容的 hash 来作标记,标记出该配置下的缓存集合。对于持续性构建来讲,每一个新增构建会根据当前的配置 hash 找到匹配的缓存集合,再继续进行构建过程。

以下图:

缓存集合

另一个注意点是对于第三方依赖即 node_modules 文件内内容的缓存和更新。对于此,Yarn 和 Npm 5 以上版本,这些包管理机制自身能够经过锁文件的 hash 校验来保证依赖内容的一致性和更新监听。对于 Npm 5 如下版本或其余状况,咱们仍然须要一种缓存和更新机制,来保证依赖内容的一致性和变化监听。一种经常使用作法是:将 node_modules 文件夹内第一层全部的 package.json 文件集合进行 hash 化。

缓存淘汰策略设计

咱们在设计任何一种缓存体系时,除了要考虑缓存校验,还要考虑到缓存容量限制。 Webpack 5 持久化缓存固然不能无限制的扩展,对于磁盘的合理利用和缓存清理设置是必不可少的关键环节。

初期 Webpack 5 核心开发者 mzgoddard 在讨论设计时认为:对于一个缓存集合,最大限度应该不超过 5 个缓存内容,最大累积资源占用不超过 500 MB,当逼近或超过 500MB 的阈值时,优先删除最老的缓存内容。同时,也设计了缓存的有效时长为 2 个星期。

这实际上相似一个经典的 LRU cache(Least Recently Used 最近最少使用)设计。 该算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“若是数据最近被访问过,那么未来被访问的概率也更高”。通常咱们经过基于 HashMap 和 双向链表实现 LRU cache。具体内容只作提示,再也不展开。

其余考虑点

一个强健有效的缓存系统,尤为是对于 Webpack 这种复杂的构建工具来讲,须要考虑的关键点仍然有不少。这里咱们简单进行罗列,不求面面俱到,更重要的是让读者可以有一个更加立体的认知:

  • 面向 Webpack plugin 和 loader 开发者的缓存需求

对于 Webpack plugin 和 loader 开发者来讲,缓存体系须要实现开箱即用的工具或策略以便完成对缓存的调试和检验。同时也须要暴露给 Webpack plugin 和 loader 开发者开关缓存的能力以及时全量缓存失效的能力。

  • 面向普通开发者的缓存需求

对于普通开发者,最核心的需求固然是能够依靠缓存系统完成构建的绝对量级优化。同时须要对未开启缓存的性能不优化型构建进行提示,且该提示应该是可关闭的。对于缓存的操做,不须要普通开发者手动进行,全部缓存体系的运转都应该是一个自动流程。

一样对于开发者,也可以使用不支持持久化缓存的 Webpack plugin 和 loader。缓存体系的设计和建设,固然不能破环整个 Webpack 生态体系。

  • 面向 Webpack 开发者

对于 Webpack 官方核心开发者,缓存体系一样须要提供测试和调试能力。

  • 面向 CI 过程和跨系统

CI/CD(持续集成 Continuous Integration / 持续部署 Continuous Deployment)中涉及到的前端构建始终是一个有趣的话题。对于 Webpack 5 持久化缓存来讲,对于 CI/CD 过程以及跨系统场景,也应该有合理的控制和设计。

整体来说,在这个阶段的持久化缓存应该“易于携带”,咱们用 portable 此次词语来形容这种特性。对不一样的 CI 实例,或不一样项目在不一样系统设备上的 clones 来讲,缓存都应该能够被重复利用,跨环境利用,这也是缓存在面向 CI 过程和跨系统当中所表现出来的可移植性。 举个例子,缓存内容应该在不一样的 pipeline 阶段中,在不一样的 CI 实例上均可用;或者从一个公共的中心化的存储中拉取最新的缓存内容,而不须要在经过第一次全量构建获取缓存内容。

  • 持久化缓存信息写入 Webpack stats

熟悉 Webpack 核心体系的读者应该对于 Webpack stats 并不陌生。Webpack stats 是 Webpack 对于一次构建的统计分析信息,它对于分析 Webpack 构建过程,优化构建方案很是重要。在此信息中,咱们也应该加入持久化缓存所关联的磁盘信息(disk cache information )。好比:编译缓存 ID,缓存所占用磁盘空间等。

总结

本篇文章没有贴源码来具体分析 Webpack 5 持久化缓存实现,而是从设计体系出发,讲解 Webpack 现有构建流程和缓存环节。其中涉及到较多 Webpack 核心原理和基本概念,在阅读过程当中读者能够随时查漏补缺。缓存体系提及来简单,可是如何实现的优雅,完成体系化、安全化、工程化等多方面考虑,仍然须要每个开发者深思。

文章开篇提到了此时严峻的疫情形势,在这个时间节点,我相信将来一切就像阿尔贝•加缪在《鼠疫》中所说:“春天的脚步正从全部偏远的区域向疫区走来。成千上万朵玫瑰依旧枯萎在市场和街道两旁花商的篮子里,但空气中充溢着它们的香气”。 同时,书中另一句话也让我印象深入:“对将来真正的慷慨,是把一切献给如今”, 抗击疫情如此,学习进阶道路一样如此。

Happy coding!

相关文章
相关标签/搜索