記錄--前端起dev從110秒減少到7秒, 開發體驗大幅提升

林恒發表於2024-06-06

🧑‍💻 寫在開頭

點贊 + 收藏 === 學會🤣🤣🤣

[webpack由淺入深]系列的內容

  • 第一層: 瞭解一個小功能的完整流程. 看完可以滿足好奇心應付原理級別面試.
  • 第二層: 原始碼陪讀, webpack原始碼比較靈活, 自己看容易陷入迷惑. 文章裡會貼出關鍵流程的程式碼來輔助閱讀原始碼. 如果你正在自己除錯, 在這些方法上下斷點會節約你寶貴的時間.

因為每次是以小功能出發, 所以文章系列會有前後依賴, 有興趣的可以關注這個系列.


webpack cache 釋出3年多了, 在歷史包袱中的專案中其實非常好用.

本文會介紹 cache 在一個專案中的實踐經驗, 和實現流程, 以及瞭解流程後的一些推論.

webpack cache 實踐經驗

我的實踐經驗是基於公司的一個 monorepo 老專案.

效果是單個子專案的dev速度從110秒減少到了7秒, 單個檔案改動10秒.

下面說的經驗也都是基於這個例子.

合適的使用場景

webpack cache 的效果是用磁碟空間換 compile 速度.

所以在我看來, webpack cache 更合適在本地 dev 的場景使用, 因為本地 dev 觸發 compile 比 ci 伺服器頻繁得多, 並且改動更小, 可以命中更多快取, 也能大幅提升開發體驗.

實踐

在實踐中, 快取的命中率沒什麼可操作性. 最佳化空間都在減少佔用磁碟空間上. 在我的專案中, 我做了以下配置:

  1. 如果配置 cache 的檔案是讀取配置檔案的, 要將buildDependencies 配置為你的檔案, 而不是 __filename. 在我們公司打包指令碼中是一個 webpack-chain 檔案.
  2. monorepo 子包會有一些公共依賴, 在 module resolve 的時候也會指到主包的 node_modules, 在這種情況下 cache 配置的 cacheDirectory 可以讓多個子包指到同一個資料夾, 來節省cache空間. (在 dev 的時候 react-refresh 會產生大幾百m的快取, 是起碼可以節省的)
  3. 合理設定 maxAge, 超過 maxAge 的未被使用的快取會被清除.

我的看法是生產設定小, dev看自己電腦空間, 如果足夠的話可以不設定. (預設一個月)

webpack cache 實現流程

下面會深入一下 cache 的實現流程, 瞭解流程除了滿足好奇心, 還可以:

  • 根據特殊場景最佳化配置.

  • 瞭解什麼邊緣情況會造成快取佔用磁碟大.

  • 根據自己需求二開 cache.

webpack cache 的實現流程職能分層非常清晰, 並且只有一個分層比較複雜, 其他都很簡單.

我們從 compile 時呼叫 cache 說起.

在 compile 流程中讀取與儲存 cache

webpack流程相關的前置知識如果不清楚, 需要先看以往的文章來補一下, 再繼續回這裡.

compilation裡有三個變數: _modulesCache, _assetsCache, _codeGenerationCache. 分別在對應的時間點讀取和寫入 cache:

  • _modulesCache 讀取: 在 addModule 的時候讀取. module 的 build 在讀取之後, 如果命中 cache, 那麼needBuild就會是false, 跳過這個 module 的 build, 來節省時間. (build 做的事是執行 loader 和 parse 並分析 ast )

  • _modulesCache 寫入: 在 module 的 build 完成之後, 把 build 後的 module 結果按照 module 的 id 儲存起來.

  • _codeGenerationCache: 讀取和寫入分別在module.codeGeneration()的前後.

  • _assetsCache: 在最終生成 assets 的階段, 在獲取 manifest 以後讀取 cache, 如果命中, 則不逐個呼叫fileManifest.render()來產生 assets 了. 如果不命中, 則呼叫 render 後寫入 cache.

(這裡呼叫的時候加了層包裝是因為這裡的 cache 都要匹配 hash )

這些 cache 的來源和相關的呼叫時機

在程式碼中可以看到, 這些 cache 都是呼叫 compiler.getCache() 獲得的, 也就是 compiler.cache()封裝了一層 facade.

this.cache就是new Cache(). 所以上面章節的 cache, 都是 new Cache()例項的呼叫.

另外可以看到, this.cache在 compiler 中, 還在對應的流程中呼叫了 beginIdle, endIdle, shutdown, 和storeBuildDependencies.

buildDependency 不影響功能先不看, 其他的呼叫之後展開.

透過 option 指向不同的 cache 實現

進入到Cache類裡, 發現所有方法的時間都是呼叫了 tapable.

cache 的具體實現, 是在 apply option 的時候注入的. (檔案是 WebpackOptionsApply, 方法是 process )

在這裡可以看到, case 很少, 只有2個.

第一個是使用記憶體, 第二個是使用檔案系統寫入硬碟.

記憶體使用裡的MemoryCachePlugin非常簡單:

在記憶體裡建立一個map, 分別在外部呼叫get(), 和store()方法的時候呼叫對應的map的方法.

另外在shutdown的時候把map清了.

對, 就是這麼簡單. 其實檔案系統也這麼簡單, 複雜的點是讀取和寫入硬碟.

寫入檔案的 cache 實現: IdleFileCachePlugin

現在我們來看寫入硬碟的實現: IdleFileCachePlugin.

先看getstore方法, 其實就是呼叫了strategy.storestrategy.restore. 只是多寫幾行程式碼來保證所有的寫操作都做完再讀.

除此之外, 還在 beginIdle 和 shutdown 的時候呼叫了strategy.afterAllStored來持久化 cache.

PackFileCacheStrategy 主要功能

進入到strategy, 我們關注store, restore, 和afterAllStored方法.

先看store, 和restore方法, 透過_getPack()獲取到從硬碟讀取的結構化資料pack, 分別呼叫packget()方法和set()方法.

afterAllStored的作用是把資料持久化到硬碟. 第一步也是獲取記憶體裡的pack資料, 再經過一定處理來寫到硬碟中.

經過觀察可以看到, _openPack()的讀取硬碟, 和afterAllStored()的寫入檔案, 都是透過fileSerializer()來進行的.

cache 在記憶體, 與檔案系統的最大區別, 其實就在於持久化的過程, 對於 cache 的讀取和寫入都是差不多的.

而下面要說的fileSerializer做的事, 就把記憶體中的格式化資料向硬碟讀寫, 並且儘量最佳化減少寫入的體積.

整理資料與寫入和讀取檔案的 Serializer

這一節是最複雜的, 主要研究物件是fileSerializer的2個方法serialize()deserialize().

並且最佳化邏輯是和上面提到的pack和相關實體的資料結構緊密相關的.

首先看fileSerializer以 middleware 的形式來分程式碼職責, 執行fileSerializerserialize()或deserialize()的時候, 會輪流執行各個 middleware 的對應的serialize()或deserialize()方法.

構造時候的 middleware 有:

  1. SingleItemMiddleware: 轉化陣列/單個元素的, 我感覺就沒啥用, 沒體會到意義.
  2. ObjectMiddleware: 在序列化的時候, 呼叫目標資料自己的函式, 進行資料整理.
  3. binaryMiddleware: 序列化/反序列化成二進位制.
  4. fileMiddleware: 讀取/寫入硬碟.

下面展開講一下我關注的ObjectMiddleware.

ObjectMiddlewarepack的讀取/寫入最佳化

先來看ObjectMiddlewareserialize()deserialize()方法.

他們的模式其實是一樣的: 構造一個上下文ctx來給序列化/反序列化的資料對應的方法呼叫.

其實 s/ds 的直接目標都是PackContainer物件, 所以會在 s/ds 的過程中呼叫PackContainer的 s/ds 方法.

ctx中提供的write, read方法可以操作正在被ObjectMiddleware處理的資料, 從而影響ObjectMiddleware的處理結果.

另外可以看到PackContainerwriteLazy的目標是this.data, 也就是pack物件, 並且write()會觸發pack物件的 s/ds 方法.

經過debug, PackContainer裡的內容其實是差不多的, 所以核心內容就是pack的 s/ds 方法了.

pack 的資料結構與最佳化

這是最後一部分, 但比較複雜, 我只有能力簡單的說一下.

首先說幾個 pack 的關鍵屬性:

  • content: 他是真正存放內容的地方. 但奇怪的他不是一個 map, 而是一個陣列.

用意是陣列的每個元素最後會被寫成單獨的檔案, 透過一些最佳化, 每次改動可以只寫有改動的 cache 所對應的檔案

  • itemInfo: 這個是儲存資料關係的地方.

他的鍵是 id, packget(), set() 的第一步都是先從itemInfo中透過 id 找到對應的資訊.

他的值是對應的資訊, 資訊內容有: etag 對比 hash; location 儲存資訊在 content 陣列的哪個位置; lastAccess 每次 get 會更新值, 在垃圾回收的時候配合 maxAge 決定是否清理; freshValue 如果不儲存在 content 中, 他是一個剛被建立的內容, 值就存在這裡, 相對的, location 有值的時候這裡是沒值的.

  • invalid: 如果pack完全沒動, 這個變數可以快速判斷. 第一次 set() 操作就會把他置為 true.

如果熟悉了這些屬性, 那麼packset()get()方法就非常好理解了.

最後, packserialize()的方法中進行了垃圾回收的操作,

就結果而言, 就是合理地對pack的資料結構進行一些更新. (主要就是 content 和 itemInfo, lazy, outdated 判斷和變更)

本文轉載於:https://juejin.cn/post/7339155841181515802

如果對您有所幫助,歡迎您點個關注,我會定時更新技術文件,大家一起討論學習,一起進步。

記錄--前端起dev從110秒減少到7秒, 開發體驗大幅提升

相關文章