Webpack 4進階--從前的日色變得慢 ,一下午只夠打一次包

前端新能源發表於2018-04-08

從前的日色變得慢,車,馬,郵件都慢,一生只夠愛一個人 -- 《從前慢》

近期在團隊專案裡把Webpack升級到4.4.1,過程中發現現存的升級文件十分有限,踩了不少坑,好在升級之後提升還算顯著,production場景下第三方依賴打包速度提升76%,development場景下本地服務首次啟動提升效果約46%,再次啟動提升效果上升至63%。這裡將這次升級過程中的點滴分享出來,希望對大家有所幫助。

理論部分

Webpack 4 釋出之後,議論最多的兩大特性,其一是零配置,其二是速度快(號稱提速上限98%)。聽起來十分美妙,在實地測試之前,首先從理論上分析一下可能性。

零配置

一言以蔽之,約定優於配置。通過mode屬性將開發/生產(development/production)環境中常用的功能設定好預設值,使用者即來即用。

打包速度快

Optimization

Webpack 4取消了四個常用的用於效能優化的plugin(UglifyjsWebpackPlugin,CommonsChunkPlugin,ModuleConcatenationPlugin,NoEmitOnErrorsPlugin),轉而提供了一個名為optimization的配置項,用於接手以上四位的工作。

Webpack 4進階--從前的日色變得慢 ,一下午只夠打一次包
注:UglifyjsWebpackPlugin並不執行tree shaking操作,這裡為了介紹sideEffects,故而將關係緊密的兩者放在一起介紹了

  1. Tree Shaking & Minimize

廢棄外掛:UglifyjsWebpackPlugin

新增屬性:sideEffects,minimize等

  • Tree Shaking

Tree shaking一直是一個美麗而遙不可及的話題。影響tree shaking的根本原因在於side effects(副作用),其中最廣為人知的一條side effect就是動態引入依賴的問題。得益於ES6的模組化實現思路,所有的依賴必須位於檔案頂部,靜態引入(然而import()的出現打破了這個規則),Webpack可以在繪製依賴圖的時候進行靜態分析,從而將真正被引用的exports新增到bundle檔案中,減少打包體積。然而很多熱度較高的第三方庫為了考慮相容性往往採用UMD實現,而其所支援的動態引入依賴的功能則導致真實的依賴圖可能要到執行時才能確定,使得靜態分析難以發揮真正威力,tree shaking採用了保守策略,導致我們發現沒有被用到的方法依然出現在了bundle檔案中。

一個好訊息是許多第三方庫相繼推出了es版,配合tree-shaking食用,口感更佳,這也是官方號稱提速98%的重要前提之一(冷漠臉)。壞訊息是ES6其實也提供import()方法支援動態引入依賴,所以以下寫法其實也是完全行的通的。。。還記得那些年我們追過的沈佳宜說過的話麼,“人生本來就有很多事情是徒勞無功的啊”。

if(Math.random() > 0.5) {
    import('./a.js').then(() => {
        ...
    })
} else {
    import('./b.js').then(() => {
        ...
    })
}
複製程式碼

除此以外,為了防止使用者不小心修改輸出元素的屬性,有些庫會將最終的輸出元素用Object.freeze方法包裹起來,這也屬於side effects之一,同樣也會對tree shaking產生影響。

回到Webpack 4,官方提供了sideEffects屬性,通過將其設定為false,可以主動標識該類庫中的檔案只執行簡單輸出,並沒有執行其他操作,可以放心shaking。除了可以減小bundle檔案的體積,同時也能夠提升打包速度。為了檢查side effects,Webpack需要在打包的時候將所有的檔案執行一遍。而在設定sideEffects之後,則可以跳過執行那些未被引用的檔案,畢竟已經明確標識了“我是平民”。因此對於一些我們自己開發的庫,設定sideEffects為false大有裨益。

  • Minimize

Minimize屬性就沒啥可多說的了,混淆壓縮檔案。

  1. Scope hoisting

廢棄外掛:ModuleConcatenationPlugin

新增屬性:concatenateModules

//開啟前
[
    /* 0 */
    function(module, exports, require) {
        var module_a = require(1)
        console.log(module_a['default'])
    }
    
    /* 1 */
    function(module, exports, require) {
        exports['default'] = 'module A'
    }
]

//開啟後
[
    function(module, exports, require) {
        var module_a_defaultExport = 'module A'
        console.log(module_a_defaultExport)
    }
]
複製程式碼

concatenateModules開啟之後,可以看出bundle檔案中的函式宣告變少了,因而可以帶來的好處,其一,檔案的體積比之前更小了,其二,執行程式碼時建立的函式作用域變少了,開銷也隨之變少了。不過scope hoisting的效果同樣也依賴於靜態分析,無奈命不由我。

  1. Code splitting

廢棄外掛:CommonsChunkPlugin

新增屬性:splitChunks,runtimeChunk, occurrenceOrder等

  • splitChunks

splitChunks在Webpack 4裡被用於取代我們熟悉CommonsChunkPlugin。讀到這裡不知道你有沒有發現其中的端倪,這是否意味著DllPlugin和CommonsChunkPlugin(splitChunks)可以共存了呢?

在Webpack 4之前,兩者並不能一起使用,原因有二

  • 一個相對沒那麼重要的原因是DllPlugin服務的目標場景是develop環境,因為第三方依賴(輸出檔案暫稱為vendors)的變更頻率較低,故而在每次啟動本地服務或者rebuild的時候將第三方依賴重新打包一次實際上是一種浪費。通過DllPlugin,將第三方依賴的打包過程從業務程式碼的打包過程中獨立出來,可以大大縮短develop環境下的啟動時間。同時通過設定hash值,也可以充分的利用瀏覽器對這部分檔案的快取,提升載入效率。而在對載入效率更為苛刻的production環境,DllPlugin打包出的檔案則稍顯笨重,很多重複的內容被多次打包進了bundle檔案。在這種場景下,CommonsChunkPlugin被視為更好的選擇,因為我們不需要為打包時間操心過多,載入效率是我們唯一需要關注的內容。所以在webpack的開發者看來,這兩者如同“I have an apple,I have a pen,Ah~~ Apple pen”一樣,實際上並不存在什麼交集。
  • 因此也引出了二者不相容更為重要的第二個原因,沒人實現

這塊功能實際上通過CommonsChunkPlugin設定兩個entry point也可以實現,一個作為業務程式碼的入口,一個作為vendors的入口。不過存在兩個問題,第一個問題是,儘管vendors被單獨設定了entry point,但是在每次啟動本地服務的時候,儘管打包的結果不變,hash值不變,瀏覽器的快取檔案也被充分利用了,它的打包過程依然會執行,所以啟動時間並不會縮短,第二個問題是,許多人在使用CommonsChunkPlugin的時候並沒有注意到Webpack會將runtime一起打包進vendors檔案,所以每次啟動的時候,儘管你並沒有修改任何第三方依賴,但是vendors檔案的hash值卻變了,導致瀏覽器快取實際上並沒有被利用起來。要解決這個問題,需要配置CommonsChunkPlugin將runtime單獨打包成一個檔案。

然而到了Webpack 4,在CommonsChunkPlugin變成splitChunks之後,出於某些未知的原因,兩者相容性的問題被解決了。。。Happy coding。

  • runtimeChunk

runtimeChunk之所以被單獨設定為一個配置項,應該就是為了主動幫助使用者避免上文所述的問題吧。

  • occurrenceOrder

occurrenceOrder應用的場景是如果不手動設定chunk的名字,而採用預設值的話,Webpack將會用更短的名字去命名引用頻度更高的chunk。

  • noEmitOnErrors

廢棄外掛:NoEmitOnErrorsPlugin

新增屬性:noEmitOnErrors

noEmitOnErrors在編譯出現錯誤時,用來跳過輸出階段。

New Plugin

Webpack 4同時實現了一套新的plugin機制,與效能相關的改進點是消除了對arguments的濫用。如同我們推崇開發時定義型別,從而可以避免JIT過程中產生過多的過載函式,以及降低重新編譯的概率。

實踐部分

講了這麼多,最後分享一下我的實操經歷。Webpack 4為使用者描繪的場景固然美好,然而帶來便利的同時也給開發者留下了不少麻煩。首當其衝的就是相容性的問題,很多我們常用的loader,plugin尚未對這次升級做好準備,找到合適的替代工具以及積極改造自研的工具將成為升級過程中一場重要戰役。接下來我會針對在這次專案升級中我所遇到的相容性問題以及最終採用的解決方案做一個總結,常規的Webpack 4配置可以在官方demo 中找到答案。

  1. CommonsChunkPlugin + DllPlugin

Nothing special,主要還是一個分類問題,如何識別存在公共依賴的第三方依賴,並將其分配到不同的entry中。例如antd和react都依賴了react,則應該將兩者分配到不同的entry中。以及如何均勻的分配依賴到不同的entry中,使得打包之後的每個entry大小相近。可以說十分考驗一名配置工程師的功力和對原始碼庫的瞭解程度。

  1. Ts-loader 因為awesome-typescript-loader(ATL)還沒有合併支援Webpack 4的pr。所以ts-loader是ts愛好者們目前最好的選擇。曾經ATL之所以能夠戰勝ts-loader,成為不少人的選擇,原因有二,其一是ATL會新開一個獨立的程式執行型別檢查操作,因此不會影響編譯時間,其二是ts的編譯結果會被快取,rebuild場景下可以提速。目前ts-loader也已經支援這兩方面功能了,所以替換時並不需要擔心。
module: {
  rule: {
    test: /\.tsx?$/,
    use: [
      'cache-loader',
      {
        loader: 'thread-loader',
        options: {
          workers: require('os').cpus().length - 1,
        }
      },
      {
        loader: 'ts-loader',
        options: {
          happyPackMode: true,
          transpileOnly: true
        }
      }
    ]
  }
}

plugins: [
  new ForkTsCheckerWebpackPlugin()
]
複製程式碼
  • ForkTsCheckerWebpackPlugin用於新建程式執行型別檢查,為此你需要關閉ts-loader自身的型別檢查功能,即設定transpileOnly為true。
  • thread-loader允許新建一個worker程式去分擔一些昂貴的loader操作;cache-loader則可以將loader的執行結果快取在本地。然而兩者同時也會帶來額外的開銷(程式管理,I/O操作),自行評估後使用。
  1. MiniCssExtractPlugin 通過名字不難猜出它的功能,由於ExtractTextWebpackPlugin尚不支援Webpack 4,而且未來很可能被吸收為配置項,Mini-css-extract-plugin可以作為過渡期的一個選擇。除了常規的css抽取合併功能外,它還會在合併時清理重複的css副本,而這也是ExtractTextWebpackPlugin尚未實現的功能,所以理論上css的打包效果更優。
  2. InlineChunkWebpackPlugin(Webpack 4尚未支援) 雖然Webpack 4尚未支援這個外掛,但還是把它加在了這裡,只是因為它確實有用。上文說到通過配置runtimeChunk為true,可以將執行時打包成獨立的chunk,然而這個chunk體積很小,單獨佔用一個http請求稍顯浪費,inline顯然是更好的選擇。InlineChunkWebpackPlugin可以幫助我們將指定的chunk通過inline的形式寫入index.html檔案。在Webpack 4尚不支援的情況下,只好在http和ctrl + a&ctrl + c&ctrl + v中選擇一個更合適您口味的方法了。
  3. CleanWebpackPlugin 首先我要說明,這是一個玄學plugin,用或不用完全取決於臉黑不黑,手髒不髒。用處就是可以在打包前清理指定目錄的檔案,譬如說舊的bundle檔案。開始我也不信,後來的結果你們也看到了。

最後秀一下資料吧

在展示最終結果之前需要宣告的一點是,由於升級Webpack的同時,還解決了諸多相容性問題,所以最終結果的表現無論優劣,都不僅僅是Webpack的功過,loader以及plugin替換帶來的效能影響同樣不可忽略。至於如何到達提速98%,如果所有依賴全部更新成為es版本的話。。。

  1. DllPlugin + CommonsChunkPlugin對第三方依賴打包場景(production場景) Webpack 3.8.1的打包時長為57411ms,Webpack 4的打包時長為13959ms,提升效果約76%,詳情如下圖所示。

    webpack3.8.1
    webpack4.4.1

  2. 本地啟動(development場景) Webpack 3.8.1的啟動時長(僅包含業務程式碼打包過程)為42890ms,Webpack 4的首次啟動(cache檔案尚未產生)時長為23017ms,Webpack 4的再次啟動(cache檔案已經存在,並非watch模式下的rebuild場景)時長為15827ms,首次啟動提升效果約46%,再次啟動提升效果上升至63%,詳情如下圖所示。

    webpack3.8.1
    webpack4.4.1(首次啟動,無快取)
    webpack4.4.1(非首次啟動,有快取)

結束語

在不糾結究竟是Webpack還是替換loader&plugin的功勞,以及升級過程中遭遇的懵逼,躁鬱,崩潰的情況下,這次升級還是為專案帶來了正反饋。如果你也是一名追求極致開發體驗的配置工程師的話,這次Webpack升級還是值得嘗試的。最後希望文章中的內容能夠有所幫助。

相關文章