webpack構建和效能優化探索
前言
隨著業務複雜度的不斷的增加,工程模組的體積也會不斷增加,構建後的模組通常要以M為單位計算。在構建過程中,基於nodejs的webpack在單程序的情況下loader表現變得越來越慢,在不做任何特殊處理的情況下,構建完後的多專案之間公用基礎資源存在重複打包,基礎庫程式碼複用率也不高,這都慢慢暴露出webpack的問題。
正文
針對存在的問題,社群湧出了各種解決方案,包括webpack自身也在不斷優化。
構建優化
下面利用相關的方案對實際專案一步一步進行構建優化,提升我們的編譯速度,本次優化相關屬性如下:
- 機器: Macbook Air 四核 8G記憶體
- Webpack: v4.10.2
- 專案:922個模組
構建優化方案如下:
- 減少編譯體積大小
- 將大型庫外鏈
- 將庫預先編譯
- 使用快取
- 並行編譯
初始構建時間如下:
增量構建 | Development 構建 | Production 構建 | 備註 |
---|---|---|---|
3088ms | 43702ms | 89371ms |
減少編譯體積大小
初始構建時候,我們利用 webpack-bundle-analyzer
對編譯結果進行分析,結果如下:
可以看到,td-ui(類似於antd的ui元件庫)、moment庫的locale、BizCharts佔了專案的大部分體積,而在沒有全部使用這些庫的全部內容的情況下,我們可以對齊進行按需載入。
針對td-ui和BizCharts,我們對齊新增按需載入 babel-plugin-import
,這個包可以在使用ES6模組匯入的時候,對其進行分析,解析成引入相應資料夾下面的模組,如下:
首先,我們先新增babel的配置,在plugins中加入 babel-plugin-import
:
{ ... "plugins": [ ... ["import", [ { libraryName: 'td-ui', style: true }, { libraryName: 'bizcharts', libraryDirectory: 'lib/components' }, ]] ] }
可以看到,我們給bizcharts也添加了按需載入,配置中添加了按需載入的指定資料夾,針對bizcharts,編譯前後程式碼對比如下:
編譯前:
編譯後:
注意: bizcharts
按需載入需要引入其核心程式碼 bizcharts/lib/core
;
到此為止,td-ui和bizcharts的按需載入已經處理完畢,接下來是針對moment的處理。moment的主要體積來源於locale國際化資料夾,由於專案中有中英文國際化的需求,我們這裡使用 webpack.ContextReplacementPugin
對該資料夾的上下文進行匹配,只匹配中文和英文的語言包,plugin配置如下:
new webpack.ContextReplacementPugin( /moment[\/\\]locale$/, //匹配資料夾 /zh-cn|en-us/// 中英文語言包 )
如果沒有國際化的需求,可以使用 webpack.IgnorePlugin
對整個locale資料夾進行忽略,配置如下:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
減少編譯體積大小完成之後得到如下構建對比結果:
增量構建 | Development 構建 | Production 構建 | 備註 |
---|---|---|---|
3088ms | 43702ms | 89371ms | |
2561ms | 27864ms | 67441ms | 減少編譯體積大小 |
將大型庫外鏈 && 將庫預先編譯
為了避免一些已經編譯好的大型庫重新編譯,我們需要將這些庫放在編譯意外的地方,或者預先編譯這些庫。
webpack也為我們提供了將模組外鏈的配置 externals
,比如我們把lodash外鏈,配置如下
module.exports = { //... externals : { lodash: 'window._' }, // 或者 externals : { lodash : { commonjs: 'lodash', amd: 'lodash', root: '_' // 指向全域性變數 } } };
針對庫預先編譯,webpack也提供了相應的外掛,那就是 webpack.Dllplugin
,這個外掛可以預先編譯製定好的庫,最後在實際專案中使用 webpack.DllReferencePlugin
將預先編譯好的庫關聯到當前的編譯結果中,無需重新編譯。
Dllplugin配置檔案webpack.dll.config.js如下:
dllReference配置檔案webpack.dll.reference.config.js如下:
最後使用 webpack-merge
將 webpack.dll.reference.config.js
合併到到webpack配置中。
注意:預先編譯好的庫檔案需要在html中手動引入並且必須放在webpack的entry引入之前,否則會報錯。
其實,將大型庫外鏈和將庫預先編譯也屬於減少編譯體積的一種,最後得到編譯時間結果如下:
增量構建 | Development 構建 | Production 構建 | 備註 |
---|---|---|---|
3088ms | 43702ms | 89371ms | |
2561ms | 27864ms | 67441ms | 減少編譯體積大小 |
2246ms | 22870ms | 50601ms | Dll優化後 |
使用快取
首先,我們開啟babel-loader自帶的快取功能(預設其實就是開啟的)。
另外,開啟 uglifyjs-webpack-plugin
的快取功能。
新增快取外掛 hard-source-webpack-plugin
(當然也可以新增cache-loader)
const hardSourcePlugin = require('hard-source-webpack-plugin'); moudle.exports = { // ... plugins: [ new hardSourcePlugin() ], // ... }
新增快取後編譯結果如下:
增量構建 | Development 構建 | Production 構建 | 備註 |
---|---|---|---|
3088ms | 43702ms | 89371ms | |
2561ms | 27864ms | 67441ms | 減少編譯體積大小 |
2246ms | 22870ms | 50601ms | Dll優化後 |
1918ms | 10056ms | 17298ms | 使用快取後 |
可以看到,編譯效果極好。
並行編譯
由於nodejs為單執行緒,為了更好利用好電腦多核的特性,我們可以將編譯並行開始,這裡我們使用 happypack
,當然也可以使用 thread-loader
,我們將babel-loader和樣式的loader交給happypck接管。
babel-loader配置如下:
less-loader配置如下:
構建結果如下:
增量構建 | Development 構建 | Production 構建 | 備註 |
---|---|---|---|
3088ms | 43702ms | 89371ms | |
2561ms | 27864ms | 67441ms | 減少編譯體積大小 |
2246ms | 22870ms | 50601ms | Dll優化後 |
1918ms | 10056ms | 17298ms | 使用快取後 |
2252ms | 11846ms | 18727ms | 開啟happypack後 |
可以看到,新增happypack之後,編譯時間有所增加,針對這個結果,我對webpack版本和專案大小進行了對比測試,如下:
- Webpack:v2.7.0
- 專案:1013個模組
- 全量production構建:105395ms
新增happypack之後,全量production構建時間降低到 58414ms
。
針對webpack版本:
- Webpack:v4.23.0
- 專案:1013個模組
- 全量development構建 : 12352ms
新增happypack之後,全量development構建降低到11351ms。
得到結論:Webpack v4 之後,happypack已經力不從心,效果並不明顯,而且在小型中並不適用。
所以針對並行載入方案要不要加,要具體專案具體分析。
效能優化
對於webpack編譯出來的結果,也有相應的效能優化的措施。方案如下:
- 減少模組數量及大小
- 合理快取
- 合理拆包
減少模組數量及大小
針對減少模組數量及大小,我們在構建優化的章節中有提到很多,具體點如下:
- 按需載入 babel-plugin-import(antd、iview、bizcharts)、babel-plugin-component(element-ui)
- 減少無用模組webpack.ContextReplacementPlugin、webpack.IgnorePlugin
- Tree-shaking:樹搖功能,消除無用程式碼,無用模組。
- Scope-Hoisting:作用域提升。
- babel-plugin-transform-runtime,針對babel-polyfill清除不必要的polyfill。
前面兩點我們就不具體描述,在構建優化章節中有說。
Tree-shaking
樹搖功能,將樹上沒用的葉子搖下來,寓意將沒有必要的程式碼刪除。該功能在webapck V2中已被webpack預設開啟,但是使用前提是, 模組必須是ES6模組
,因為ES6模組為靜態分析,動態引入的特性,可以讓webpack在構建模組的時候知道,那些模組內容在引入中被使用,那些模組沒有被使用,然後將沒有被引用的的模組在轉為為AST後刪除。
由於必須使用ES6模組,我們需要將babel的自動模組轉化功能關閉,否則你的es6模組將自動轉化為commonjs模組,配置如下:
{ "presets": [ "react", "stage-2", [ "env", { "modlues": false // 關閉babel的自動轉化模組功能,保留ES6模組語法 } ] ] }
Tree-shaking編譯時候可以在命令後使用--display-used-exports可以在shell打印出關於程式碼剔除的提示。
Scope-Hoisting
作用域提升,儘可能的把打散的模組合併到一個函式中,前提是不能造成程式碼冗餘。因此只有那些被引用了一次的模組才能被合併。
可能不好理解,下面demo對比一下有無Scope-Hoisting的編譯結果。
首先定義一個util.js檔案
export default 'Hello,Webpack';
然後定義入口檔案main.js
import str from './util.js' console.log(str);
下面是無Scope-Hoisting結果:
然後是Scope-Hoisting後的結果:
與Tree-Shaking類似,使用Scope-Hoisting的前提也是必須是ES6模組,除此之外,還需要加入webpack內建外掛,位於webpack資料夾, webpack/lib/optimize/ModuleConcatenationPlugin
,配置如下:
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin'); module.exports = { //... plugins: [ new ModuleConcatenationPlugin() ] //... }
另外,為了跟好的利用Scope-Hoisting,針對Npm的第三方模組,它們也可能提供了ES6模組,我們可以指定優先使用它們的ES6模組,而不是使用它們編譯後的程式碼,webpack的配置如下:
module.exports = { //... resolve: { // 優先採用jsnext:main中指定的ES6模組檔案 mainFields: ['jsnext:main', 'module', 'browser', 'main'] } //... }
jsnext:main
為業內大家約定好的存放ES6模組的資料夾,後續為了規範,更改為 module
資料夾。
babel-plugin-transform-runtime
在我們實際的專案中,為了相容一些老式的瀏覽器,我們需要在專案加入babel-polyfill這個包。由於babel-polyfill太大,導致我們編譯後的包體積增大,降低我們的載入效能,但是實際上,我們只需要加入我們使用到的不相容的內容的polyfill就可以,這個時候 babel-plugin-transform-runtime
就可以幫我們去除那些我們沒有使用到的polyfill,當然,你需要在 babal-preset-env
中配置你需要相容的瀏覽器,否則會使用預設相容瀏覽器。
新增babel-plugin-transform-runtime的.babelrc配置如下:
{ "presets": [["env", { "targets": { "browsers": ["last 2 versions", "safari >= 7", "ie >= 9", "chrome >= 52"] // 配置相容瀏覽器版本 }, "modules": false }], "stage-2"], "plugins": [ "transform-class-properties", "transform-runtime", // 新增babel-plugin-transform-runtime "transform-decorators-legacy" ] }
合理使用快取
webpack對應的快取方案為新增hash,那我們為什麼要給靜態資源新增hash呢?
- 避免覆蓋舊檔案
- 回滾方便,只需要回滾html
- 由於檔名唯一,可開啟伺服器永遠緩
然後,webpack對應的hash有兩種, hash
和 chunkhash
。
- hash是跟整個專案的構建相關,只要專案裡有檔案更改,整個專案構建的hash值都會更改,並且全部檔案都共用相同的hash值
- chunkhash根據不同的入口檔案(Entry)進行依賴檔案解析、構建對應的chunk,生成對應的雜湊值。
細想我們期望的最理想的hash就是當我們的編譯後的檔案,不管是初始化檔案,還是chunk檔案或者樣式檔案,只要檔案內容一修改,我們的hash就應該更改,然後重新整理快取。可惜,hash和chunkhash的最終效果都沒有達到我們的預期。
另外,還有來自於的 extract-text-webpack-plugin
的 contenthash
,contenthash針對編譯後的每個檔案內容生成hash。只是extract-text-webpack-plugin在wbepack4中已經被棄用,而且這個外掛只對css檔案生效。
webpack-md5-hash
為了達到我們的預期效果,我們可以為webpack新增 webpack-md5-hash
外掛,這個外掛可以讓webpack的chunkhash根據檔案內容生成hash,相對穩定,這樣就可以達到我們預期的效果了,配置如下:
var WebpackMd5Hash = require('webpack-md5-hash'); module.exports = { // ... output: { //... chunkFilename: "[chunkhash].[id].chunk.js" }, plugins: [ new WebpackMd5Hash() ] };
合理拆包
為了減少首屏載入的時候,我們需要將包拆分成多個包,然後需要的時候在載入,拆包方案有:
- 第三方包,DllPlugin、externals。
- 動態拆包,利用import()、require.ensure()語法拆包
- splitChunksPlugin
針對第一點第三方包,我們也在第一章節構建優化中有介紹,這裡就不詳細說了。
動態拆包
首先是import(),這是webpack提供的語法,webpack在解析到這樣的語法時,會將指定的目錄檔案打包成一個chunk,當成非同步載入檔案輸出到編譯結果中,語法如下:
import(/* webpackChunkName: chunkName */ './chunkFile.js').then(_module => { // do something });
import()遵循promise規範,可以在then的回撥函式中處理模組。
注意:import()的引數不能完全是動態的,如果是動態的字串,需要預先指定字首資料夾,然後webpack會把整個資料夾編譯到結果中,按需載入。
然後是require.ensure(),與import()類似,為webpack提供函式,也是用來生成非同步載入模組,只是是使用callback的形式處理模組,語法如下:
// require.ensure(dependencies: String[], callback: function(require), chunkName: String) require.ensure([], function(require){ const _module = require('chunkFile.js'); }, 'chunkName');
splitChunksPlugin
webpack4中,將commonChunksPlugin廢棄,引入splitChunksPlugin,兩個plugin的作用都是用來切割chunk。
webpack 把 chunk 分為兩種型別,initial和async。在webpack4的預設情況下,production構建會分析你的 entry、動態載入(import()、require.ensure)模組,找出這些模組之間共用的node_modules下的模組,並將這些模組提取到單獨的chunk中,在需要的時候非同步載入到頁面當中。
預設配置如下:
module.exports = { //... optimization: { splitChunks: { chunks: 'async', // 標記為非同步載入的chunk minSize: 30000, minChunks: 1, maxAsyncRequests: 5, maxInitialRequests: 3, automaticNameDelimiter: '~', // 檔名中chunk的分隔符 name: true, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, priority: -10 }, default: { minChunks: 2, // 最小共享的chunk數 priority: -20, reuseExistingChunk: true } } } } };
splitChunksPlugin提供了靈活的配置,開發者可以根據自己的需求分割chunk,比如下面官方的例子1程式碼:
module.exports = { //... optimization: { splitChunks: { cacheGroups: { commons: { name: 'commons', chunks: 'initial', minChunks: 2 } } } } };
意思是在所有的初始化模組中抽取公共部分,生成一個chunk,chunk名字為comons。
在如官方例子2程式碼:
module.exports = { //... optimization: { splitChunks: { cacheGroups: { commons: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all' } } } } };
意思是從所有模組中抽離來自於node_modules下的所有模組,生成一個chunk。當然這只是一個例子,實際生產環境中並不推薦,因為會使我們首屏載入的包增大。
針對官方例子2,我們可以在開發環境中使用,因為在開發環境中,我們的node_modules下的所有檔案是基本不會變動的,我們將其生產一個chunk之後,每次增量編譯,webpack都不會去編譯這個來自於node_modules的已經生產好的chunk,這樣如果專案很大,來源於node_modules的模組非常多,這個時候可以大大降低我們的構建時間。
最後
現在大部分前端專案都是基於webpack進行構建的,面對這些專案,或多或少都有一些需要優化的地方,或許做優化不為完成KPI,僅為自己有更好的開發體驗,也應該行動起來。