likes
comments
collection
share

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

作者站长头像
站长
· 阅读数 14

概念

缓存介绍

在计算中,缓存(Cache)是一个高速数据存储层,相比于直接访问源数据,缓存能够提供给用户和App更容易获取数据的途径,缓存允许应用有效地重用以前检索或计算的数据。缓存应用很广泛,大量应用于不同的系统,如Web应用、数据库、操作系统等等,存储形式可以是存储在内存中,也可以是存储在硬盘中。我们可以从以下四个点了解缓存知识:

  • 工作流程:当应用请求数据时,会优先从缓存中读取数据,如果缓存中读取不到数据,那么会从数据源读取数据并做缓存操作。
  • 缓存类型:从类型上可以将缓存区分为内存型缓存、本地缓存、分布式缓存。其中内存型缓存存储在应用内存中,本地缓存存储于硬盘中,分布式缓存则是存储于不同系统中用于提高应用可用性和性能。
  • 缓存淘汰:如果有可能,我们更希望缓存能够存储所有的数据。但是事实上当缓存数据过多的时候会造成系统性能降低甚至不可用,此时需要有合适的缓存淘汰策略,常用的有LRU、LFU等等算法
  • 缓存失效:缓存的存在会导致同一时刻有两个数据源,由此会带来数据不一致情况,所以需要有对应的缓存失效机制。

Webpack缓存

介绍

相比于Vite按需处理,Webpack在打包项目时需要加载并编译应用所有代码,编译过程中文件解析、转译都会花费大量时间。在少量更改代码时冷启动也是需要进行全量编译,这样明显多做很多无用功,所以Webpack提供了内存和磁盘缓存机制用于提高编译效率。

WebpackModule对象、Module代码生成结果、Chunk资源输出提供缓存机制,意在减少这些阶段花费时间,从而提高编译速率,下面是一个中等复杂项目使用和不使用缓存时的编译时间对比,全量编译需要11秒,而使用缓存后则不到1秒编译时间。

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

使用

Webpack5中持久缓存是一个开箱即用的能力,只需要添加配置项即可:

module.exports = {
  cache: {
    type: 'filesystem'
  }
}

Webpack5没有默认开启缓存,主要是Webpack持久缓存失效算法无法感知用户级别配置的能力,所以需要使用者有一定的意识。先看看有哪些场景会使用缓存无法使用:

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

在上面脑图中,构建工具变更已经在Webpack缓存机制考虑范围内,但是配置项和构建脚本则在Webpack考虑范围外,无法感知到变化。基于上述Webpack考虑将cache配置项设置为可选项并默认不开启。

除此之外,Webpack为不同的场景提供了三种缓存策略:

  • timestamps:以文件时间戳作为缓存有效与否判断依据,性能较高。在git pullgit rebase等部分代码变更场景使用;
  • hash:以文件内容hash作为缓存有效与否判断依据,更为可靠。在git cloneCI/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机制将缓存实现模块与编译器挂钩;
  • 实现层:缓存策略实现

从上面角度上看待缓存能力整体设计,能够得到下面的能力架构图:

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

原理解析

应用层

Webpack在编译时会经历四个阶段:

  1. make阶段:解析项目模块依赖关系,将模块文件引用关系映射到内存中,并最终产出模块图ModuleGraph
  2. seal阶段:在ModuleGraph基础上分析产物输出形式,构建ChunkGraph,并进行模块代码生成。
  3. emit阶段:在代码生成基础上,根据ChunkGraph内容进行 Chunk 资源内容整合。

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

在三个阶段中都有耗费时间的操作,分别是:

  1. 解析模块依赖关系:需要读取文件 -> loader处理 -> 解析代码,从而得到模块描述Module对象。
  2. 代码生成:需要逐个完成模块代码转译操作,得到代码生成结果CodeGenerationResult对象。
  3. 资源整合:将模块编译结果整合成资源并输出资源描述对象Source

Webpack在开启缓存能力之后,使用Cache实例为上述三种对象实例提供缓存能力,从而减少不必要的编译开支。Cache实例是缓存能力的接口对象。

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

缓存实现

Webpack目前提供两种形式的缓存能力,在配置的cache.type字段可以配置缓存类型,有memoryfilesystem两个选项,分别代表内存缓存持久化缓存

内存缓存

在讲解内存缓存之前问大家一个问题:如果让你设计一个JS内存缓存,那你会用什么存储结构呢?答案肯定是有用一个Map结构,key存储索引,value存储缓存内容。Webpack内部也是使用Map做存储的。

普通内存缓存(MemoryCachePlugin)

普通内存缓存实现了getstoreshutdown事件,代码比较简单就贴在这里:

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清除算法,思路如下:

  1. 缓存存储:首先先将缓存区分两个年代:新生代(cache)和老年代(oldCache),缓存存储都会存储于cache中;

  2. 缓存读取:当发生缓存读取时,优先从新生代中获取,如果没有命中,那么会从老年代中获取,此时有两种结果

    1. 如果命中缓存,此时会将老年代的缓存放到新生代中;
    2. 如果没有命中缓存,那么返回空

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

  1. 缓存淘汰机制

    1. 接下来会将cache进行等额划分区域,区域大小可配置。

    2. 每次构建结束时会做两件事情

      1. 按顺序挑选一块区域放到oldCache中,并设置过期时间。
      2. 遍历oldCache,删除过期缓存

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

持久化缓存

相比于内存缓存,持久化缓存设计上会更加复杂,考虑的影响因素会更多。这是因为内存缓存是在一次构建应用内存储信息(通常是开启watch模式) ,构建工具、构建配置等不会发生变更,所以考虑的因素会少很多。但是持久化缓存需要考虑更多因素。所以持久化缓存设计上会比内存缓存更加复杂

持久化内存部分按照自上而下讲解模块设计,分别讲解插件、PackFile缓存策略、持久化三个模块:

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

缓存插件(IdleFileCachePlugin)

Webpack持久化缓存插件叫做IdleFileCachePlugin,从名称上可以看出它有两个特点:

  • 持久化缓存
  • 空闲时候做存储

IdleFileCachePlugin在实现缓存的storeset方法之外,还监听了编译器的beginIdleshutdown事件,其中beginIdle发生编译器完成编译任务,shutdown发生在编译退出前。

用户可以自定义IdleFileCachePlugin的配置项内容,自定义持久化缓存发生的事件:

module.exports = {
  cache: {
    // 编译器空闲后,兜底缓存更新等待时间
    idleTimeout: 60000,
    // 大改造后缓存更新等待时间,大改造的定义是编译花费时间大于平均编译花费时间的2倍
    idleTimeoutAfterLargeChanges: 1000,
    // 插件第一次进行编译缓存存储的等待时间
    idleTimeoutForInitialStore: 5000,
  }
}

IdleFileCachePlugin在缓存策略之上做一层与编译器的桥接层,通过监听编译器事件完成缓存生命周期调转:

hook行为
get打开缓存实例,并读取数据。读完数据后会提供restore回调,用于实时更新缓存
store往存储队列存储添加存储数据任务
beginIdle & shutdownflush存储队列内容,将数据持久化

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

缓存策略(PackFileCacheStrategy)

缓存策略分成两部分讲解:Pack缓存管理、缓存有效性检测:

Pack管理

Pack内部有三个领域模型组成,先介绍各个领域模型的概念:

名称作用
Pack缓存管理实例。对外提供缓存查询、存储接口,对内管理PackContent和PackItemInfo。
PackContentPackContentItems实例的封装,提供缓存内容按需还原能力,优化缓存还原性能。
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缓存分块能力,如果缓存全量反序列化成为内存对象,那么需要花费大量时间以及内存空间,而分块能力则是将缓存进行分块并提供按需加载能力。

缓存分块算法

缓存分块需要考虑两个问题:

  • 相关联缓存聚合: 在同一此构建任务中缓存存于同一个块
  • 缓存块大小控制: 缓存块内容不能过大,过大则失去分块的作用

所以在做缓存分块时,以存储时间维度和存储块大小作为分块区分标志,将存储任务推进队列中,并做分块隔离,等待后续序列化时做缓存分块操作:

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

在缓存分块基础上读取缓存时,会先从PackItemInfo中获取缓存内容所在区域,再通过区域item获取缓存key,从而读到最终缓存内容。

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

缓存淘汰

PackItemInfo中有一个lastAccess代表最后访问时间,用于缓存淘汰时使用。

缓存有效性检测

缓存是否有效取决于两方面内容:

  • 项目代码及其依赖变更:这部分在应用层已经解决;
  • 构建工具变更:当webpackwebpack所依赖的第三方库发生版本变更时,缓存失效。

缓存使用SystemFileInfo按照配置项需求对构建工具及其依赖做了快照处理,在读取缓存之前会做缓存有效性检测(其中PackContainer存储缓存数据及构建信息):

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

序列化工具

Webpack缓存数据多种多样,可以是JavaScript内置数据类型,也可以是自定义类实例,如NormalModuleCodeGeneration产生的实例。

序列化工具提供数据转换middleware机制,middleware分别在数据序列化和反序列化时注册数据处理能力。数据在持久化过程中会经历ObjectMiddlewareBinaryMiddlewareFileMiddleware

  1. ObjectMiddleware

    1. 序列化:将内存中对象实例转为字符串;
    2. 反序列化:将字符串还原成为内存对象实例
  2. BinaryMiddleware

    1. 序列化:将字符串转为字节码
    2. 反序列化:将字节码转为字符串
  3. FileMiddleware

    1. 序列化:将字节码写入磁盘;
    2. 反序列化:从磁盘读取数据

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

这里重点讲解一下ObjectMiddleware机制,缓存存取时,除了JavaScript内置数据类型之外,还会存在很多领域模型实例(即Class实例)缓存,需要自定义这些实例的序列化和反序列化的方法:

  • 存储时将传进来的内存对象数据转为可存储数据;
  • 提取时将存储数据转为内存对象数据;

所以在每个类实例上需要明确存取哪些数据,即需要实现serialize以及deserialize方法,如NormalModule类:

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

同时还要进行注册操作保证还原对象时能够进行实例化:

makeSerializable(NormalModule, "webpack/lib/NormalModule");

ObjectMiddleware在执行时会递归遍历Pack实例(该实例也存在serializedeserialize方法)完成数据转换。

运行机制

Webpack5源码解读系列7 - 加速构建利器 - 缓存机制(详细讲解缓存实现原理)

小结

Webpack缓存分为内存缓存和持久缓存:

  • 内存缓存

    •   内存缓存本质是由Map内存存储数据,可分为普通缓存和带GC策略缓存
    • 普通缓存:由Map实现存储,不考虑缓存淘汰;
    • 带GC策略缓存:使用cacheoldCache实现LRU变种缓存淘汰算法;
  • 持久缓存

    •   相比于内存缓存,持久缓存设计上更加复杂,从架构设计上可以将其分为插件接入层、缓存策略层以及序列化层
    • 插件接入层:提供缓存策略接入编译器能力;
    • 缓存策略层:提供缓存Pack管理能力以及缓存有效性检测能力;
    • 序列化层:提供内存对象与磁盘文件的存储、还原能力