webpack性能優化概述
很多很多人都認爲性能是一個項目必不可少的,我總結了有關webpack構建項目中的性能優化的幾個方面。在瞭解性能優化之前,最好對webpack編譯原理有所瞭解,方便更深入的學習。
可以參考:大神眼中的webpack構建工具:對編譯原理的分析
本文中性能優化目錄:
- 構建性能:是指在開發階段的構建性能,而不是生產環境的構建性能,儘可能提高開發效率
- 減少模塊解析:
- 優化loader性能
- 熱替換
- 傳輸性能:服務端的JS傳輸給客戶端的時間。總代碼量越少,時間越少。文件數量越少,http請求次數越少。
- 分包
- 手動分包
- 自動分包
- 體積優化
- 代碼壓縮
- tree shaking
- 懶加載
- gzip
- 分包
- 運行性能:在瀏覽器端的運行速度。
- 運行性能主要在書寫代碼中體現
一、構建性能
1、減少模塊解析
模塊解析包括:抽象語法樹分析、依賴分析、模塊語法替換。如果一個模塊不做模塊解析,那麼經過loaders處理後的代碼就是最後的源碼。但是模塊解析又是必須要做的步驟,那麼如何減少模塊解析?嘿嘿。如果一個模塊中沒有其他依賴就可以不對其進行模塊解析,其實,減少模塊解析主要是針對一些已經打包好的第三方庫,比如jquery
等。配置一個模塊不進行解析很簡單,只要在module
中配置noParse
。一個正則表達式。
module.exports = {
mode: "development",
devtool: "source-map",
module: {
rules:[],
noParse: /jquery/
}
}
2、優化loader性能
(1)減少loader應用範圍
優化loader的性能,其實就是進一步限制loader的應用範圍,對於某些庫,不需要使用loader,比如說babel-loader
,babel-loader
是將某些ES2015+轉換爲瀏覽器識別的語法。但是某些庫,本來就沒有使用這麼高版本的語法,使用loader處理完全是浪費時間,所以不需要對其進行loader處理了呀。比如loadsh
庫。我們可以通過配置,讓其跳過loader處理。
通過module.rule.exclude
或module.rule.include
,排除或僅包含需要應用loader的場景
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /lodash/,
use: "babel-loader"
}
]
}
}
當然,第三方大部分庫都已經對其進行了babel處理,如果暴力一點,甚至可以排除掉node_modules
目錄中的模塊,或僅轉換src
目錄的模塊。但是要慎重,排除之前要去官網看看是否已經處理過了。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
//也可以用 include: /src/,效果相同
use: "babel-loader"
}
]
}
}
(2)緩存loader的結果
如果某個文件內容不變,經過相同的loader解析後,解析後的結果也不變,所以我們可以將loader的解析結果保存下來,讓後續的解析直接使用緩存的結果,當然這種方式會增加第一次構建時間
cache-loader
可以實現這樣的功能,要將cache-loader
放在最前面,雖然放在最前面,但是他可以決定讓後續loader是否運行。具體配置看官方文檔!
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: "cache-loader",
options:{
cacheDirectory: "./cache" //緩存的目錄
}
}, ...loaders] //其餘的loaders
},
],
},
};
實際上,loader的運行過程中,還包含一個過程,即pitch
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-l6TffEJ7-1584435464160)(F:\博客\前端工程化\assets\pitch-loader運行過程.png)]
(3)爲loader開啓多線程
如果loader進行處理的過程是一個異步操作的話,可以大大減少處理時間,thread-loader
會開啓一個線程池,它會把後續的loader放到線程池的線程中運行,以提高構建效率。因爲後續的loader是放入新的線程池中,就無法使用webpack api、自定義的plugin api,也無法訪問的webpack options。具體把thread-loader
放在什麼位置,要根據項目視情況而定,可以傻瓜式測試。但要注意的是,開啓和管理新的線程也是需要時間的。
module.exports = {
mode: "development",
devtool: "source-map",
module: {
rules: [
{
test: /\.js$/,
use: [
"file-loader",
"thread-loader", //將thread-loader和babel-loader放入新的線程中
"babel-loader"
]
}
]
}
};
3、熱替換 HMR
我們在使webpack-dev-server
開發服務器時,他會事時的監控代碼變動,不需要重新帶包,但是webpack-dev-server
發現代碼變動的時候,瀏覽器會刷新,重新請求所有資源。這顯然不是我們開發最理想的結果,我們更希望,當我們更改一部分代碼的時候,瀏覽器不刷新,只是局部進行替換。熱替換就是實習了局部替換。要注意熱替換不會講題構建的性能,但是它可以降低代碼變動到效果呈現的時間。
使用webpack-dev-server
的流程:
使用熱替換的流程:
使用熱替換
- 更改配置:
module.exports = {
devServer:{
hot:true // 開啓熱替換
},
plugins:[
new webpack.HotModuleReplacementPlugin() //使用插件
]
}
- 更改代碼:隨便一個文件寫入以下代碼,只要保證運行即可
// index.js
if(module.hot){ // 是否開啓了熱更新
module.hot.accept() // 接受熱更新
}
熱替換原理
當在配置文件中開啓了熱替換後,webpack-dev-server
會向打包結果中注入module.hot
屬性,所以在上述文件中添加的module.hot
代碼。默認情況下,webpack-dev-server
不管是否開啓了熱更新,當重新打包後,都會調用location.reload
刷新頁面,但是如果運行了module.hot.accept()
,就不會再調用loaction.reload()
來刷新頁面,而是使用websocket
的方式,module.hot.accept()
會讓服務器更新的內容通過websocket
傳送給瀏覽器,僅僅是傳輸修改的部分。然後將結果交給插件HotModuleReplacementPlugin
注入的代碼執行,插件HotModuleReplacementPlugin
會根據覆蓋原始代碼,然後讓代碼重新執行。
比如我在這裏修改了一個js文件的模塊導出,當監控到代碼發生變化以後,websocket向客戶端發出了兩個服務,第一個是熱替換的哈希值,不解釋。
第二個就是需要熱替換的代碼,當接收到這個這段代碼的時候,HotModuleReplacementPlugin
插件就會根據key值"./src/a.js"來找到模塊的位置,將模塊的value重新覆蓋。
module.hot.accept()
的作用是讓webpack-dev-server
通過socket
管道,把服務器更新的內容發送到瀏覽器
簡單來說,熱替換就是開啓熱替換的webpack-dev-server
開發服務器監控到代碼變化,通過websocket從服務的向客戶端發送變化的內容,客戶端接受到變化的內容後替換掉原內容。
樣式熱替換
對於樣式也是可以使用熱替換的,需要使用style-loader
,因爲熱替換髮生時HotModuleReplacementPlugin
只會簡單的重新運行模塊代碼。因此style-loader
的代碼一運行,就會重新設置style
元素中的樣式。而mini-css-extract-plugin
,由於它生成文件是在構建期間,運行期間並會也無法改動文件,因此它對於熱替換是無效的。
二、傳輸性能
1、手動分包
手動分包的總體思路是先單獨打包公共模塊,公共模塊會被打包成一個動態鏈接庫(ddl),並且形成一個資源清單。然後再根據入口模塊進行正常的打包過程。
當正常打包時,如果發現模塊中使用了資源清單中描述的模塊,如下
//源碼,入口文件index.js
import $ from "jquery"
import _ from "lodash"
_.isArray($(".red"));
由於資源清單中包含jquery
和lodash
兩個模塊,因此打包結果不會出現jquery
和lodash
的源代碼,而是通過導出一個模塊的方式,如下
(function(modules){
//...
})({
// index.js文件的打包結果並沒有變化
"./src/index.js":
function(module, exports, __webpack_require__){
var $ = __webpack_require__("./node_modules/jquery/index.js")
var _ = __webpack_require__("./node_modules/lodash/index.js")
_.isArray($(".red"));
},
// 由於資源清單中存在,jquery的代碼並不會出現在這裏
"./node_modules/jquery/index.js":
function(module, exports, __webpack_require__){
module.exports = jquery;
},
// 由於資源清單中存在,lodash的代碼並不會出現在這裏
"./node_modules/lodash/index.js":
function(module, exports, __webpack_require__){
module.exports = lodash;
}
})
這樣一來,重複代碼就會減少,也就減少了傳輸時的體積。
(1)打包公共模塊
打包公共模塊是一個獨立的打包過程,所以我們通常會重建一個配置文件webpack.dll.config.js
,需要兩個過程,首先打包公共模塊,暴露變量名,然後用DllPlugin
插件生成資源清單
const path = require("path")
const webpack = require("webpack")
module.exports = {
mode: "production",
entry: {//打包公共模塊
jquery: ["jquery"],
lodash: ["lodash"]
},
output: {
filename: "dll/[name].js",
library: "[name]"//
},
plugins: [//生成資源清單
new webpack.DllPlugin({
path: path.resolve(__dirname, "dll", "[name].manifest.json"), //資源清單的保存位置
name: "[name]"//資源清單中,暴露的變量名
})
]
};
運行後,即可完成公共模塊打包
npx webpack --config webpack.dll.config.js
(2)使用公共模塊
- 在頁面中手動引入公共模塊
<script src="./dll/jquery.js"></script>
<script src="./dll/lodash.js"></script>
- 爲了避免把公共模塊清除,需要重新設置
clean-webpack-plugin
,如果沒有使用你該插件則忽略
new CleanWebpackPlugin({
// 要清除的文件或目錄
// 排除掉dll目錄本身和它裏面的文件
cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
})
目錄和文件的匹配規則使用的是globbing patterns
- 使用
DllReferencePlugin
,告訴webpack資源清單的位置,如果遇到導出模塊已經在資源清單中,則不需要再進行打包。
module.exports = {
plugins:[
new webpack.DllReferencePlugin({
manifest: require("./dll/jquery.manifest.json")
}),
new webpack.DllReferencePlugin({
manifest: require("./dll/lodash.manifest.json")
})
]
}
簡單來說,手動打包首先要開啓output.library
暴露公共模塊,使用webpack.DllPlugin
插件生成資源清單(可以不使用,自己寫),然後在頁面中引入資源清單中的依賴,最後用DllReferencePlugin
插件使用資源清單。
在手動打包的過程中,我們需要注意,資源清單是不參與運行的,所以不能把資源清單放在打包目錄中。
手動打包優點:
- 極大提升自身模塊的打包速度
- 極大的縮小了自身文件體積
- 有利於瀏覽器緩存第三方庫的公共代碼
缺點:
- 使用非常繁瑣
- 如果第三方庫中包含重複代碼,則效果不太理想
2、自動分包
自動包區別於手動分包的是不需要確定具體爲那個模塊分包,而是從一個宏觀的角度來控制分包,那麼要控制分包,就需要有一個合理的分包策略。webpack4已經放棄了原來用CommonsChunkPlugin
實現分包,而是在內部使用SplitChunksPlugin
進行分包。
分包流程:分包時,webpack根據分包策略,實現具體的分包,它會開啓一個新的chunk,對分離的模塊進行打包處理。公共代碼會生成新chunk即common,最後打包成budle_common.js 如圖所示:
自動分包原理:自動分包會檢查每個chunk編譯的結果,根據分包策略,找到那些滿足策略的模塊,並生成新的chunk打包這些模塊,再將打包出去的模塊從原來的包中移除,並修改原來包的代碼
(1)分包策略的基本配置
webpack提供了optimization
配置項,用於配置一些優化信息,其中splitChunks
是分包策略的配置,其中有以下常用配置
- chunks:該配置項用於配置需要應用分包策略的chunk,有以下三個值,默認時
async
- all: 對於所有的chunk都要應用分包策略,一般來說使用這個值
- async:僅針對異步chunk應用分包策略
- initial:僅針對普通chunk應用分包策略
- maxSize:如果一個要被分出來的包超過了該值,webpack就會盡可能的將其分成多個包。注意:分包的基礎單位是模塊,如果一個完整的模塊超過了該體積,它是無法做到再切割的,因此,儘管使用了這個配置,完全有可能某個包還是會超過這個體積。通常不使用
- automaticNameDelimiter:新chunk名稱的分隔符,默認值~
- minChunks:一個模塊至少被多少個chunk使用時,纔會進行分包,默認值1
- minSize:當分包達到多少字節後才允許被真正的拆分,默認值30000
module.exports = {
optimization: {
splitChunks: {
//分包配置
chunks: "all",
//maxSize: 60000
automaticNameDelimiter: ".",
minChunks: 2,
minSize: 30000
}
}
}
(2)緩存組
實際上,分包策略是基於緩存組的。每個緩存組提供一套獨有的策略,webpack按照緩存組的優先級依次處理每個緩存組,被緩存組處理過的分包不需要再次分包。默認情況下,webpack提供了兩個緩存組,很多時候,緩存組對於我們來說沒什麼意義,因爲默認的緩存組就已經夠用了。
webpack默認緩存組
module.exports = {
optimization:{
splitChunks: {
chunks:"all",
//全局配置
cacheGroups: {
// 屬性名是緩存組名稱,會影響到分包的chunk名
// 屬性值是緩存組的配置,緩存組繼承所有的全局配置,也有自己特殊的配置
vendors: {
test: /[\\/]node_modules[\\/]/, // 當匹配到相應模塊時,將這些模塊進行單獨打包
priority: -10 // 緩存組優先級,優先級越高,該策略越先進行處理,默認值爲0
},
default: {
minChunks: 2, // 覆蓋全局配置,將最小chunk引用數改爲2
priority: -20, // 優先級
reuseExistingChunk: true // 重用已經被分離出去的chunk
}
}
}
}
}
通過緩存組對公共樣式分離:webpack.config.js
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
styles: {
test: /\.css$/, // 匹配樣式模塊
minSize: 30000,
minChunks: 2
}
}
}
},
module: {
rules: [{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"] }]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["index"]
}),
new MiniCssExtractPlugin({
filename: "[name].[hash:5].css",
// chunkFilename是配置來自於分割chunk的文件名
chunkFilename: "common.[hash:5].css"
})
]
}
3、代碼壓縮
爲生產環境進行代碼壓縮,減少代碼體積是增加傳輸性能必不可少的環節,進行代碼壓縮同時也可以破壞代碼可讀性,提升破解成本,目前流行的代碼壓縮工具主要有UglifyJs
和Terser
UglifyJs
是一個傳統的代碼壓縮工具,已存在多年,曾經是前端應用的必備工具,但由於它不支持ES6
語法,所以目前的流行度已有所下降。
Terser
是一個新起的代碼壓縮工具,支持ES6+
語法,因此被很多構建工具內置使用。
Terser官網:https://terser.org/
webpack
已經內置了Terser
,所以我們在啓用生產環境後即可用其進行代碼壓縮。
webpack自動集成了Terser如果你想更改、添加壓縮工具,又或者是想對Terser進行配置,使用下面的webpack配置即可
const TerserPlugin = require('terser-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
optimization: {
// 是否要啓用壓縮,默認情況下,生產環境會自動開啓
minimize: true,
minimizer: [ // 壓縮時使用的插件,可以有多個
new TerserPlugin(),
new OptimizeCSSAssetsPlugin()
],
},
};
4、tree shaking
代碼壓縮可以移除模塊內部的無效代碼,而tree shaking
可以移除模塊之間的無效代碼。比如說
// myMath.js
export function add(a, b){
console.log("add")
return a+b;
}
export function sub(a, b){
console.log("sub")
return a-b;
}
這個工具模塊有兩個導出方法,但是整個項目只使用了add
,如果在打包的時候兩個方法都打包的話無疑會增加無效代碼量,tree shaking
的作用就是移除無效的代碼塊。webpack2
開始就支持了tree shaking
。只要是生產環境,tree shaking
自動開啓
tree shaking工作原理:webpack
會從入口模塊出發尋找依賴關係,當解析一個模塊時,webpack
會根據ES6的模塊導入語句來判斷,該模塊依賴了另一個模塊的哪個導出。依賴分析完畢後,webpack
會根據每個模塊每個導出是否被使用,標記未使用的導出爲dead code
,然後交給代碼壓縮工具處理。代碼壓縮工具最終移除掉那些dead code
代碼
在具體分析依賴時,webpack
堅持的原則是:保證代碼正常運行,然後再儘量tree shaking
。
我們在書寫導入導出時,儘量使用以下方式:
- 使用
export xxx
導出,而不使用export default {xxx}
導出 - 使用
import {xxx} from "xxx"
導入,而不使用import xxx from "xxx"
導入
所以,如果你依賴的是一個導出的對象,由於JS語言的動態特性,以及webpack
還不夠智能,爲了保證代碼正常運行,它不會移除對象中的任何信息。
ES6的模塊導入語句:使用ES6的模塊導入語句,有利於更好的分析依賴,是因爲ES6模塊有以下特點:
-
導入導出語句只能是頂層語句
-
import的模塊名只能是字符串常量
-
import綁定的變量是不可變的
使用第三方庫tree shaking注意:
某些第三方庫可能使用的是commonjs
的方式導出,比如lodash
又或者沒有提供普通的ES6方式導出。對於這些庫,tree shaking
是無法發揮作用的。但好在很多流行的庫都發布了它的ES6
版本,比如lodash-es
。我們在使用loadsh
的時候可以使用lodash-es
副作用函數(side effect):函數運行過程中,可能會對外部環境造成影響的功能。如果函數中包含以下代碼,該函數叫做副作用函數:異步代碼、localStorage、對外部數據的修改
純函數(pure function):如果一個函數沒有副作用,同時,函數的返回結果僅依賴參數,則該函數叫做
webpack
堅持的原則是:保證代碼正常運行,然後再儘量tree shaking
。因此當webpack
無法確定某個模塊是否有副作用時,它往往將其視爲有副作用。當我們知道某個導出沒有副作用,但是webpack
擔心common.js
有副作用,如果去掉會影響某些功能,這時候我們就需要標記該文件是沒有副作用的。當然,第三方插件中,一般都已經標記過了,我們無需自己添加。
在package.json
中加入sideEffects
{
"sideEffects": false
//"sideEffects": ["!src/common.js"]
}
5、懶加載
懶加載就是動態加載,按需加載,當我們需要的時候再加載。使用import()
語法。import()
會返回一個promise
。
if(Math.random()<0.5){
const {add} =await import("./utils.js")
const result = add(1,3)
}
此時,utils
工具類是等到要執行時纔會引入,而不會變成頂層語句直接執行。當我們執行到import時纔會到服務端請求該模塊的js文件,而不是在頁面加載的時候就去請求,可以減少首頁加載時間過長
6、gzip
gzip是一種壓縮文件的算法,當我們js文件過大的時候,就可以使用gzip的方式,對文件進行壓縮,配合服務器端進行使用。
具體使用參照1:
webpack-dev-server開發服務器 和 webpack中常用plugin和loader一文中的compression-webpack-plugin
插件
具體使用參照2:
webpack+nginx實現gzip壓縮解決vue首屏加載過慢