深度解析webpack5持久化快取

蘋果小蘿蔔發表於2022-04-20

基本介紹

持久化快取是 webpack5 所帶來的非常強大的特性之一。一句話概括就是構建結果持久化快取到本地的磁碟,二次構建(非 watch 模組)直接利用磁碟快取的結果從而跳過構建過程當中的 resolve、build 等耗時的流程,從而大大提升編譯構建的效率。

持久化快取主要解決的就是優化編譯流程,減少編譯耗時的問題,通過全新的快取系統的設計使得整個構建流程更加的高效和安全。在此之前官方或者社群也有不少解決編譯耗時,提高編譯效率的方案。

例如官方提供的 cache-loader,可將上一個 loader 處理的結果快取到磁碟上,下一次在走這個流程的時候(pitch)依據一定的規則來使用快取內容從而跳過後面 loader 的處理。不過 cache-loader 也僅僅能覆蓋到經由 loader 處理後的檔案內容,快取內容的範圍比較受限,此外就是 cache-loader 快取是在構建流程當中進行的,快取資料的過程也是有一些效能開銷的,會影響整個的編譯構建速度,所以建議是搭配譯耗時較長的 loader 一起使用。另外就是 cache-loader 是通過對比檔案 metadata 的 timestamps,這種快取失效策略不是非常的安全,具體見我之前遇到的case

此外還有 babel-loadereslint-loader 內建的快取功能,DLL 等。核心的目的就是已經處理過的內容不需要再重新走一遍原有的流程。這些方案都能解決一定場景下的編譯效率問題。

基本使用

持久化快取是開箱即用的一個功能,但是預設不開啟。為什麼 webpack 沒有將這個功能預設開啟呢?這個其實也在持久化文件中有做說明:webpack 將(反)序列化資料的流程內建並做到的開箱即用,但是對於專案的使用者而言是需要充分了解持久化快取的一些基本配置和策略,來確保在實際開發環節編譯構建的安全性(快取失效策略)。

先看下基本的配置規則 cache 使用:

// webpack.config.js
module.exports = {
  cache: {
    // 開啟持久化快取
    type: 'fileSystem',
    buildDependencies: {
      config: [__filename]
    }
  }
}

cache 欄位下完成有關持久化快取的基本配置,當 type 為 fileSystem 時開啟持久化快取的能力(watch 模式下是分級快取配合使用),另外需要特別注意的是 buildDependencies 的配置,這個配置和整個構建流程的安全性有關。常見於和專案相關的一些配置資訊,例如你是使用 @vue/cli 進行開發的專案,那麼 vue.config.js 就需要作為專案的 buildDependencies,此外 webpack 在內部處理流程當中將所有的 loader 也作為了 buildDependenceis,一旦 buildDependencies 發生了變更,那麼在編譯流程的啟動階段便會導致整個快取失效,進而走一遍新的構建流程。

此外,和持久化快取另外一個相關的配置是:snapshotsnapshot 相關的配置決定了快取記憶體生成 snapshot 時所採用的策略(timestamps | content hash | timestamps + content hash),而這個策略最終會影響到快取是否失效,即 webpack 是否決定來使用快取。

const path = require('path')
module.exports = {
  // ...
  snapshot: {
    // 針對包管理器維護存放的路徑,如果相關依賴命中了這些路徑,那麼他們在建立 snapshot 的過程當中不會將 timestamps、content hash 作為 snapshot 的建立方法,而是 package 的 name + version
    // 一般為了效能方面的考慮,
    managedPaths: [path.resolve(__dirname, '../node_modules')],
    immutablePaths: [],
    // 對於 buildDependencies snapshot 的建立方式
    buildDependencies: {
      // hash: true
      timestamp: true
    },
    // 針對 module build 建立 snapshot 的方式
    module: {
      // hash: true
      timestamp: true
    },
    // 在 resolve request 的時候建立 snapshot 的方式
    resolve: {
      // hash: true
      timestamp: true
    },
    // 在 resolve buildDependencies 的時候建立 snapshot 的方式
    resolveBuildDependencies: {
      // hash: true
      timestamp: true
    }
  }
}

不同的建立 snapshot 方式有不同的效能表現、安全性考慮和適用場景,具體可以參閱相關的文件

其中需要注意一點 cache.buildDependenciessnapshot.buildDependencies 的含義並不一致。cache.buildDependencies 是將哪些檔案 or 目錄作為 buildDependencies(webpack 內部會預設將所有的 loader 作為 buildDependencies) 而 snapshot.buildDependencies 是定義這些 buildDependencies 建立 snapshot 的方式(hash/timestamp)。

構建產出快取在 webpack 內部已經完成了,但是對於一個應用專案而言,高頻的業務開發迭代節奏,基礎庫的升級、第三方庫的接入等等,對於這部分的更新而言 webpack 顯然需要做的一件事情就是感知其變更,同時使快取失效而重新構建新的模組,構建結束後重新寫入快取,這也是 webpack 在持久化快取設計當中非常重要的一個特性:安全性。

工作流 & 原理介紹

首先我們來看下一個 module 的處理流程,一般會經過:

  • resolve(路徑查詢,需要被處理的檔案路徑,loader 路徑等)
  • build(構建)
  • generate(程式碼生成)

等階段。

module 被建立之前,需要經過一系列的 resolve 過程,例如需要被處理的檔案路徑,loader 等等。

module 被建立之後,需要經過 build 的過程:基本上就是交由 loader 處理後進行 parse,這個過程結束後開始處理依賴。

特別是針對 resolvebuild 階段都有一些優化的建議或者是相關的外掛來提升其效率。例如在 resolve 階段可以通過 resolve.extensions 減少 resolve 階段匹配路徑的字尾型別。其核心的目的還是為了減少 resolve 的流程來提高效率。在 build 階段,針對一些耗時的操作使用 cache-loader 快取對應的處理結果等等。

那麼在持久化快取的方案當中,針對這些場景又是如何來進行設計和處理的呢?

首先來看下和 resolve 相關的持久化快取外掛:ResolverCachePlugin.js

class CacheEntry {
  constructor(result, snapshot) {
    this.result = result
    this.snapshot = snapshot
  }
  // 部署(反)序列化介面
  serialize({ write }) {
    write(this.result)
    write(this.snapshot)
  }
  deserialize({ read }) {
    this.result = read()
    this.snapshot = read()
  }
}

// 註冊(反)序列化資料結構
makeSerializable(CacheEntry, 'webpack/lib/cache/ResolverCachePlugin')

class ResolverCachePlugin {
  apply(compiler) {
    const cache = compiler.getCache('ResolverCachePlugin')
    let fileSystemInfo
    let snapshotOptions
    ...
    compiler.hooks.thisCompilation.tap('ResolverCachePlugin', compilation => {
      // 建立 resolve snapshot 相關的配置
      snapshotOptions = compilation.options.snapshot.resolve
      fileSystemInfo = compilation.fileSystemInfo
      ...
    })

    const doRealResolve = (
      itemCache,
      resolver,
      resolveContext,
      request,
      callback
    ) => {
      ...
      resolver.doResolve(
        resolver.hooks.resolve,
        newRequest,
        'Cache miss',
        newResolveContext,
        (err, result) => {
          const fileDependencies = newResolveContext.fileDependencies
          const contextDependencies = newResolveContext.contextDependencies
          const missingDependencies = newResolveContext.missingDependencies
          // 建立快照
          fileSystemInfo.createSnapshot(
            resolveTime,
            fileDependencies,
            contextDependencies,
            missingDependencies,
            snapshotOptions,
            (err, snapshot) => {
              ...
              // 持久化快取
              itemCache.store(new CacheEntry(result, snapshot), storeErr => {
                ...
                callback()
              })
            }
          )
        }
      )
    }

    compiler.resolverFactory.hooks.resolver.intercept({
      factory(type, hook) {
        hook.tap('ResolverCachePlugin', (resolver, options, userOptions) => {
          ...
          resolver.hooks.resolve.tapAsync({
            name: 'ResolverCachePlugin',
            stage: -100
          }, (request, resolveContext, callback) => {
            ...
            const itemCache = cache.getItemCache(identifier, null)
            ...
            const processCacheResult = (err, cacheEntry) => {
              if (cacheEntry) {
                const { snapshot, result } = cacheEntry
                // 判斷快照是否失效
                fileSystemInfo.checkSnapshotValid(
                  snapshot,
                  (err, valid) => {
                    if (err || !valid) {
                      // 進入後續的 resolve 環節
                      return doRealResolve(
                        itemCache,
                        resolver,
                        resolveContext,
                        request,
                        done
                      )
                    }
                    ...
                    // 使用快取資料
                    done(null, result)
                  }
                )
              }
            }
            // 獲取快取
            itemCache.get(processCacheResult)
          })
        })
      }
    })
  }
}

webpack 在做 resolve 快取的流程是非常清晰的:通過在 resolverFactory.hooks.resolver 上做了劫持,並新增 resolver.hooks.resolve 的鉤子,需要注意的是這個 resolve hook 的執行時機 stage: -100,這也意味著這個 hook 執行時機是非常靠前的。

通過 identifier 唯一標識去獲取持久化快取的內容:resolveData 和 snapshot,接下來判斷 snapshot 是否失效,如果失效的話就重新走 resolve 的邏輯,如果沒有失效直接返回 resolveData,跳過 resolve 流程。而在實際走 resolve 的過程當中,流程結束後,首先需要做的一個工作就是依據 fileDependenciescontextDependenciesmissingDependencies 以及在 webpack.config 裡面 snapshot 的 resolve 配置來生成快照的內容,到這裡 resolve 的結果以及 snapshot 都已經生成好了,接下來呼叫持久化快取的介面 itemCache.store 將這個快取動作放到快取佇列當中。

接下來看下 module build 當中和持久化快取相關的內容。

在一個 module 在建立完後,需要將這個 module 加入到整個 moduleGraph 當中來,首先通過 _modulesCache.get(identifier) 來獲取這個 module 的快取資料,如果有快取資料那麼使用快取資料,沒有的話就使用本次建立 module 當中使用的資料。

class Compilation {
  ...
  handleModuleCreation(
    {
      factory,
      dependencies,
      originModule,
      contextInfo,
      context,
      recusive = true,
      connectOrigin = recursive
    },
    callback
  ) {
    // 建立 module
    this.factorizeModule({}, (err, factoryResult) => {
      ...
      const newModule = factoryResult.module

      this.addModule(newModule, (err, module) => {
        ...
      })
    })
  }

  _addModule(module, callback) {
    const identifer = module.identifier()
    // 獲取快取模組
    this._modulesCache.get(identifier, null, (err, cacheModule) => {
      ...
      this._modules.set(identifier, module)
      this.modules.add(module)
      ...
      callback(null, module)
    })
  }
}

接下來進入到 buildModule 階段,在實際進入後續 build 流程之前,有個比較重要的工作就是通過 module.build 方法判斷當前 module 是否需要重新走 build 流程,這裡面也有幾層不同的判斷邏輯,例如 loader 處理過程中指定 buildInfo.cachable,又或者說當前模組沒有 snapshot 去檢查也是需要重新走 build 流程的,最後就是在 snapshot 存在的情況下,需要檢查 snapshot 是否失效。一旦判斷這個 module 的 snapshot 沒有失效,即走快取的邏輯,那麼最終會跳過這個 module 被 loader 處理以及被 parse 的環節。因為這個 module 的所有進行都是從快取當中獲取,包括這個 module 的所有依賴,接下來就進入遞迴處理依賴的階段。如果 snapshot 失效了,那麼就走正常的 build 流程(loader 處理,parse,收集依賴等),build 流程結束後,會利用在構建過程中收集到的 fileDependenciescontextDependenciesmissingDependencies 以及在 webpack.config 裡面 snapshot 的 module 配置來生成快照的內容,此時當前 module 編譯流程結束,同時快照也已經生成好了,接下來才會呼叫持久化快取介面 this._modulesCache.store(module.identifier(), null, module) 將這個快取動作放到快取佇列當中。

// compilation.js
class Compilation {
  ...
  _buildModule(module, callback) {
    ...
    // 判斷 module 是否需要被 build
    module.needBuild(..., (err, needBuild) => {
        ...
        if (!needBuild) {
          this.hooks.stillValidModule.call(module)
          return callback()
        }

        ...
        // 實際 build 環節
        module.build(..., err => {
          ...
          // 將當前 module 內容加入到快取佇列當中
          this._modulesCache.store(module.identifier(), null, module, err => {
            ...
            this.hooks.succeedModule.call(module)
            return callback()
          })
        })
      }
    )
  }
}

// NormalModule.js
class NormalModule extends Module {
  ...
  needBuild(context, callback) {
    if (this._forceBuild) return callback(null, true)

    // always build when module is not cacheable
    if (!this.buildInfo.cachable) return callback(null, true)

    // build when there is no snapshot to check
    if (!this.buildInfo.snapshot) return callback(nuull, true)
    ...

    // check snapshot for validity
    fileSystemInfo.checkSnapshotValid(this.buildInfo.snapshot, (err, valid) => {
      if (err) return callback(err);
      if (!valid) return callback(null, true);
        const hooks = NormalModule.getCompilationHooks(compilation);
        hooks.needBuild.callAsync(this, context, (err, needBuild) => {
        if (err) {
          return callback(
            HookWebpackError.makeWebpackError(
            err,
            "NormalModule.getCompilationHooks().needBuild"
          )
        );
      }
      callback(null, !!needBuild);
      });
    });
  }
}

一張圖來梳理下上述的流程:

webpack-persist-cache

通過分析 module 被建立之前的 resolve 流程以及建立之後的 build 流程,基本瞭解了在整個快取系統當中上層使用的流程,一個比較重要的點就是快取的安全性設計,即在做持久化快取的過程中,需要被快取的內容是一方面,有關這個快取的 snapshot 也是需要被快取下來的,這個是快取是否失效的判斷依據

watch 階段對比 snapshot:檔案的變化觸發新的一次 compilation,在 module.needBuild 中根據 snapshot 來判斷是否需要重新走編譯的流程,這個時候記憶體當中的 _snapshotCache 雖然存在,但是以 Object 作為 key 的 Map 獲取 module.buildInfo.snapshot 階段的時候為 undefined,因此還是會進行 _checkSnapshotValidNoCache,實際上 snapshot 資訊一方面被持久化快取到磁碟當中,此外在生成 snapshot 的階段時,記憶體當中也快取了不同 module 的 timestamp、content hash 這些資訊,所以在 _checkSnapshotValidNoCache 執行的階段也是優先從快取當中獲取這些資訊並進行對比。

第二次熱啟動對比 snapshot :記憶體當中的 _snapshotCache 已經不存在,首先從快取當中讀取 module.buildInfo.snapshot 快照的內容,然後進行 _checkSnapshotValidNoCache

那麼對於 snapshot 來說,有哪些內容是需要被關注的呢?

首先第一點就是和當前需要被快取內容強相關的路徑依賴,包含了:fileDependenciescontextDependenciesmissingDependencies,在生成 snapshot 的過程當中,這些路徑依賴是需要被包含在內的,同時也是判斷快取是否失效的依據;

webpack-cache-snapshot

第二點就是在 snapshot 相關的配置策略,這也決定了 snapshot 的生成方式(timestampes、content hash)、速度以及可靠性。

timestamps 成本低,但是容易失效,content hash 成本更高,但是更加安全。
本地開發的情況下,涉及到頻繁的變動,所以使用成本更低的 timestamps 校驗的方式。但是對於 buildDependencies,相對來說一遍是供編譯環節消費使用的配置檔案等,對於單次的構建流程來說其變動的頻率比較小,同時一般這些配置檔案會影響到大量的模組的編譯,所以使用 content hash。

在 CI 場景下,例如如果是 git clone 的操作的話(快取檔案的存放),一般是 timestamps 會變,但是為了加速編譯速度,使用 content hash 來作為 snapshot 的校驗規則。另外一種場景是 git pull 操作,部分內容的 timestamps 不會經常變,那麼使用 timestamps + content hash 的策略規則。

這部分有關 snapshot 具體的生成策略可以參照 FileSystemInfo.js 裡面有關 createSnapshot 方法的實現。

另外一個需要關注的點就是 buildDependencies 對於快取安全性的影響,在構建啟動之後 webpack 首先會讀取快取,但是在決定是否使用快取之前有個非常重要的判斷依據就是對於 buildDependenciesresolveDependenciesbuildDependencies snapshot 進行檢查,只有當兩者的 snapshot 和快取對比沒有失效的情況下才可啟用快取資料,否則快取資料會全量失效。其效果等同於專案進行一次全新的編譯構建流程。

此外還想說下就是整個持久化快取的底層設計:持久化快取的流程設計是非常獨立且和專案應用的 compile 流程完全解耦的。

在這其中有一個非常重要的類 Cache,銜接了整個專案應用的 compile 流程以及持久化快取的流程。

// lib/Cache.js
class Cache {
  constructor() {
    this.hooks = {
      get: new AsyncSeriasBailHook(['identifer', 'etag', 'gotHandlers']),
      store: new AsyncParallelHook(['identifer', 'etag', 'data']),
      storeBuildDependenceies: new AsyncParallelHook(['dependencies']),
      beginIdle: new SyncHook([]),
      endIdle: new AsyncParallelHook([]),
      shutdown: new AsyncParallelHook([])
    }
  },
  get(identifier, etag, callback) {
    this.hooks.get.callAsync(identifer, etag, gotHandler, (err, result) => {
      ...
    })
  },
  store(identifer, etag, data, callbackk) {
    this.hooks.store.callAsync(identifer, etag, data, makeWebpackErrorCallback(callback, 'Cache.hooks.store'))
  }
}

compile 流程當中需要進行快取的讀取或者寫入操作的時候呼叫 Cache 例項上暴露的 getstore 方法,然後 Cache 通過自身暴露出來的 hooks.gethooks.store 來和快取系統進行互動。之前有提到過在使用持久化快取的過程中 webpack 內部其實是啟動了分級快取,即:記憶體快取(MemoryCachePlugin.jsMemoryWithGcCachePlugin.js)和檔案快取(IdleFileCachePlugin.js)。

記憶體快取和檔案快取分別註冊 Cache 上暴露出來的 hooks.gethooks.store,這樣當在 compile 過程當中丟擲 get/store 事件時也就和快取的流程銜接上了。

get 的階段, watch 模式下的持續構建環節,優先使用記憶體快取(一個 Map 資料)。在二次構建,沒有記憶體快取的情況下,使用檔案快取。

store 的階段,並非是立即將快取內容寫入磁碟,而是將所有的寫操作快取到一個佇列裡面,當 compile 階段結束後,才進行寫的操作。

對於需要被持久化快取的資料結構來說:

  1. 按約定單獨部署(反)序列化資料的介面(serializedeserialize)
  2. 註冊序列化資料結構(makeSerializable)

在 compile 結束後進入到(反)序列化快取資料的階段,實際上也是對應呼叫資料結構上部署的 (de)serialize 介面晉升(反)序列化資料。

整體看下來 webpack5 提供的持久化快取的技術方案相對於開篇提到的一些構建編譯提效的方案來說更加完備,可靠,效能更優,主要體現在:

  • 開箱即用,簡單的配置即可開啟持久化特性;
  • 完備性:v5 設計的一套快取體系更加細粒度覆蓋到了 compile 流程當中非常耗時的流程,例如不僅僅是上文提到的 module resolve、build 階段,還在 程式碼生成、sourceMap 階段都使用到了持久化快取。此外對於開發者而言,遵照整個快取體系的約定也可以開發出基於持久化快取特性的功能特性,從而提高編譯效率;
  • 可靠性:相較於 v4 版本,內建了更加安全基於 content hash 的快取對比策略,即 timestamp + content hash,不同的開發環境、場景下在開發效率和安全性之間取得平衡;
  • 效能:compile 流程和持久化快取解耦,在 compile 階段持久化快取資料的動作不會阻礙整個流程,而是快取至一個佇列當中,只有當 compile 結束後才會進行,與之相關的配置可參見 cache

對開發者而言

  • 基於持久化快取特性開發的 custom module、dependency 需按照約定部署相關介面;
  • 依託框架提供的快取策略,構建安全可靠的依賴關係、快取;

對使用者而言

  • 需要了解持久化快取所解決的問題;
  • 不同開發環境(dev、build)、場景(CI/CD)下的快取、snapshot 等相關的生成策略、配置,適用性;
  • 快取失效的策略規則;

相關文件:


文章首發於個人Blog: 如果您覺得不錯請給個star吧~


相關文章