加速 Webpack

發表於2018-01-11

Web 應用日益複雜,相關開發技術也百花齊放,這對前端構建工具提出了更高的要求。 Webpack 從眾多構建工具中脫穎而出成為目前最流行的構建工具,幾乎成為目前前端開發裡的必備工具之一。 大多數人在使用 Webpack 的過程中都會遇到構建速度慢的問題,在專案大時顯得尤為突出,這極大的影響了我們的開發體驗,降低了我們的開發效率。

本文將傳授你一些加速 Webpack 構建的技巧,下面來一一介紹。

通過多程式並行處理

由於有大量檔案需要解析和處理,構建是檔案讀寫和計算密集型的操作,特別是當檔案數量變多後,Webpack 構建慢的問題會顯得嚴重。 執行在 Node.js 之上的 Webpack 是單執行緒模型的,也就是說 Webpack 需要處理的任務需要一件件挨著做,不能多個事情一起做。

檔案讀寫和計算操作是無法避免的,那能不能讓 Webpack 同一時刻處理多個任務,發揮多核 CPU 電腦的威力,以提升構建速度呢?

使用 HappyPack

HappyPack 就能讓 Webpack 做到上面丟擲的問題,它把任務分解給多個子程式去併發的執行,子程式處理完後再把結果傳送給主程式。

接入 HappyPack 的相關程式碼如下:

以上程式碼有兩點重要的修改:

  • 在 Loader 配置中,所有檔案的處理都交給了 happypack/loader 去處理,使用緊跟其後的 querystring ?id=babel 去告訴 happypack/loader 去選擇哪個 HappyPack 例項去處理檔案。
  • 在 Plugin 配置中,新增了兩個 HappyPack 例項分別用於告訴 happypack/loader 去如何處理 .js 和 .css 檔案。選項中的 id 屬性的值和上面 querystring 中的 ?id=babel 相對應,選項中的 loaders 屬性和 Loader 配置中一樣。

接入 HappyPack 後,你需要給專案安裝新的依賴:

npm i -D happypack

安裝成功後重新執行構建,你就會看到以下由 HappyPack 輸出的日誌:

Happy[babel]: Version: 4.0.0-beta.5. Threads: 3
Happy[babel]: All set; signaling webpack to proceed.Happy[css]: Version: 4.0.0-beta.5. Threads: 3Happy[css]: All set; signaling webpack to proceed.

說明你的 HappyPack 配置生效了,並且可以得知 HappyPack 分別啟動了3個子程式去並行的處理任務。

在整個 Webpack 構建流程中,最耗時的流程可能就是 Loader 對檔案的轉換操作了,因為要轉換的檔案資料巨多,而且這些轉換操作都只能一個個挨著處理。 HappyPack 的核心原理就是把這部分任務分解到多個程式去並行處理,從而減少了總的構建時間。

從前面的使用中可以看出所有需要通過 Loader 處理的檔案都先交給了 happypack/loader 去處理,收集到了這些檔案的處理權後 HappyPack 就好統一分配了。

每通過 new HappyPack() 例項化一個 HappyPack 其實就是告訴 HappyPack 核心排程器如何通過一系列 Loader 去轉換一類檔案,並且可以指定如何給這類轉換操作分配子程式。

核心排程器的邏輯程式碼在主程式中,也就是執行著 Webpack 的程式中,核心排程器會把一個個任務分配給當前空閒的子程式,子程式處理完畢後把結果傳送給核心排程器,它們之間的資料交換是通過程式間通訊 API 實現的。

核心排程器收到來自子程式處理完畢的結果後會通知 Webpack 該檔案處理完畢。

使用 ParallelUglifyPlugin

在使用 Webpack 構建出用於釋出到線上的程式碼時,都會有壓縮程式碼這一流程。 最常見的 JavaScript 程式碼壓縮工具是 UglifyJS,並且 Webpack 也內建了它。

用過 UglifyJS 的你一定會發現在構建用於開發環境的程式碼時很快就能完成,但在構建用於線上的程式碼時構建一直卡在一個時間點遲遲沒有反應,其實卡住的這個時候就是在進行程式碼壓縮。

由於壓縮 JavaScript 程式碼需要先把程式碼解析成用 Object 抽象表示的 AST 語法樹,再去應用各種規則分析和處理 AST,導致這個過程計算量巨大,耗時非常多。

為什麼不把多程式並行處理的思想也引入到程式碼壓縮中呢?

ParallelUglifyPlugin 就做了這個事情。 當 Webpack 有多個 JavaScript 檔案需要輸出和壓縮時,原本會使用 UglifyJS 去一個個挨著壓縮再輸出, 但是 ParallelUglifyPlugin 則會開啟多個子程式,把對多個檔案的壓縮工作分配給多個子程式去完成,每個子程式其實還是通過 UglifyJS 去壓縮程式碼,但是變成了並行執行。 所以 ParallelUglifyPlugin 能更快的完成對多個檔案的壓縮工作。

使用 ParallelUglifyPlugin 也非常簡單,把原來 Webpack 配置檔案中內建的 UglifyJsPlugin 去掉後,再替換成 ParallelUglifyPlugin,相關程式碼如下:

接入 ParallelUglifyPlugin 後,專案需要安裝新的依賴:

npm i -D webpack-parallel-uglify-plugin

安裝成功後,重新執行構建你會發現速度變快了許多。如果設定 cacheDir 開啟了快取,在之後的構建中會變的更快。

縮小檔案搜尋範圍

Webpack 啟動後會從配置的 Entry 出發,解析出檔案中的匯入語句,再遞迴的解析。 在遇到匯入語句時 Webpack 會做兩件事情:

  1. 根據匯入語句去尋找對應的要匯入的檔案。例如 require(‘react’) 匯入語句對應的檔案是 ./node_modules/react/react.js,而require(‘./util’)匯入語句 對應的檔案是 ./util.js。
  2. 根據找到的要匯入檔案的字尾,使用配置中的 Loader 去處理檔案。例如使用 ES6 開發的 JavaScript 檔案需要使用 babel-loader 去處理。

以上兩件事情雖然對於處理一個檔案非常快,但是當專案大了以後檔案量會變的非常多,這時候構建速度慢的問題就會暴露出來。 雖然以上兩件事情無法避免,但需要儘量減少以上兩件事情的發生,以提高速度。

接下來一一介紹可以優化它們的途徑。

縮小 resolve.modules 的範圍

Webpack的resolve.modules 用於配置 Webpack 去哪些目錄下尋找第三方模組。

resolve.modules 的預設值是 [‘node_modules’],含義是先去當前目錄下的 ./node_modules 目錄下去找想找的模組,如果沒找到就去上一級目錄 ../node_modules 中找,再沒有就去 ../../node_modules 中找,以此類推,這和 Node.js 的模組尋找機制很相似。

當安裝的第三方模組都放在專案根目錄下的 ./node_modules 目錄下時,沒有必要按照預設的方式去一層層的尋找,可以指明存放第三方模組的絕對路徑,以減少尋找,配置如下:

縮小 Loader 的命中範圍

除此之外在使用 Loader 時可以通過 test 、 include 、 exclude 三個配置項來命中 Loader 要應用規則的檔案。 為了儘可能少的讓檔案被 Loader 處理,可以通過 include 去命中只有哪些檔案需要被處理。

以採用 ES6 的專案為例,在配置 babel-loader 時,可以這樣:

你可以適當的調整專案的目錄結構,以方便在配置 Loader 時通過 include 去縮小命中範圍。

縮小 resolve.extensions 的數量

在匯入語句沒帶檔案字尾時,Webpack 會自動帶上字尾後去嘗試詢問檔案是否存在。 Webpack 配置中的 resolve.extensions 用於配置在嘗試過程中用到的字尾列表,預設是:

extensions: [‘.js’, ‘.json’]

也就是說當遇到 require(‘./data’) 這樣的匯入語句時,Webpack 會先去尋找 ./data.js 檔案,如果該檔案不存在就去尋找 ./data.json 檔案,如果還是找不到就報錯。

如果這個列表越長,或者正確的字尾在越後面,就會造成嘗試的次數越多,所以 resolve.extensions 的配置也會影響到構建的效能。 在配置 resolve.extensions 時你需要遵守以下幾點,以做到儘可能的優化構建效能:

  • 字尾嘗試列表要儘可能的小,不要把專案中不可能存在的情況寫到字尾嘗試列表中。
  • 頻率出現最高的檔案字尾要優先放在最前面,以做到儘快的退出尋找過程。
  • 在原始碼中寫匯入語句時,要儘可能的帶上字尾,從而可以避免尋找過程。例如在你確定的情況下把 require(‘./data’) 寫成 require(‘./data.json’)。

相關 Webpack 配置如下:

縮小 resolve.mainFields 的數量

Webpack 配置中的 resolve.mainFields 用於配置第三方模組使用哪個入口檔案。

安裝的第三方模組中都會有一個 package.json 檔案用於描述這個模組的屬性,其中有些欄位用於描述入口檔案在哪裡,resolve.mainFields 用於配置採用哪個欄位作為入口檔案的描述。

可以存在多個欄位描述入口檔案的原因是因為有些模組可以同時用在多個環境中,針對不同的執行環境需要使用不同的程式碼。 以 isomorphic-fetchfetch API 為例,它是 的一個實現,但可同時用於瀏覽器和 Node.js 環境。

為了減少搜尋步驟,在你明確第三方模組的入口檔案描述欄位時,你可以把它設定的儘量少。 由於大多數第三方模組都採用 main 欄位去描述入口檔案的位置,可以這樣配置 Webpack:

使用本方法優化時,你需要考慮到所有執行時依賴的第三方模組的入口檔案描述欄位,就算有一個模組搞錯了都可能會造成構建出的程式碼無法正常執行。

善用現存的檔案

通過 module.noParse 忽略檔案

Webpack 配置中的 module.noParse 配置項可以讓 Webpack 忽略對部分沒采用模組化的檔案的遞迴解析處理,這樣做的好處是能提高構建效能。 原因是一些庫,例如 jQuery 、ChartJS, 它們龐大又沒有采用模組化標準,讓 Webpack 去解析這些檔案耗時又沒有意義。

在上面的 優化 resolve.alias 配置 中講到單獨完整的 react.min.js 檔案就沒有采用模組化,讓我們來通過配置 module.noParse 忽略對 react.min.js 檔案的遞迴解析處理, 相關 Webpack 配置如下:

注意被忽略掉的檔案裡不應該包含 import 、 require 、 define 等模組化語句,不然會導致構建出的程式碼中包含無法在瀏覽器環境下執行的模組化語句。

通過 resolve.alias 對映檔案

Webpack 配置中的 resolve.alias 配置項通過別名來把原匯入路徑對映成一個新的匯入路徑。

在實戰專案中經常會依賴一些龐大的第三方模組,以 React 庫為例,庫中包含兩套程式碼:

  • 一套是採用 CommonJS 規範的模組化程式碼,這些檔案都放在 lib 目錄下,以 package.json 中指定的入口檔案 react.js 為模組的入口。
  • 一套是把 React 所有相關的程式碼打包好的完整程式碼放到一個單獨的檔案中,這些程式碼沒有采用模組化可以直接執行。其中 dist/react.js 是用於開發環境,裡面包含檢查和警告的程式碼。dist/react.min.js 是用於線上環境,被最小化了。

預設情況下 Webpack 會從入口檔案 ./node_modules/react/react.js 開始遞迴的解析和處理依賴的幾十個檔案,這會時一個耗時的操作。 通過配置 resolve.alias 可以讓 Webpack 在處理 React 庫時,直接使用單獨完整的 react.min.js 檔案,從而跳過耗時的遞迴解析操作。

相關 Webpack 配置如下:

除了 React 庫外,大多數庫釋出到 Npm 倉庫中時都會包含打包好的完整檔案,對於這些庫你也可以對它們配置 alias。

但是對於有些庫使用本優化方法後會影響到後面要講的使用 Tree-Shaking 去除無效程式碼的優化,因為打包好的完整檔案中有部分程式碼你的專案可能永遠用不上。 一般對整體性比較強的庫採用本方法優化,因為完整檔案中的程式碼是一個整體,每一行都是不可或缺的。 但是對於一些工具類的庫,例如 lodash,你的專案可能只用到了其中幾個工具函式,你就不能使用本方法去優化,因為這會導致你的輸出程式碼中包含很多永遠不會執行的程式碼。

使用 DllPlugin

在介紹 DllPlugin 前先給大家介紹下 DLL。 用過 Windows 系統的人應該會經常看到以 .dll 為字尾的檔案,這些檔案稱為動態連結庫,在一個動態連結庫中可以包含給其他模組呼叫的函式和資料。

要給 Web 專案構建接入動態連結庫的思想,需要完成以下事情:

  • 把網頁依賴的基礎模組抽離出來,打包到一個個單獨的動態連結庫中去。一個動態連結庫中可以包含多個模組。
  • 當需要匯入的模組存在於某個動態連結庫中時,這個模組不能再次被打包,而是去動態連結庫中獲取。
  • 頁面依賴的所有動態連結庫需要被載入。

為什麼給 Web 專案構建接入動態連結庫的思想後,會大大提升構建速度呢? 原因在於包含大量複用模組的動態連結庫只需要編譯一次,在之後的構建過程中被動態連結庫包含的模組將不會在重新編譯,而是直接使用動態連結庫中的程式碼。 由於動態連結庫中大多數包含的是常用的第三方模組,例如 react、react-dom,只要不升級這些模組的版本,動態連結庫就不用重新編譯。

接入 Webpack

Webpack 已經內建了對動態連結庫的支援,需要通過2個內建的外掛接入,它們分別是:

  • DllPlugin 外掛:用於打包出一個個單獨的動態連結庫檔案。
  • DllReferencePlugin 外掛:用於在主要配置檔案中去引入 DllPlugin 外掛打包好的動態連結庫檔案。

下面以基本的 React 專案為例,為其接入 DllPlugin,在開始前先來看下最終構建出的目錄結構:

├── main.js
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json

其中包含兩個動態連結庫檔案,分別是:

  • polyfill.dll.js 裡面包含專案所有依賴的 polyfill,例如 Promise、fetch 等 API。
  • react.dll.js 裡面包含 React 的基礎執行環境,也就是 react 和 react-dom 模組。

以 react.dll.js 檔案為例,其檔案內容大致如下:

可見一個動態連結庫檔案中包含了大量模組的程式碼,這些模組存放在一個陣列裡,用陣列的索引號作為 ID。 並且還通過 _dll_react 變數把自己暴露在了全域性中,也就是可以通過 window._dll_react 可以訪問到它裡面包含的模組。

其中 polyfill.manifest.json 和 react.manifest.json 檔案也是由 DllPlugin 生成,用於描述動態連結庫檔案中包含哪些模組, 以 react.manifest.json 檔案為例,其檔案內容大致如下:

可見 manifest.json 檔案清楚地描述了與其對應的 dll.js 檔案中包含了哪些模組,以及每個模組的路徑和 ID。

main.js 檔案是編譯出來的執行入口檔案,當遇到其依賴的模組在 dll.js 檔案中時,會直接通過 dll.js 檔案暴露出的全域性變數去獲取打包在 dll.js 檔案的模組。 所以在 index.html 檔案中需要把依賴的兩個 dll.js 檔案給載入進去,index.html 內容如下:

 

以上就是所有接入 DllPlugin 後最終編譯出來的程式碼,接下來教你如何實現。

構建出動態連結庫檔案

構建輸出的以下這四個檔案

├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json

和以下這一個檔案

├── main.js

是由兩份不同的構建分別輸出的。

與動態連結庫相關的檔案需要由一個獨立的構建輸出,用於給主構建使用。新建一個 Webpack 配置檔案 webpack_dll.config.js 專門用於構建它們,檔案內容如下:

使用動態連結庫檔案

構建出的動態連結庫檔案用於在其它地方使用,在這裡也就是給執行入口使用。

用於輸出 main.js 的主 Webpack 配置檔案內容如下:

注意:在 webpack_dll.config.js 檔案中,DllPlugin 中的 name 引數必須和 output.library 中保持一致。 原因在於 DllPlugin 中的 name 引數會影響輸出的 manifest.json 檔案中 name 欄位的值, 而在 webpack.config.js 檔案中 DllReferencePlugin 會去 manifest.json 檔案讀取 name 欄位的值, 把值的內容作為在從全域性變數中獲取動態連結庫中內容時的全域性變數名。

執行構建

在修改好以上兩個 Webpack 配置檔案後,需要重新執行構建。 重新執行構建時要注意的是需要先把動態連結庫相關的檔案編譯出來,因為主 Webpack 配置檔案中定義的 DllReferencePlugin 依賴這些檔案。

執行構建時流程如下:

  1. 如果動態連結庫相關的檔案還沒有編譯出來,就需要先把它們編譯出來。方法是執行 webpack –config webpack_dll.config.js 命令。
  2. 在確保動態連結庫存在的前提下,才能正常的編譯出入口執行檔案。方法是執行 webpack 命令。這時你會發現構建速度有了非常大的提升。

相信給你的專案加上以上優化方法後,構建速度會大大提高,趕快去試試把!

參考資源


相關文章