Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)
概念
缓存介绍
在计算中,缓存(Cache
)是一个高速数据存储层,相比于直接访问源数据,缓存能够提供给用户和App更容易获取数据的途径,缓存允许应用有效地重用以前检索或计算的数据。缓存应用很广泛,大量应用于不同的系统,如Web应用、数据库、操作系统等等,存储形式可以是存储在内存中,也可以是存储在硬盘中。我们可以从以下四个点了解缓存知识:
- 工作流程:当应用请求数据时,会优先从缓存中读取数据,如果缓存中读取不到数据,那么会从数据源读取数据并做缓存操作。
- 缓存类型:从类型上可以将缓存区分为内存型缓存、本地缓存、分布式缓存。其中内存型缓存存储在应用内存中,本地缓存存储于硬盘中,分布式缓存则是存储于不同系统中用于提高应用可用性和性能。
- 缓存淘汰:如果有可能,我们更希望缓存能够存储所有的数据。但是事实上当缓存数据过多的时候会造成系统性能降低甚至不可用,此时需要有合适的缓存淘汰策略,常用的有LRU、LFU等等算法
- 缓存失效:缓存的存在会导致同一时刻有两个数据源,由此会带来数据不一致情况,所以需要有对应的缓存失效机制。
Webpack缓存
介绍
相比于Vite
按需处理,Webpack
在打包项目时需要加载并编译应用所有代码,编译过程中文件解析、转译都会花费大量时间。在少量更改代码时冷启动也是需要进行全量编译,这样明显多做很多无用功,所以Webpack
提供了内存和磁盘缓存机制用于提高编译效率。
Webpack
为Module
对象、Module
代码生成结果、Chunk
资源输出提供缓存机制,意在减少这些阶段花费时间,从而提高编译速率,下面是一个中等复杂项目使用和不使用缓存时的编译时间对比,全量编译需要11秒,而使用缓存后则不到1秒编译时间。
使用
在Webpack5
中持久缓存是一个开箱即用的能力,只需要添加配置项即可:
module.exports = {
cache: {
type: 'filesystem',
}
}
Webpack5
没有默认开启缓存,主要是Webpack
持久缓存失效算法无法感知用户级别配置的能力,所以需要使用者有一定的意识。先看看有哪些场景会使用缓存无法使用:
在上面脑图中,构建工具变更已经在Webpack
缓存机制考虑范围内,但是配置项和构建脚本则在Webpack
考虑范围外,无法感知到变化。基于上述Webpack
考虑将cache
配置项设置为可选项并默认不开启。
除此之外,Webpack
为不同的场景提供了三种缓存策略:
timestamps
:以文件时间戳作为缓存有效与否判断依据,性能较高。在git pull
、git rebase
等部分代码变更场景使用;hash
:以文件内容hash
作为缓存有效与否判断依据,更为可靠。在git clone
、CI/CD
场景使用;timestamps
+hash
:先试用timestamps
判断缓存是否有效,如果判断失效,再使用hash
作为判断依据,大部分场景下比timestamps
可靠,比hash
性能高。
module.exports = {
// 构建工具(即webpack)缓存检测
buildDependencies: {
timestamps: true,
hash: true,
},
// 构建工具依赖缓存检测
resolveBuildDependencies: {
timestamps: true,
hash: true,
},
// 应用模块缓存检测
module: {
timestamps: true,
hash: true,
},
// 应用模块的模块请求缓存检测
resolve: {
timestamps: true,
hash: true,
},
}
Webpack缓存设计架构
缓存模块是一个相对独立模块,与Webpack
解耦开来,并通过插件(Plugin
)机制接入Webpack
能力。在缓存模块中提供了两种类型缓存能力:内存缓存、持久缓存:
- 内存缓存:顾名思义仅在构建应用生命周期内可用;
- 持久缓存:可持久化于磁盘,在不同构件进程中都能使用;
从整体上看,可以将Webpack
缓存分成三层看待,分别为应用层、连接层、实现层:
- 应用层:在
Webpack
编译器内直接使用 - 连接层:通过
Plugin
机制将缓存实现模块与编译器挂钩; - 实现层:缓存策略实现
从上面角度上看待缓存能力整体设计,能够得到下面的能力架构图:
原理解析
应用层
Webpack
在编译时会经历四个阶段:
make
阶段:解析项目模块依赖关系,将模块文件引用关系映射到内存中,并最终产出模块图ModuleGraph
。seal
阶段:在ModuleGraph
基础上分析产物输出形式,构建ChunkGraph
,并进行模块代码生成。emit
阶段:在代码生成基础上,根据ChunkGraph
内容进行Chunk
资源内容整合。
在三个阶段中都有耗费时间的操作,分别是:
- 解析模块依赖关系:需要读取文件 -> loader处理 -> 解析代码,从而得到模块描述
Module
对象。 - 代码生成:需要逐个完成模块代码转译操作,得到代码生成结果
CodeGenerationResult
对象。 - 资源整合:将模块编译结果整合成资源并输出资源描述对象
Source
Webpack
在开启缓存能力之后,使用Cache
实例为上述三种对象实例提供缓存能力,从而减少不必要的编译开支。Cache
实例是缓存能力的接口对象。
缓存实现
Webpack
目前提供两种形式的缓存能力,在配置的cache.type
字段可以配置缓存类型,有memory
和filesystem
两个选项,分别代表内存缓存和持久化缓存。
内存缓存
在讲解内存缓存之前问大家一个问题:如果让你设计一个JS内存缓存,那你会用什么存储结构呢?答案肯定是有用一个Map
结构,key
存储索引,value
存储缓存内容。Webpack
内部也是使用Map
做存储的。
普通内存缓存(MemoryCachePlugin)
普通内存缓存实现了get
、store
、shutdown
事件,代码比较简单就贴在这里:
class MemoryCachePlugin {
/**
* Apply the plugin
* @param {Compiler} compiler the compiler instance
* @returns {void}
*/
apply(compiler) {
/** @type {Map<string, { etag: Etag | null, data: any }>} */
const cache = new Map();
compiler.cache.hooks.store.tap(
{ name: "MemoryCachePlugin", stage: Cache.STAGE_MEMORY },
(identifier, etag, data) => {
// 主动存储缓存
cache.set(identifier, { etag, data });
}
);
compiler.cache.hooks.get.tap(
{ name: "MemoryCachePlugin", stage: Cache.STAGE_MEMORY },
(identifier, etag, gotHandlers) => {
const cacheEntry = cache.get(identifier);
// 如果为null说明
if (cacheEntry === null) {
return null;
} else if (cacheEntry !== undefined) {
return cacheEntry.etag === etag ? cacheEntry.data : null;
}
// 1. 如果缓存为空,第一次执行的时候会将创建的对象写进缓存
// 2. 这里存储回调存放到队列中,等到后面执行完毕的时候批量进行缓存设置
gotHandlers.push((result, callback) => {
if (result === undefined) {
cache.set(identifier, null);
} else {
cache.set(identifier, { etag, data: result });
}
return callback();
});
}
);
compiler.cache.hooks.shutdown.tap(
{ name: "MemoryCachePlugin", stage: Cache.STAGE_MEMORY },
() => {
// 清空缓存
cache.clear();
}
);
}
}
GC能力内存缓存(MemoryWithGCCachePlugin)
普通缓存缺少了缓存淘汰机制,当缓存数量过多时会导致查询性能下降甚至发生OOM,GC能力内存缓存在普通内存缓存基础上增加了内存淘汰机制。该机制是通过实现了GC清除算法,思路如下:
-
缓存存储:首先先将缓存区分两个年代:新生代(
cache
)和老年代(oldCache
),缓存存储都会存储于cache
中; -
缓存读取:当发生缓存读取时,优先从新生代中获取,如果没有命中,那么会从老年代中获取,此时有两种结果
- 如果命中缓存,此时会将老年代的缓存放到新生代中;
- 如果没有命中缓存,那么返回空
-
缓存淘汰机制:
-
接下来会将
cache
进行等额划分区域,区域大小可配置。 -
每次构建结束时会做两件事情
- 按顺序挑选一块区域放到
oldCache
中,并设置过期时间。 - 遍历
oldCache
,删除过期缓存
- 按顺序挑选一块区域放到
-
持久化缓存
相比于内存缓存,持久化缓存设计上会更加复杂,考虑的影响因素会更多。这是因为内存缓存是在一次构建应用内存储信息(通常是开启watch
模式) ,构建工具、构建配置等不会发生变更,所以考虑的因素会少很多。但是持久化缓存需要考虑更多因素。所以持久化缓存设计上会比内存缓存更加复杂。
持久化内存部分按照自上而下讲解模块设计,分别讲解插件、PackFile缓存策略、持久化三个模块:
缓存插件(IdleFileCachePlugin)
Webpack
持久化缓存插件叫做IdleFileCachePlugin
,从名称上可以看出它有两个特点:
- 持久化缓存
- 空闲时候做存储
IdleFileCachePlugin
在实现缓存的store
和set
方法之外,还监听了编译器的beginIdle
、shutdown
事件,其中beginIdle
发生编译器完成编译任务,shutdown
发生在编译退出前。
用户可以自定义IdleFileCachePlugin
的配置项内容,自定义持久化缓存发生的事件:
module.exports = {
cache: {
// 编译器空闲后,兜底缓存更新等待时间
idleTimeout: 60000,
// 大改造后缓存更新等待时间,大改造的定义是编译花费时间大于平均编译花费时间的2倍
idleTimeoutAfterLargeChanges: 1000,
// 插件第一次进行编译缓存存储的等待时间
idleTimeoutForInitialStore: 5000,
}
}
IdleFileCachePlugin
在缓存策略之上做一层与编译器的桥接层,通过监听编译器事件完成缓存生命周期调转:
hook | 行为 |
---|---|
get | 打开缓存实例,并读取数据。读完数据后会提供restore回调,用于实时更新缓存 |
store | 往存储队列存储添加存储数据任务 |
beginIdle & shutdown | flush存储队列内容,将数据持久化 |
缓存策略(PackFileCacheStrategy)
缓存策略分成两部分讲解:Pack缓存管理、缓存有效性检测:
Pack管理
Pack内部有三个领域模型组成,先介绍各个领域模型的概念:
名称 | 作用 |
---|---|
Pack | 缓存管理实例。对外提供缓存查询、存储接口,对内管理PackContent和PackItemInfo。 |
PackContent | PackContentItems实例的封装,提供缓存内容按需还原能力,优化缓存还原性能。 |
PackContentItems | 存储缓存项内容。 |
PackItemInfo | - 缓存项信息以及索引,包含缓存过期时间、缓存内容下标。 |
- 短暂存储着缓存数据,在持久化时会将数据写入PackContent中 |
缓存分块
Pack
对象内存放着缓存内容PackContent
和缓存索引PackItemInfo
,数据结构如下:
interface PackContent {
content: Map<string, CacheEntry>;
// 数据项对应content key
item: Map<string, string>;
// ...其他数据结构
}
interface PackItemInfo {
loc: number; // PackContent下标
lastAccess: number; // 最后访问时间
}
interface Pack {
contents: PackContent[];
itemInfo: Map<string, PackItemInfo>;
}
在Pack
中的PackContent
是以数组形式存在,且PackContent
内部又是Map
数据结构,理论上用一个Map
就可以完成缓存存储工作(参考内存缓存),那这里的意图是什么呢?
这里是Pack
缓存分块能力,如果缓存全量反序列化成为内存对象,那么需要花费大量时间以及内存空间,而分块能力则是将缓存进行分块并提供按需加载能力。
缓存分块算法
缓存分块需要考虑两个问题:
- 相关联缓存聚合: 在同一此构建任务中缓存存于同一个块
- 缓存块大小控制: 缓存块内容不能过大,过大则失去分块的作用
所以在做缓存分块时,以存储时间维度和存储块大小作为分块区分标志,将存储任务推进队列中,并做分块隔离,等待后续序列化时做缓存分块操作:
在缓存分块基础上读取缓存时,会先从PackItemInfo
中获取缓存内容所在区域,再通过区域item
获取缓存key
,从而读到最终缓存内容。
缓存淘汰
PackItemInfo
中有一个lastAccess
代表最后访问时间,用于缓存淘汰时使用。
缓存有效性检测
缓存是否有效取决于两方面内容:
- 项目代码及其依赖变更:这部分在应用层已经解决;
- 构建工具变更:当
webpack
或webpack
所依赖的第三方库发生版本变更时,缓存失效。
缓存使用SystemFileInfo
按照配置项需求对构建工具及其依赖做了快照处理,在读取缓存之前会做缓存有效性检测(其中PackContainer
存储缓存数据及构建信息):
序列化工具
Webpack
缓存数据多种多样,可以是JavaScript内置数据类型,也可以是自定义类实例,如NormalModule
、CodeGeneration
产生的实例。
序列化工具提供数据转换middleware
机制,middleware
分别在数据序列化和反序列化时注册数据处理能力。数据在持久化过程中会经历ObjectMiddleware
、BinaryMiddleware
、FileMiddleware
:
-
ObjectMiddleware
:- 序列化:将内存中对象实例转为字符串;
- 反序列化:将字符串还原成为内存对象实例
-
BinaryMiddleware
:- 序列化:将字符串转为字节码
- 反序列化:将字节码转为字符串
-
FileMiddleware
:- 序列化:将字节码写入磁盘;
- 反序列化:从磁盘读取数据
这里重点讲解一下ObjectMiddleware
机制,缓存存取时,除了JavaScript内置数据类型之外,还会存在很多领域模型实例(即Class实例)缓存,需要自定义这些实例的序列化和反序列化的方法:
- 存储时将传进来的内存对象数据转为可存储数据;
- 提取时将存储数据转为内存对象数据;
所以在每个类实例上需要明确存取哪些数据,即需要实现serialize
以及deserialize
方法,如NormalModule
类:
同时还要进行注册操作保证还原对象时能够进行实例化:
makeSerializable(NormalModule, "webpack/lib/NormalModule");
ObjectMiddleware
在执行时会递归遍历Pack
实例(该实例也存在serialize
和deserialize
方法)完成数据转换。
运行机制
小结
Webpack缓存分为内存缓存和持久缓存:
-
内存缓存
- 内存缓存本质是由
Map
内存存储数据,可分为普通缓存和带GC策略缓存 - 普通缓存:由
Map
实现存储,不考虑缓存淘汰; - 带GC策略缓存:使用
cache
、oldCache
实现LRU变种缓存淘汰算法;
- 内存缓存本质是由
-
持久缓存
- 相比于内存缓存,持久缓存设计上更加复杂,从架构设计上可以将其分为插件接入层、缓存策略层以及、序列化层。
- 插件接入层:提供缓存策略接入编译器能力;
- 缓存策略层:提供缓存Pack管理能力以及缓存有效性检测能力;
- 序列化层:提供内存对象与磁盘文件的存储、还原能力
转载自:https://juejin.cn/post/7231809738203086906