前言
隨著業務複雜度的不斷的增加,工程模組的體積也會不斷增加,構建後的模組通常要以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交給happypack接管。
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,僅為自己有更好的開發體驗,也應該行動起來。