從構建進程間緩存設計 談 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!

相關文章
相關標籤/搜索