Webpack編譯速度優化實戰
當你的應用的規模還很小時,你可能不會在乎Webpack的編譯速度,無論使用3.X還是4.X版本,它都足夠快,或者說至少沒讓你等得不耐煩。但隨著業務的增多,嗖嗖嗖一下專案就有上百個元件了,也是件很簡單的事情。這時候當你再獨立編前端模組的生產包時,或者CI工具中編整個專案的包時,如果Webpackp配置沒經過優化,那編譯速度都會慢得一塌糊塗。編譯耗時10多秒鐘的和編譯耗時一兩分鐘的體驗是迥然不同的。出於開發時的心情的考慮,加上不能讓我們前端的程式碼編譯拖累整個CI的速度這兩個出發點,迫使我們必須去加快編譯速度。本文主要是探討下可做編譯速度優化的地方,對一些API使用上不會做太多講解,需要的同學可以直接翻看文件中的介紹。筆者的Webpack版本為4.29.6,後文中內容都基於這個版本。
一、已存在的針對編譯速度的優化
筆者這套Webpack架子源自CRA的eject,基於Webpack4.x,在Loader和Plugin的選擇和設計上已是最佳實踐方案,基本上無需改動什麼。其原有的對編譯的優化配置在於這三處:
1. 通過terser-webpack-plugin的parallel和cache配置來並行處理並快取之前的編譯結果。terser-webpack-plugin是之前UglifyPlugin的一個替代品,因為UglifyPlugin已經沒有繼續維護了,從Webpack4.x起,已經推薦使用terser-webpack-plugin來進行程式碼壓縮、混淆,以及Dead Code Elimination以實現Tree Shaking。對於parallel從整個設定的名稱大家就會知道它有什麼用,沒錯,就是並行,而cache也就是快取該外掛的處理結果,在下一次的編譯中對於內容未改變的檔案可以直接複用上一次編譯的結果。
2. 通過babel-loader的cache配置來快取babel的編譯結果。
3. 通過IgnorePlugin設定對moment的整個locale本地化資料夾匯入的正則匹配,來防止將所有的本地化檔案進行打包。如果你確實需要某國語言,僅手動匯入那國的語言包即可。
在專案逐漸變大的過程中,生產包的編譯時間也從十幾秒增長到了一分多鐘,這是讓人受不了的,這就迫使著筆者必須進行額外的優化以加快編譯速度,為編包節省時間。下面的段落就講解下筆者做的幾個額外優化。
二、多執行緒(程序)支援
從上個段落的terser-webpack-plugin的parallel設定中,我們可以得到這個啟發:啟用多程序來模擬多執行緒,並行處理資源的編譯。於是筆者引入了HappyPack,筆者之前的那套老架子也用了它,但之前沒寫東西來介紹那套架子,這裡就一併說了。關於HappyPack,經常玩Webpack的同學應該不會陌生,網上也有一些關於其原理的介紹文章,也寫得很不錯。HappyPack的工作原理大致就是在Webpack和Loader之間多加了一層,改成了Webpack並不是直接去和某個Loader進行工作,而是Webpack test到了需要編譯的某個型別的資源模組後,將該資源的處理任務交給了HappyPack,再由HappyPack再起內部進行執行緒排程,分配一個執行緒呼叫處理該型別資源的Loader來處理這個資源,完成後上報處理結果,最後HappyPack把處理結果返回給Webpack,最後由Webpack輸出到目的路徑。將都在一個執行緒內的工作,分配到了不同的執行緒中並行處理。
使用方法如下:
首先引入HappyPack並建立執行緒池:
const HappyPack = require('happypack'); const happyThreadPool = HappyPack.ThreadPool({size: require('os').cpus().length - 1});
替換之前的Loader為HappyPack的外掛:
{ test: /\.(js|mjs|jsx|ts|tsx)$/, include: paths.appSrc, use: ['happypack/loader?id=babel-application-js'], },
將原Loader中的配置,移動到對應外掛中:
new HappyPack({ id: 'babel-application-js', threadPool: happyThreadPool, verbose: true, loaders: [ { loader: require.resolve('babel-loader'), options: { ...省略 }, }, ], }),
大致使用方式如上所示,HappyPack的配置講解文章有很多,不會配的同學可以自己搜尋,本文這裡只是順帶說說而已。
HappyPack老早也沒有維護了,它對url-loader的處理是有問題的,會導致經過url-loader處理的圖片都無效,筆者之前也去提過一個Issue,有別的開發者也發現過這個問題。總之,用的時候一定要測試一下。
對於多執行緒的優勢,我們舉個例子:
比如我們有四個任務,命名為A、B、C、D。
任務A:耗時5秒
任務B:耗時7秒
任務C:耗時4秒
任務D:耗時6秒
單執行緒序列處理的總耗時大約在22秒。
改成多執行緒並行處理後,總耗時大約在7秒,也就是那個最耗時的任務B的執行時長,僅僅通過配置多執行緒處理我們就能得到大幅的編譯速度提升。
寫到這裡,大家是不是覺得編譯速度優化就可以到此結束了?哈哈,當然不是,上面這個例子在實際的專案中根本不具有廣泛的代表性,筆者實際專案的情況是這樣的:
我們有四個任務,命名為A、B、C、D。
任務A:耗時5秒
任務B:耗時60秒
任務C:耗時4秒
任務D:耗時6秒
單執行緒序列處理的總耗時大約在75秒。
改成多執行緒並行處理後,總耗時大約在60秒,從75秒優化到60秒,確實有速度上的提升,但是因為任務B的耗時太長了,導致整個專案的編譯速度並沒有發生本質上的變化。事實上筆者之前那套Webpack3.X的架子就是因為這個問題導致編譯速度慢,所以,只靠引入多執行緒就想解決大專案編譯速度慢的問題是不現實的。
那我們還有什麼辦法嗎?當然有,我們還是可以從TerserPlugin得到靈感,那就是依靠快取:在下一次的編譯中能夠複用上一次的結果而不執行編譯永遠是最快的。
至少存在有這三種方式,可以讓我們在執行構建時不進行某些檔案的編譯,從最本質上提升前端專案整體的構建速度:
1. 類似於terser-webpack-plugin的cache那種方式,這個外掛的cache預設生成在node_modules/.cache/terser-plugin檔案下,通過SHA或者base64編碼之前的檔案處理結果,並儲存檔案對映關係,方便下一次處理檔案時可以檢視之前同文件(同內容)是否有可用快取。其他Webpack平臺的工具也有類似功能,但快取方式不一定相同。
2. 通過externals配置在編譯的時候直接忽略掉外部庫的依賴,不對它們進行編譯,而是在執行的時候,通過<script>標籤直接從CDN伺服器下載這些庫的生產環境檔案。
3. 將某些可以庫檔案編譯以後儲存起來,每次編譯的時候直接跳過它們,但在最終編譯後的程式碼中能夠引用到它們,這就是Webpack DLLPlugin所做的工作,DLL借鑑至Windows動態連結庫的概念。
後面的段落將針對這幾種方式做講解。
三、Loader的Cache
除了段落一中提到的terser-webpack-plugin和babel-loader支援cache外,Webpack還直接另外提供了一種可以用來快取前序Loader處理結果的Loader,它就是cache-loader。通常我們可以將耗時的Loader都通過cache-laoder來快取編譯結果。比如我們打生產環境的包,對於Less檔案的快取你可以這樣使用它:
{ test: /\.less$/, use: [ { loader: MiniCssExtractPlugin.loader, options: { ...省略 }, }, { loader: 'cache-loader', options: { cacheDirectory: paths.appPackCacheCSS, } }, { loader: require.resolve('css-loader'), options: { ...省略 }, }, { loader: require.resolve('postcss-loader'), options: { ...省略 } } ] }
Loader的執行順序是從下至上,因此通過上述配置,我們可以通過cache-laoder快取postcss-loader和css-loader的編譯結果。
但我們不能用cache-loader去快取mini-css-extract-plugin的結果,因為它的作用是要從前序Loader編譯成的含有樣式字串的JS檔案中把樣式字串單獨抽出來打成獨立的CSS檔案,而快取這些獨立CSS檔案並不是cache-loader的工作。
但如果是要快取開發環境的Less編譯結果,cache-loader可以快取style-loader的結果,因為style-loader並沒有從JS檔案中單獨抽出樣式程式碼,只是在編譯後的程式碼中添加了一些額外程式碼,讓編譯後的程式碼在執行時,能夠建立包含樣式的<style>標籤並放入<head>標籤內,這樣的效能不是太好,所以基本上只有開發環境採用這種方式。
在對樣式檔案配置cache-loader的時候,一定要記住上述這兩點,要不然會出現樣式無法正確編譯的問題。
除了對樣式檔案的編譯結果進行快取外,對其他型別的檔案(除了會打包成獨立的檔案外)的編譯結果進行快取也是可以的。比如url-laoder,只要大小沒有達到limitation的圖片都會被打成base64,大於limitation的檔案會打成單獨的圖片類檔案,就不能被cache-loader快取了,如果遇到了這種情況,資源請求會404,這是在使用cache-loader時需要注意的。
當然,通過使用快取能得到顯著編譯速度提升的,依舊是那些耗時的Loader,如果對某些型別的檔案編譯並不耗時,或者說檔案本身數量太少,都可以先不必做快取,因為即便做了快取,編譯速度的提升也不明顯。
最後筆者將所有Loader和Plugin的cache預設目錄從node_modules/.cache/移到了專案根目錄的build_pack_cache/目錄(生產環境)和dev_pack_cache目錄(開發環境),通過NODE_ENV自動區分。這麼做是因為筆者的CI工程每次會刪除之前的node_modules資料夾,並從node_modules.tar.gz解壓一個新的node_modules資料夾,所以將快取放在node_modules/.cache/目錄裡面會無效,筆者也不想去動CI的程式碼。通過這個改動,對cache檔案的管理更直觀一些,也能避免node_modules的體積一直增大。如果想清除快取,直接刪掉對應目錄即可。當然了,這兩個個目錄是不需要被Git跟蹤的,所以需要在.gitignore中新增上。CI環境中如果沒有對應的快取目錄,相關Loader會自動建立。而且,因為開發環境和生產環境編譯出的資源是不同的,在開發環境下對資源的編譯往往都沒有做壓縮和混淆處理等,為了有效地快取不同環境下的編譯結果,需要區分開快取目錄。
四、外部擴充套件externals
按照Webpack官方的說法:我們的專案如果想用一個庫,但我們又不想Webpack對它進行編譯(因為它的原始碼很可能已是經過編譯和優化的生產包,可以直接使用)。並且我們可能通過window全域性方式來訪問它,或者通過各種模組化的方式來訪問它,那麼我們就可以把它配置進extenals裡。
比如我要使用jquery可以這樣配置:
externals: { jquery: 'jQuery' }
我就可以這樣使用了,就像我們直接引入一個在node_modules中的包一樣:
import $ from 'jquery'; $('.div').hide();
這樣做能有效的前提就是我們在HTML檔案中在上述程式碼執行以前就已經通過了<script>標籤從CDN下載了我們需要的依賴庫了,externals配置會自動在承載我們應用的html檔案中加入:
<script src="https://code.jquery.com/jquery-1.1.14.js">
externals還支援其他靈活的配置語法,比如我只想訪問庫中的某些方法,我們甚至可以把這些方法附加到window物件上:
externals : { subtract : { root: ["math", "subtract"] } }
我就可以通過 window.math.subtract 來訪問subtract方法了。
對於其他配置方式如果有興趣的話可以自行檢視文件。
但是,筆者的專案並沒有這麼做,因為在它最終交付給客戶後,應該是處於一個內網環境(或者一個被防火牆嚴重限制的環境)中,極大可能無法訪問任何網際網路資源,因此通過<script>指令碼請求CDN資源的方式將失效,前置依賴無法正常下載就會導致整個應用奔潰。
五、DllPlugin
在上個段落中的結尾處,提到了筆者的專案在交付使用者後會面臨的網路困境,所以筆者必須選擇另外一個方式來實現類似於externals配置能夠提供的功能。那就是Webpack DLLPlugin以及它的好搭檔DLLReferencePlugin。筆者有關DLLPlugin的使用都是在構建生產包的時候使用。
要使用DLLPlugiin,我們需要單獨開一個webpack配置,暫且將其命名為webpack.dll.config.js,以便和主Webpack的配置檔案webpack.config.js進行區分。內容如下:
'use strict';
process.env.NODE_ENV = 'production'; const webpack = require('webpack'); const path = require('path'); const {dll} = require('./dll'); const DllPlugin = require('webpack/lib/DllPlugin'); const TerserPlugin = require('terser-webpack-plugin'); const getClientEnvironment = require('./env'); const paths = require('./paths'); const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false'; module.exports = function (webpackEnv = 'production') { const isEnvDevelopment = webpackEnv === 'development'; const isEnvProduction = webpackEnv === 'production'; const publicPath = isEnvProduction ? paths.servedPath : isEnvDevelopment && '/'; const publicUrl = isEnvProduction ? publicPath.slice(0, -1) : isEnvDevelopment && ''; const env = getClientEnvironment(publicUrl); return { mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development', devtool: isEnvProduction ? 'source-map' : isEnvDevelopment && 'cheap-module-source-map', entry: dll, output: { path: isEnvProduction ? paths.appBuildDll : undefined, filename: '[name].dll.js', library: '[name]_dll_[hash]' }, optimization: { minimize: isEnvProduction, minimizer: [ ...省略 ] }, plugins: [ new webpack.DefinePlugin(env.stringified), new DllPlugin({ context: path.resolve(__dirname), path: path.resolve(paths.appBuildDll, '[name].manifest.json'), name: '[name]_dll_[hash]', }), ], }; };
為了方便DLL的管理,我們還單獨開了個dll.js檔案來管理webpack.dll.config.js的入口entry,我們把所有需要DLLPlugin處理的庫都記錄在這個檔案中:
const dll = { core: [ 'react', '@hot-loader/react-dom', 'react-router-dom', 'prop-types', 'antd/lib/badge', 'antd/lib/button', 'antd/lib/checkbox', 'antd/lib/col', ...省略 ], tool: [ 'js-cookie', 'crypto-js/md5', 'ramda/src/curry', 'ramda/src/equals', ], shim: [ 'whatwg-fetch', 'ric-shim' ], widget: [ 'cecharts', ], }; module.exports = { dll, dllNames: Object.keys(dll), };
對於要把哪些庫放入DLL中,請根據自己專案的情況來定,對於一些特別大的庫,又沒法做模組分割和不支援Tree Shaking的,比如Echarts,建議先去官網按專案所需功能定製一套,不要直接使用整個Echarts庫,否則會白白消耗許多的下載時間,JS預處理的時間也會增長,減弱首屏效能。
然後我們在webpack.config.js的plugins配置中加入DLLReferencePlguin來對DLLPlugin處理的庫進行對映,好讓編譯後的程式碼能夠從window物件中找到它們所依賴的庫:
{ ...省略 plugins: [ ...省略 // 這裡的...用於延展開陣列,因為我們的DLL有多個,每個單獨的DLL輸出都需要有一個DLLReferencePlguin與之對應,去獲取DLLPlugin輸出的manifest.json庫對映檔案。
// dev環境下暫不採用DLLPlugin優化。 ...(isEnvProduction ? dllNames.map(dllName => new DllReferencePlugin({ context: path.resolve(__dirname), manifest: path.resolve(__dirname, '..', `build/static/dll/${dllName}.manifest.json`) })) : [] ), ...省略 ] ... }
我們還需要在承載我們應用的index.html模板中加入<script>,從webpack.dll.config.js裡配置的output輸出資料夾中前置引用這些DLL庫。對於這個工作DLLPlguin和它的搭檔不會幫我們做這件事情,而已有的html-webpack-plugin也不能幫助我們去做這件事情,因為我們沒法通過它往index.html模板加入特定內容,但它有個增強版的兄弟script-ext-html-webpack-plugin可以幫我們做這件事情,筆者之前也用過這個外掛內聯JS到index.html中。但筆者懶得再往node_modules中加依賴包了,另闢了一個蹊徑:
CRA這套架子已經使用了DefinePlugin來在編譯時建立全域性變數,最常用的就是建立process環境變數,讓我們的程式碼可以分辨是開發還是生產環境,既然已有這樣的設計,何不繼續使用,讓DLLPlugn編譯的獨立JS檔名暴露在某個全域性變數下,並在index.html模板中迴圈這個變數陣列,迴圈建立<script>標籤不就行了,在上面提到的dll.js檔案中最後匯出的 dllNames 就是這個陣列。
然後我們改造一下index.html:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> <% if (process.env.NODE_ENV === "production") { %> <% process.env.DLL_NAMES.forEach(function (dllName){ %> <script src="/static/dll/<%= dllName %>.dll.js"></script> <% }) %> <% } %> </head> <body> <noscript>Please allow your browser to run JavaScript scripts.</noscript> <div id="root"></div> </body> </html>
最後我們改造一下build.js指令碼,加入打包DLL的步驟:
function buildDll (previousFileSizes){ let allDllExisted = dllNames.every(dllName => fs.existsSync(path.resolve(paths.appBuildDll, `${dllName}.dll.js`)) && fs.existsSync(path.resolve(paths.appBuildDll, `${dllName}.manifest.json`)) ); if (allDllExisted){ console.log(chalk.cyan('Dll is already existed, will run production build directly...\n')); return Promise.resolve(); } else { console.log(chalk.cyan('Dll missing or incomplete, first starts compiling dll...\n')); const dllCompiler = webpack(dllConfig); return new Promise((resolve, reject) => { dllCompiler.run((err, stats) => { ...省略 }) }); } }
checkBrowsers(paths.appPath, isInteractive) .then(() => { // Start dll webpack build. return buildDll(); }) .then(() => { // First, read the current file sizes in build directory. // This lets us display how much they changed later. return measureFileSizesBeforeBuild(paths.appBuild); }) .then(previousFileSizes => { // Remove folders contains hash files, but leave static/dll folder. fs.emptyDirSync(paths.appBuildCSS); fs.emptyDirSync(paths.appBuildJS); fs.emptyDirSync(paths.appBuildMedia); // Merge with the public folder copyPublicFolder(); // Start the primary webpack build return build(previousFileSizes); }) .then(({stats, previousFileSizes, warnings}) => { ... 省略 }) ... 省略
大致邏輯就是如果xxx.dll.js檔案存在且對應的xxx.manifest.json也存在,那麼就不重新編譯DLL,如果缺失任意一個,就重新編譯。DLL的編譯過程走完後再進行主包的編譯。由於我們將DLLPlugin編譯出的檔案也放入build資料夾中,所以之前每次開始編譯主包都需要清空整個build資料夾的操作也修改為了僅僅清空除了放置有dll.js和manifest.json的目錄。
如果我們的底層依賴庫確實發生了變化,需要我們更新DLL,按照之前的檢測邏輯,我們只需要刪除整個某個dll.js檔案即可,或者直接刪除掉整個build資料夾。
哈哈,到此所有的有關DLL的配置就完成了,大功告成。
本段落開始時有提到過DLLPlugin的使用都是在生產環境下。因為開發環境下的打包情況很特殊而且複雜:
在開發環境下整個應用是通過webpack-dev-server來打包並起一個Express服務來serve的。Express服務在內部掛載了webpack-dev-middleware作為中介軟體,webpack-dev-middleware可以實現serve由Webpack compiler編譯出的所有資源、將所有的資源打入記憶體檔案系統中的功能,並能結合dev-sever實現監聽原始檔改動並提供HRM。webpack-dev-server接收了一個Webpack compiler和一個有關HTTP配置的config作為例項化時的引數。這個compiler會在webpack-dev-middleware中執行,用監聽方式啟動compiler後,compiler的outputFileSystem會被webpack-dev-middleware替換成記憶體檔案系統,其執行後打包出來的東西都沒有實際落盤,而是存放在了這個記憶體檔案系統中,而ouputFileSystem本身是在Node.js的fs模組基礎上封裝的。將編譯結果存放進記憶體中是webpack-dev-middleware內部最奇妙的地方。
當我們的在開發環境的前端介面中發起一個靜態資源請求,請求到達dev-server後,經過路由的判斷,這個靜態資源都會被重定向到記憶體檔案系統中去獲取資源,資源在記憶體中是二進位制格式,以返回流的形式來響應請求,並且在response的時候會為content-type加上對應的MIME型別,瀏覽器拿到資料流後再根據response header中content-type的值就能正確解析伺服器返回的資源了。事實上就算將檔案資源落盤,也必須先把檔案從磁碟讀到記憶體中,再以流的形式返回給客戶端,這樣一來會多一個從磁碟中將檔案讀進記憶體的步驟,反而還沒有直接操作記憶體快。記憶體檔案系統在npm start起得程序被幹掉後,就被回收了,下一次再起程序的時候,會建立一個全新的記憶體檔案系統。
所有,由於開發環境打包的特殊性,怎麼在開發環境使用DLLPlugin還需要再研究下。因此筆者只是在開發環境使用了多執行緒和各種快取。由於開發環境下,編譯的工作量少於生產環境,並且對所有資源的讀寫都是走記憶體的,因此速度很快,每次檢測到原始檔變動並進行重編譯的過程也會很快。所以開發環境的編譯速度在目前來看還可以接受,暫時不需要優化。
這裡順帶說一句,筆者之前在看有關DLLPlugin的文件和文章時,也注意到了一個現象,就是很多人都說DLLPlugin過時了,externals使用起來更方便,配置更簡單,甚至在CRA、vue-cli這些最佳實踐的腳手架中都已經沒再繼續使用DLLPlugin了,因為Webpack4.x的編譯速度已經足夠快了。筆者的體會就是:我這個專案就是基於Webpack4.X的,專案規模變大以後,沒有覺得4.X有多麼地快。筆者的專案在交付客戶後也極大可能不能訪問網際網路,所以externals配置對筆者的專案來說沒有用,只能通過使用DLLPlugin提高生產包的編譯速度。我想這也是為什麼Webpack到了4.X版本依然沒有去掉DLLPlugin的原因,因為不是所有的前端專案都一定是網際網路專案。還是那句話:實踐出真知,不要人云亦云。
六、編譯速度提升了多少?
筆者開發機4和8執行緒,單核基礎頻率2.2GHz。所有測試都基於已對Echarts進行功能定製。
1. 最原始的CRA腳手架編譯筆者這個專案的速度。
初次打包,無任何CRA原始配置的快取,這和最初在CI上進行構建的情況完全一樣,因為每次node_moduels都要刪除重來,無法快取任何結果:
大概1分10多秒。
有快取以後:
如果不定製Echarts的話,直接引入整個Echarts,在沒快取的時候大概會多5秒時間。
2. 引入dll後。
初次打包,並且無任何DLL檔案和CRA原始配置的快取:
先編譯DLL,再編譯主包,整個過程耗時直接變成了57秒。
有DLL檔案和快取後:
降到27秒多了。
3.最後我們把多執行緒和cache-loader上了:
無任何DLL檔案、CRA原始配置的快取以及cache-loader的快取時:
大概在接近60秒左右。
有DLL檔案和所有快取後:
最終,耗時已經下降至17秒左右了。在CI的伺服器上執行,速度還會更快些。
在打包速度上,比最原始的1分10多秒耗時已經有本質上的提升了。這才是我們想要的。
七、總結
至此,我們已經將所有底層依賴DLL化了,幾乎所有能做快取的東西都快取了,並支援多執行緒編譯。當專案穩定後,無論專案規模多大,小幅度的修改將始終保持在這個編譯速度,耗時不會有太大的變化。
據說在Webpack5.X帶來了全新的編譯效能體驗,很期待使用它的時候。談及到此,筆者只覺得有淡淡的憂傷,那就是前端技術、工具、框架、開發理念這些的更新速度實在是太快了。就拿Webpack這套構建平臺來說,當Webpack5.X普及後,Webpack4.X這套優化可能也就過時了,又需要重新學習