You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
use std::collections::HashSet;use std::path::PathBuf;structSnapshot{file_dependencies:HashSet<PathBuf>,missing_dependencies:HashSet<PathBuf>,context_dependencies:HashSet<PathBuf>,}
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
webpack Persistent Cache 和 Memory Cache
English version: webpack Persistent Cache and Memory Cache
缓存设计的难点不是“存”,而是如何处理失效(invalidation)。webpack 的持久化缓存、内存缓存和增量计算看起来相互交织,但可以从三个问题入手:缓存依赖哪些输入、如何判断输入变化,以及结果可以复用多久。
本文结合链接到的 webpack 源码分析这些设计,并与 Rspack、Turbopack 的公开实现和案例作比较。涉及实现细节时,以对应链接中的版本为准。
缓存:正确性与性能
缓存通过复用结果避免重复计算。正确的失效判断必须覆盖所有会影响结果的输入,同时校验成本还要低于重新计算的成本。
webpack 的复杂缓存策略,最终都在权衡这两个目标。
可以借用 Salsa 的术语作类比,把缓存相关的输入和计算分成两类。这只是帮助理解的类比,并不是 webpack 的正式分类:
webpack 的通用缓存接口以
identifier定位缓存项,以etag校验版本。对于需要文件系统校验的输入,调用方可能把 etag 设为null,再通过checkSnapshotValid()等接口判断 snapshot 是否仍然有效。etag 避免直接对复杂输入执行深度比较,但生成 etag 本身也有成本。如果使用 hash 作为 etag,还必须完整编码相关输入,并控制碰撞风险。
例如,
MemoryCachePlugin的核心读取逻辑如下:而 ResolverCachePlugin 会先取得 etag 为
null的缓存项,再单独检查 snapshot:文件系统输入:Snapshot
Snapshot 是 webpack 校验文件系统输入的核心结构。模块能否复用缓存,不只取决于单个 timestamp 或 content hash,还取决于它记录的整个依赖快照是否有效。
Snapshot 数据结构
FileSystemInfo.js 中的 Snapshot 包含以下字段,下面省略了类型注释和方法:
这些字段大致分为几组:
_flags、_cached*IterablestartTimefileTimestamps、fileHashes、fileTshscontextTimestamps、contextHashes、contextTshsmissingExistencemanagedItemInfo、managedFiles、managedContexts、managedMissingchildrenSnapshot 的校验策略
webpack 支持三种主要策略:
时间戳比较很快,但文件内容不变时,时间戳也可能变化。例如,checkout、重新解压依赖,或者编辑器重新写入相同内容,都可能导致不必要的缓存失效。单纯执行
git fetch并不等于改写工作区文件,不能将两者混为一谈。如果大多数文件的时间戳都不稳定,额外存储和比较时间戳的收益就会下降。相反,如果大多数文件未被改写,先比较时间戳再按需比较 hash,通常更有价值。
时间戳的精度问题
“时间戳相同”不必然意味着“内容相同”。假设文件系统的时间精度为 1 秒,那么同一秒内的两次修改可能具有相同的时间戳。
webpack 的文件系统信息不只保存原始时间戳,还会结合时间精度、
safeTime和 snapshot 的startTime等信息处理不安全的时间窗口。相关逻辑见 FileSystemInfo.js 的快照校验。因此,不能把 timestamp + hash 模式简化成无条件的“时间戳相同就复用”。同样,时间窗口处理也不意味着可以安全忽略所有人为保留时间戳的内容修改。
不同场景的 Snapshot 配置
不同计算过程需要不同的校验策略。以下默认值来自 webpack 的 snapshot 默认配置:
snapshot.modulesnapshot.contextModulesnapshot.resolvesnapshot.buildDependenciessnapshot.resolveBuildDependenciesDependencies
Snapshot 的核心是记录计算依赖。依赖发生相关变化,快照就需要失效。
index.js时回退到index.json,那么后续出现index.js就可能改变解析结果。例如,
require.context()或某些工具提供的import.meta.glob()会建立目录依赖。这里是在比较类似机制,并不表示 webpack 原生支持后者。将项目根目录加入扫描范围时,应特别注意依赖跟踪的规模和校验成本。另一个容易忽略的细节是:resolver 在解析过程中读取的目录,不一定会成为 context dependency。在本文引用的 webpack 实现中,相关目录也会作为 file dependency 加入 resolve snapshot;它与递归扫描整个目录的语义不同。
Build Dependencies
webpack 会追踪
buildDependencies引入的依赖,例如通过require、import建立的依赖关系。它不会自动把任意fs读取都转换成构建依赖,因此文件和非文件输入仍需要正确声明。这部分使用专门的 resolver,不能简单假设应用的所有
resolve配置都会生效。实现见 FileSystemInfo.js。webpack 还会利用
require.cache分析已加载的 CommonJS 模块依赖。这意味着,构建依赖分析与 Node.js 加载器的实际行为可能发生交互。不同工具采用不同的依赖收集方式时,就可能在别名处理上表现不同。Rspack issue #13734 记录了一个使用 TypeScriptpaths时的构建依赖解析案例。Managed Paths 与 Immutable Paths
大型项目需要跟踪的依赖很多。webpack 利用一些额外假设,减少 snapshot 的计算量。
Immutable paths 假设路径中的内容不变,或者内容变化时路径也会变化。例如,某些包缓存将内容标识编码到路径中。在该假设成立时,可以跳过对路径内容的常规校验。
Managed paths 假设目录由包管理器维护,包内容随着包的身份或版本变化而变化。webpack 可以利用
package.json中的名称、版本等信息,避免逐文件校验整个包。这里需要区分“读取包元信息”和“对整个包内容计算 hash”:managed paths 的优化依赖前者能够代表包内容,而不是证明每个文件都没有变化。
配置说明见 webpack Snapshot 文档。
参数版本信息:Etag
Snapshot 负责校验文件系统输入,etag 则可以用来表示计算参数的版本。下面用一个代码生成过程说明两者的差别。
以下 TypeScript 示例用于说明缓存设计,省略了
Module、Config等类型的定义。如果要缓存
moduleCodegen(),就需要设计缓存 key 和失效策略。解法一:直接比较参数引用
假设
cache是按参数引用比较的元组缓存,而不是普通的 JavaScriptMap:这种做法有两个问题:
module.buildInfo或其他状态变化了,可能错误地命中缓存。因此,不能简单地把对象身份当作内容版本。
解法二:深度比较或自定义相等关系
复杂对象未必有合适的相等语义。函数就是一个典型例子:
这里的函数引用始终相同,但执行结果依赖闭包中的可变状态。
另一个问题是比较成本。假设 snapshot 包含大量路径:
比较这样的集合,可能涉及路径比较、hash 计算和集合查找。当输入足够复杂时,判断相等的成本可能接近甚至超过重新计算的成本。
解法三:用 Etag 表示相关输入的版本
先计算真正影响输出的
filename,再把它与模块内容的 etag 合并:这个示例假设模块的 hash 已覆盖渲染所需的其他输入。
filename只计算一次,避免有状态函数在生成 etag 和实际渲染时返回不同结果。Etag 有两个核心要求。
第一,覆盖所有影响结果的因素。
遗漏输入很容易造成错误缓存。例如,Rspack issue #14873 展示了一个 chunk render 缓存没有包含输出路径的案例:当文件名函数将 chunk 移到更深的目录时,即使 chunk 内容 hash 不变,其中的相对资源 URL 也可能需要变化。
把版本信息直接编码进 key 也可以实现缓存,但可能增加 key 的构造、存储和比较成本。区分稳定的 identifier 与可变的 etag,有助于明确两者的职责。
第二,如果用 hash 作为版本标记,要完整编码输入,并降低碰撞概率。
用于缓存正确性的内容 hash,与
HashMap、HashSet中用于定位桶的 hash,承担着不同职责:Eq区分 key。在Hash/Eq契约成立的前提下,冲突主要影响性能。下面用两个普通函数演示区别,避免把示例误认为真实的
RspackHashtrait 实现:如果只是为一个按完整字符串比较
Eq的 key 提供桶 hash,前缀 hash 仍可以满足“相等值具有相同 hash”的要求,只是可能造成大量冲突。如果直接用它判定缓存内容相同,就会系统性地遗漏后缀变化。任何有限长度的 hash 都存在碰撞可能;完整编码输入并不等于数学上消除了碰撞。
Dev 和 Build 下的 Rebuild
webpack 在开发重建与生产构建中都会建立新的 Compilation,并在构建过程中复用仍然有效的缓存。
watching.invalidate()。后者的公开接口不要求调用方传入 changed files 列表。需要区分
invalidate()的公开参数和 watcher 内部传递的信息。webpack 的 watcher 会提供文件时间信息、修改和删除的路径;这些信息会参与文件系统状态更新,不能简单理解为“只供插件消费”。相关流程见 Watching.js。作为对照,本文引用的 Rspack 持久化 snapshot 实现 会计算 modified、deleted 和 unchanged 路径集合。这体现了另一种组织失效信息的方式:先恢复并计算变化集合,再把这些信息用于后续构建。
Lazy Compilation 与缓存生命周期
开发重建不一定由文件修改触发。启用 lazy compilation 时,浏览器访问尚未激活的入口,也可能触发新的编译。
考虑访问顺序
A → B → A。入口活跃状态、当前 ModuleGraph 和缓存生命周期是三个需要分别处理的问题:因此,从当前图中移除不活跃模块,与保留其可恢复的缓存结果,是可以分开设计的。能否复用还取决于具体缓存层、有效性校验和驱逐策略。
Turbopack 在 Next.js 16.3 中利用文件系统持久化能力驱逐内存缓存,改善长时间开发会话的内存占用。官方给出的“编译 50 个路由后”的示例数据如下:
这些是特定项目的测量结果,不能直接推广到所有应用。数据与机制说明见 Turbopack: What's New in Next.js 16.3。
存储层:Memory、Filesystem 与 GC
webpack 的
cache.type支持两种主要模式:type: "memory":只启用内存缓存。type: "filesystem":启用文件系统缓存,并通常配合内存热区。可以用 L1、L2 的类比理解两层之间的关系。两种模式虽然都涉及内存,但对应的配置并不相同:
cache.maxGenerationscache.maxMemoryGenerationscache.cacheUnaffectedcache.memoryCacheUnaffectedexperiments.cacheUnaffectedexperiments.cacheUnaffectedcache.maxAge这些内存代际选项也有特殊值。例如,filesystem 模式下
maxMemoryGenerations: 0会禁用额外的内存缓存层,不能概括为任何配置下都必然启用 L1。具体装配逻辑见 WebpackOptionsApply.js,配置含义见 webpack Cache 文档。Generational GC
webpack 使用 generation 衡量缓存项经历了多少轮编译仍未被访问,以此驱逐长期不活跃的缓存项。这不是一个按真实时间运行的计时器,也不等同于精确的内存字节上限。
cache.maxGenerations控制。cache.maxMemoryGenerations控制。Filesystem cache 还有独立的
cache.maxAge。它约束磁盘 pack 中缓存项的闲置时长,过期项会在 pack 整理和后续持久化过程中被淘汰,并非到达期限就立即逐项修改磁盘文件。相关实现见 PackFileCacheStrategy.js。flowchart TD Read[缓存读取] --> L1["L1:内存缓存<br/>maxMemoryGenerations"] L1 -->|未命中| L2["L2:磁盘 pack 缓存<br/>maxAge"] L2 -->|未命中或无效| Compute[重新计算] L2 -->|命中后回填| L1持久化时还需要处理文件替换的时机。如果已有 pack 仍可能被读取,就不能随意覆盖或删除正在使用的数据。采用临时文件写入、再切换文件版本,是实现这类存储时需要考虑的机制。
L2 命中后的 L1 回填
L1 未命中时,内存层可以注册
gotHandler,并继续向下一层读取。L2 返回结果后,Cache.get()在完成返回前调用 handler,把结果与 etag 写入 L1。同一进程内的后续读取,就可以直接命中内存。这使得内存缓存被驱逐后,仍然有机会从磁盘恢复,避免完整重算。对 module build 等较重的计算,这一点尤其有价值,也是通过磁盘缓存支撑多路由开发会话内存驱逐的基础。
回填与有效性校验是不同的职责:磁盘中存在一个结果,不代表它对当前输入仍然有效。 需要文件系统快照的缓存项,仍应由相应调用方校验 snapshot;其他缓存项则应校验 etag 或对应的失效条件。
对任何 bundler 实现,都需要区分两种能力:
只实现前者,并不自动具备后者。类似地,暴露了 generation 配置,也不代表所有缓存项都已接入统一的驱逐管理。
基于 Idle Timeout 的延迟存储
开发模式下,持久化缓存的存储不应长期占据 HMR 的关键路径。webpack 的 IdleFileCachePlugin 会在进入空闲阶段后,按
idleTimeout等配置安排写盘。快速连续编辑时,推迟写盘可以减少重复存储,也能避免上一轮缓存序列化与下一轮构建争用资源。相关配置包括普通空闲超时、首次存储超时,以及大量变化后的存储超时。
这也提示了一个实现上的检查点:如果保存 snapshot、序列化或写盘包含大量计算,并且阻塞重建的关键路径,那么开启 persistent cache 反而可能让 HMR 变慢。
缓存的生命周期与依赖范围
生命周期
按结果的有效期,webpack、Rspack、Turbopack 中的缓存可以分成三类:
这三类缓存对 key、失效判断和恢复成本的要求不同。合理划分缓存的有效范围,是高性能缓存实现的重要部分。
计算的依赖范围
另一个分类维度,是计算结果依赖多大范围的上下文:
export *传播的 provided exports 信息。后两类计算引出了
cacheUnaffected与 global effect 的边界。图计算:CacheUnaffected 与 Global Effect
cache.cacheUnaffected(memory)与cache.memoryCacheUnaffected(filesystem)都依赖experiments.cacheUnaffected。它们主要用于同一个 Compiler 生命周期中的重复编译,尤其是开发重建。webpack 的缓存可以粗略区分为通用缓存接口中的缓存项,以及为未受影响模块保留的内存计算结果。两者互相补充。
CacheUnaffected 与通用缓存接口的区别
cache.store()等通用缓存接口cacheUnaffectedbuildInfo、引用关系、影响传播、chunk graph 等get()Map.get()、WeakTupleMap.get()通用缓存进入 filesystem 后端时,需要跨进程稳定的标识。模块可以使用 identifier,但依赖对象、临时 ID 等标识未必跨进程稳定。把所有缓存都设计成可跨进程恢复,会引入额外的标识转换、持久化和校验成本。相关问题可参考 Rust 编译器关于增量持久化的说明。
相比之下,
cacheUnaffected可以利用仍然存活的对象身份和图关系,避免为每次图计算都生成完整的、可持久化的输入摘要。局部计算与非局部计算
如果某个结果的输入能够以较低成本完整表示,通用缓存就比较容易使用:构造 identifier、计算 etag,必要时额外检查 snapshot。
如果结果依赖 ModuleGraph、ChunkGraph,甚至其他计算结果,构造完整 etag 的成本可能很高。
cacheUnaffected尝试通过证明“本轮变化未影响该模块”,直接复用相关内存结果。这不是说
cache.store()只能缓存局部计算,而是两种机制在表达输入和证明结果有效上的成本不同。以模块为例:
module.build主要依赖该模块已声明的构建输入。在输入被完整跟踪、loader 行为符合缓存约定的前提下,可以通过 snapshot 等条件复用结果。export *链上的变化也可能改变它的导出集合。此时,
index.js的导出名称包括a、b、c、d、e。如果把lib3.js改为:那么
index.js的导出集合也会变化,尽管它自己的源码没有改变。理论上可以把所有
export *依赖纳入 etag,但计算这些 etag 可能需要重复遍历依赖图。利用模块影响范围复用结果,有机会降低这部分成本。下面是简化的伪代码:Affected Modules 的计算
webpack 的
_computeAffectedModules()不直接比较每个模块的导出集合。它会检查buildInfo的引用、Dependency 指向的 Module,以及依赖关系上的影响传播。应区分两个阶段:
依赖变化向引用方传播时,还会考虑 dependency 的影响类型,而不是无条件让所有反向可达模块失效。
例如,将
lib3.js改为:虽然导出名称没有变化,但不能据此断言引用方一定不属于 affected modules。模块重建可能更换
buildInfo对象,进而触发保守的缓存失效和依赖传播。理论上可复用某个导出分析结果,不等于这套实现一定会保留该缓存。ModuleMemCaches 与 ModuleMemCaches2
webpack 中有三个容易混淆的字段:
compiler.moduleMemCachesbuildInfo、引用信息和一级缓存compilation.moduleMemCachescompilation.moduleMemCaches2后两个 Map 属于当前 Compilation。它们可以在本轮重新建立,同时继续引用上一轮仍然有效的
WeakTupleMap缓存对象。下面的图省略了具体 Module key:flowchart TD Store["compiler.moduleMemCaches<br/>跨 Compilation 的模块缓存条目"] L1["一级 memCache:WeakTupleMap<br/>模块分析、依赖图查询等结果"] Wrapper["key = memCache2<br/>references + memCache"] L2["二级 memCache2:WeakTupleMap<br/>module hash、runtime requirements"] A1["Compilation A<br/>moduleMemCaches"] B1["Compilation B<br/>moduleMemCaches"] A2["Compilation A<br/>moduleMemCaches2"] B2["Compilation B<br/>moduleMemCaches2"] Store -->|item.memCache| L1 L1 --> Wrapper Wrapper --> L2 A1 --> L1 B1 -->|仍有效时复用| L1 A2 --> L2 B2 -->|校验后复用| L2这些缓存主要保存模块相关的中间计算结果,不是另一份模块源码、AST 或构建产物存储。
一级 MemCache
访问一级缓存包含两次查找:
有效性条件:
buildInfo引用未变化。典型缓存项:
"noWarningsOrErrors"trueFlagDependencyExportsPlugin实例["bundleChunkGraph.blockModules", runtime][dependency, cacheStage, ...args]"memCache2"{ references, memCache }对应公开源码:诊断缓存、导出分析、ChunkGraph 构建、Dependency 查询。
生命周期:
模块构建结束后,
_computeAffectedModules()将当前模块与上一轮基线比较:buildInfo引用改变,或受跟踪的依赖指向改变:创建新的 memCache。buildInfo:删除相应的跨 Compilation 条目。因此,一级缓存可以跨越多轮 Compilation,直到模块自身、依赖指向或影响链发生相关变化。
二级 MemCache2
二级缓存需要在 seal 阶段完成 module/chunk ID 分配后,才能进一步验证。
有效性条件:
典型缓存项:
"moduleRuntimeRequirements-" + runtimeKeySet<RuntimeGlobals>,或表示没有 runtime requirements 的null"moduleHash-" + runtimeKey公开实现见 runtime requirements 缓存 和 module hash 缓存。
生命周期:
_computeAffectedModulesWithChunkGraph()会执行以下处理:两级缓存的区别
假设模块源码和依赖关系都没变,但 module ID 从
10变成15:一级缓存可以继续复用,二级缓存则需要替换。buildInfo变化Global Effect 与 CacheUnaffected 的边界
webpack 的
cacheUnaffected不是一个逐阶段开启的增量开关。理解它时,可以把重点放在“模块自身及其所引用模块的变化”上,但这不是所有计算过程的完整依赖范围。有些计算还受到消费者或整个模块图的影响。仅沿“依赖变化影响引用方”的方向传播,无法覆盖这些计算的失效集合。webpack 将相关影响称为 global effect。
典型例子是
FlagDependencyUsagePlugin:模块的 used exports 由谁引用了它、使用了哪些导出等信息共同决定。首次编译:
随后只修改入口:
lib.js的源码没有变化,但它的 used exports 从foo变成了bar。这里是消费者的变化影响生产者。在 cacheUnaffected 与 usedExports 的公开复现 中,实验移除了 webpack 对不兼容配置的保护,再强行同时启用两者。第二轮编译会错误复用模块 hash 和生成结果,导致输出与全新构建不一致。
正常的 webpack 会拒绝这个不兼容组合,因此该复现不能被理解为默认配置就会产生上述错误。保护逻辑见 FlagDependencyUsagePlugin。
这里需要保证的链路是:
只更新 exports 状态,而没有让下游缓存失效,就仍然可能输出旧代码。对按阶段维护增量结果的实现,需要明确每个阶段改变了什么,以及哪些后续结果依赖这些变化;不能只凭插件名称决定清理范围。
以下优化都涉及这类边界:
optimization.usedExportsFlagDependencyUsagePluginoptimization.mangleExportsMangleExportsPluginoptimization.concatenateModulesModuleConcatenationPlugin另外两处保护见 MangleExportsPlugin 和 ModuleConcatenationPlugin。
因此,
cacheUnaffected虽然有利于开发重建,却不能自动解决所有生产优化的增量计算问题。存在不兼容的 global effect 时,webpack 的这些插件会直接阻止该组合;通用内存缓存和持久化缓存仍可以提供其他层面的复用。生产增量优化的困难在于:生产构建追求全局优化,而增量计算希望把一次变化的影响限制在局部。这种张力也存在于 Rust LTO 等跨模块优化中。相关讨论可参考 Challenges for Incremental Production Optimizations。
其他缓存接口与风险边界
除了
cacheUnaffected与通用缓存接口,webpack 还有一些用途更窄、失效假设不同的缓存选项。Module Unsafe Cache
module.unsafeCache用来跨 Compilation 复用 Dependency 解析到 Module 的结果。在本文引用的默认配置中,顶层 cache 开启时,默认 predicate 匹配
nameForCondition()路径包含node_modules的模块;cache 关闭时,默认值为false。falsetrue(module) => boolean基本过程是:
Dependency D → Module M。D → M。命中依据是 Dependency 的对象身份,不是 request 字符串。重新 build 产生新的 Dependency 对象后,就不会命中旧对象的映射。
新旧 Compilation 可能持有同一个 Module 实例,同时拥有不同的 ModuleGraph。即使图关系由不同的图对象维护,
dependencies、buildInfo、source 等 Module 自身字段仍可能是共享可变状态,不能把旧 Compilation 当作完全不可变的历史快照。之所以叫 unsafe,是因为它用更强的不变性假设省略了部分失效验证。如果 package、resolve 配置、rules、loader 或插件行为发生变化,就可能继续使用过期的解析结果。默认聚焦
node_modules,是因为第三方依赖通常在一次 watch 会话中比较稳定。Factory 还可以通过
factoryResult.cacheable = false禁用这类复用。具体条件见 Compilation.js。Loader Cacheable
NormalModule.needBuild()会综合模块状态、是否可缓存以及 snapshot 等条件,决定是否重建。如果 loader 使用了额外输入,应尽量通过依赖声明把它们纳入失效判断。如果输入无法被可靠跟踪,或者相同已知输入仍可能产生不同结果,可以调用:
这会将该次模块构建结果标记为不可缓存。虚拟模块以及依赖外部可变状态的 loader,尤其需要明确其输入和生命周期。接口说明见 Loader Interface。
Resolve Cache 与 Resolve Unsafe Cache
两者都缓存解析过程,但有效性假设不同:
resolve.cacheresolve.unsafeCacheresolve.cache的默认值跟随顶层 cache 的启用状态。根据 webpack Resolve 文档,resolve.unsafeCache默认未启用,需要显式配置。两者属于不同层次;启用 unsafe cache 时,只要解析结果还依赖 cache key 之外的可变输入,就需要检查其假设是否仍然成立。All reactions