【工程】webpack 系列 —效能優化
theme: cyanosis highlight: atom-one-dark
打算系統的整理一下,webpack
的一些知識點
,也是時候結合專案中使用的一些 案例
,做一些總結了。
webpack系列
打算從 webpack核心功能
-> 常用擴充套件
-> CSS 工程化
-> JS 相容性
-> 效能優化
這幾個方面開始記錄。
以及結合一些案例
,方便大家閱讀和實踐,以備 開箱即用
。
倉庫地址:PantherVkin/webpack-note (github.com)
效能優化概述
效能優化主要從下面三個維度入手:
- 構建效能
這裡所說的構建效能,是指在開發階段的構建效能,而不是生產環境的構建效能。
優化的目標,是降低從打包開始,到程式碼效果呈現所經過的時間。
構建效能會影響開發效率。構建效能越高,開發過程中時間的浪費越少。
- 傳輸效能
傳輸效能是指,打包後的JS程式碼傳輸到瀏覽器
經過的時間。
在優化傳輸效能時要考慮到:
-
總傳輸量:所有需要傳輸的JS檔案的內容加起來,就是總傳輸量,重複程式碼越少,總傳輸量越少
-
檔案數量:當訪問頁面時,需要傳輸的JS檔案數量,檔案數量越多,http請求越多,響應速度越慢
-
瀏覽器快取:JS檔案會被瀏覽器快取,被快取的檔案不會再進行傳輸
-
執行效能
執行效能是指,JS程式碼在瀏覽器端的執行速度。
它主要取決於我們如何書寫高效能的程式碼
。
構建效能
減少模組解析
- 什麼叫做模組解析?
模組解析包括:抽象語法樹分析、依賴分析、模組語法替換
。
- 不做模組解析會怎樣?
如果某個模組不做解析,該模組經過loader處理後的程式碼就是最終程式碼。
如果沒有loader對該模組進行處理,該模組的原始碼就是最終打包結果的程式碼。
如果不對某個模組進行解析,可以縮短構建時間
。
- 哪些模組不需要解析?
模組中
無其他依賴
:一些已經打包好的第三方庫,比如jquery。
-
如何讓某個模組不要解析?
-
配置
module.noParse
,它是一個正則,被正則匹配到的模組不會解析。
js
module.exports = {
mode: "development",
module: {
noParse: /test/
}
}
- 完整案例
webpack-note/examples/5.1-減少模組解析 at master · PantherVkin/webpack-note (github.com)
優化loader效能
限制loader的應用範圍
對於某些庫,不使用loader ?
例如:babel-loader可以轉換ES6或更高版本的語法,可是有些庫本身就是用ES5語法書寫的,不需要轉換,使用babel-loader反而會浪費構建時間。
lodash就是這樣的一個庫。
lodash是在ES5之前出現的庫,使用的是ES3語法。
- 通過
module.rule.exclude
或module.rule.include
,排除
或僅包含
需要應用loader的場景。
js
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /lodash/,
use: "babel-loader"
}
]
}
}
- 如果暴力一點,甚至可以排除掉
node_modules
目錄中的模組,或僅轉換src
目錄的模組。
js
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
//或
// include: /src/,
use: "babel-loader"
}
]
}
}
這種做法是對loader的範圍進行進一步的限制,
和noParse不衝突
。
快取loader的結果
我們可以基於一種假設:
如果某個檔案內容不變,經過相同的loader解析後,解析後的結果也不變。
於是,可以將loader的解析結果儲存下來,讓後續的解析直接使用儲存的結果。
cache-loader
可以實現這樣的功能。
js
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['cache-loader', ...loaders]
},
],
},
};
- loader 的
pitch
過程
有趣的是,cache-loader
放到最前面
,卻能夠決定後續的loader是否執行
。
實際上,loader的執行過程中,還包含一個過程,即pitch
。
loaderN.pitch
有返回值,則把返回的原始碼 交給上一個loader。如果沒有返回值,繼續 下一個
loader(N+1).pitch
。
``js
function loader(source) {
return
new source`
}
loader.pitch = function (filePath) { // 可返回可不返回 // 如果返回,返回原始碼 }
module.exports = loader ```
-
cache-loader
的原理同上。
-
完整案例
webpack-note/webpack.config.js at master · PantherVkin/webpack-note (github.com)
為loader的執行開啟多執行緒
thread-loader
會開啟一個執行緒池,執行緒池中包含適量的執行緒。
它會把後續的loader放到執行緒池的執行緒中執行,以提高構建效率。
由於後續的loader會放到新的執行緒
中,所以,後續的loader不能:
- 使用 webpack api 生成檔案
- 無法使用自定義的 plugin api
- 無法訪問 webpack options
在實際的開發中,可以進行測試,來決定thread-loader
放到什麼位置。
特別注意,開啟和管理執行緒需要消耗時間,在小型專案中使用thread-loader
反而會增加構建時間。
熱替換 HMR
當使用webpack-dev-server
時,考慮程式碼改動到效果呈現的過程。
而使用了熱替換後,流程發生了變化。
熱替換並不能降低構建時間(可能還會稍微增加),但可以降低程式碼改動到效果呈現的時間
。
使用和原理
- 更改配置
js
module.exports = {
devServer:{
hot:true // 開啟HMR
},
plugins:[
// 可選
new webpack.HotModuleReplacementPlugin()
]
}
- 更改程式碼
```js // index.js
if(module.hot){ // 是否開啟了熱更新 module.hot.accept() // 接受熱更新 } ```
首先,這段程式碼會參與最終執行!
當開啟了熱更新後,webpack-dev-server
會向打包結果中注入module.hot
屬性。
預設情況下,webpack-dev-server
不管是否開啟了熱更新,當重新打包後,都會呼叫。location.reload
重新整理頁面。
但如果運行了module.hot.accept()
,將改變這一行為。
module.hot.accept()
的作用是讓webpack-dev-server
通過socket
管道,把伺服器更新的內容傳送到瀏覽器。
然後,將結果交給外掛HotModuleReplacementPlugin
注入的程式碼執行。
外掛HotModuleReplacementPlugin
會根據覆蓋原始程式碼,然後讓程式碼重新執行。
所以,熱替換髮生在程式碼執行期。
樣式熱替換
對於樣式也是可以使用熱替換的,但需要使用style-loader
。
因為熱替換髮生時,HotModuleReplacementPlugin
只會簡單的重新執行模組程式碼。
因此style-loader
的程式碼一執行,就會重新設定style
元素中的樣式。
```js
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = { mode: "development", devtool: "source-map", devServer: { open: true, hot: true }, module:{ rules:[ {test:/.css$/, use:["style-loader", "css-loader"]} ] }, plugins: [ new HtmlWebpackPlugin({ template: "./public/index.html" }) ] }; ```
而mini-css-extract-plugin
,由於它生成檔案是在構建期間,執行期間並會也無法改動檔案,因此它對於熱替換是無效的。
傳輸效能
手動分包
基本原理
- 先單獨的打包公共模組
暴露出
全域性變數
。
公共模組會被打包成為動態連結庫
(dll Dynamic Link Library),並生成資源清單。
- 根據
入口模組
進行正常打包
打包時,如果發現模組中使用了資源清單中
描述的模組,則不會形成下面的程式碼結構。
js
//原始碼,入口檔案index.js
import $ from "jquery"
import _ from "lodash"
_.isArray($(".red"));
由於資源清單中包含jquery
和lodash
兩個模組,因此打包結果的大致格式是:
js
(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;
}
})
打包公共模組
打包公共模組是一個獨立的打包過程。
webpack.dll.config.js
單獨打包公共模組,暴露全域性變數名
js
// webpack.dll.config.js
module.exports = {
mode: "production",
entry: {
jquery: ["jquery"],
lodash: ["lodash"]
},
output: {
filename: "dll/[name].js",
library: "[name]" // 每個bundle 暴露的全域性變數名
}
};
- 利用
DllPlugin
生成資源清單
```js // webpack.dll.config.js const webpack = require("webpack"); const path = require("path");
module.exports = { mode: "production", entry: { jquery: ["jquery"], lodash: ["lodash"] }, output: { filename: "dll/[name].js", library: "[name]" // 每個bundle暴露的全域性變數名 }, plugins: [ new webpack.DllPlugin({ path: path.resolve(__dirname, "dll", "[name].manifest.json"), name: "[name]" }) ] }; ```
- 執行後,即可完成公共模組打包
js $ npx webpack --config webpack.dll.config.js
使用公共模組
根據入口模組
進行正常打包
。
- 在
index.html
頁面中手動引入公共模組
或者使用
CDN
。
```html
```
webpack.config.js
重新設定clean-webpack-plugin
如果使用了外掛clean-webpack-plugin
,為了避免它把公共模組清除,需要做出以下配置。
js
new CleanWebpackPlugin({
// 要清除的檔案或目錄
// 排除掉dll目錄本身和它裡面的檔案
cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
})
目錄和檔案的匹配規則使用的是globbing patterns
webpack.config.js
使用DllReferencePlugin
控制打包結果
動態連結庫引用外掛。
發現資源清單中有的模組,不會打包到最終程式碼,把這個模組忽略掉,直接匯出 全域性變數。
js
module.exports = {
plugins:[
new webpack.DllReferencePlugin({
manifest: require("./dll/jquery.manifest.json")
}),
new webpack.DllReferencePlugin({
manifest: require("./dll/lodash.manifest.json")
})
]
}
- 完整案例
webpack-note/examples/5.3-手動分包 at master · PantherVkin/webpack-note (github.com)
總結
手動打包的過程:
-
開啟
output.library
暴露公共模組 -
用
DllPlugin
建立資源清單 -
用
DllReferencePlugin
使用資源清單
手動打包的注意事項:
-
資源清單不參與執行,可以不放到打包目錄中
-
記得
手動引入公共JS
,以及避免被刪除 -
不要對小型的公共JS庫使用
優點:
- 極大
提升自身模組的打包速度
- 極大的
縮小了自身檔案體積
- 有利於
瀏覽器快取第三方庫的公共程式碼
缺點:
-
使用非常繁瑣
-
如果第三方庫中包含重複程式碼,則效果不太理想
自動分包
基本原理
不同與手動分包,自動分包是從實際的角度出發,從一個更加巨集觀的角度來控制分包,而一般不對具體哪個包要分出去進行控制。
因此使用自動分包,不僅非常方便,而且更加貼合實際的開發需要。
要控制自動分包,關鍵是要配置一個合理的分包策略。
有了分包策略之後,不需要額外安裝任何外掛,webpack會自動的按照策略進行分包。
實際上,webpack在內部是使用SplitChunksPlugin
進行分包的。
從分包流程中至少可以看出以下幾點:
-
分包策略至關重要,它決定了如何分包
-
分包時,webpack開啟了一個新的chunk,對
分離的模組進行打包
-
打包結果中,公共的部分被提取出來形成了一個單獨的檔案,它是新chunk的產物
分包策略的配置
webpack提供了optimization
配置項,用於配置一些優化資訊。
其中splitChunks
是分包策略的配置。
js
module.exports = {
optimization: {
splitChunks: {
// 分包策略
}
}
}
事實上,分包策略有其預設的配置,我們只需要輕微的改動,即可應對大部分分包場景。
chunks
該配置項用於配置需要應用分包策略的chunk。
我們知道,分包是從已有的chunk中分離出新的chunk,那麼哪些chunk需要分離呢。
chunks有三個取值,分別是:
all
: 對於所有的chunk都要應用分包策略async
:【預設】僅針對非同步chunk應用分包策略initial
:僅針對普通chunk應用分包策略
所以,你只需要配置chunks
為all
即可。
maxSize
該配置可以控制包的最大位元組數
。
如果某個包(包括分出來的包)超過了該值,則webpack會盡可能的將其分離成多個包。
但是不要忽略的是,分包的基礎單位是模組,如果一個完整的模組超過了該體積,它是無法做到再切割的,因此,儘管使用了這個配置,完全有可能某個包還是會超過這個體積。
另外,該配置看上去很美妙,實際意義其實不大。
因為分包的目的是提取大量的公共程式碼,從而減少總體積和充分利用瀏覽器快取。
雖然該配置可以把一些包進行再切分,但是實際的總體積和傳輸量並沒有發生變化。
如果要進一步減少公共模組的體積,只能是壓縮和
tree shaking
。
- 分包策略的其他配置
如果不想使用其他配置的預設值,可以手動進行配置:
automaticNameDelimiter
:新chunk名稱的分隔符,預設值~minChunks
:一個模組被多少個chunk使用時,才會進行分包,預設值1minSize
:當分包達到多少位元組後才允許被真正的拆分,預設值30000
快取組
之前配置的分包策略是全域性的。
而實際上,分包策略是基於快取組的
。
每個快取組提供一套獨有的策略,webpack按照快取組的優先順序依次處理每個快取組,被快取組處理過的分包不需要再次分包。
預設情況下,webpack提供了兩個快取組:
js
module.exports = {
optimization:{
splitChunks: {
//全域性配置
cacheGroups: {
// 屬性名是快取組名稱,會影響到分包的chunk名
// 屬性值是快取組的配置,快取組繼承所有的全域性配置,也有自己特殊的配置
vendors: {
test: /[\\/]node_modules[\\/]/, // 當匹配到相應模組時,將這些模組進行單獨打包
priority: -10 // 快取組優先順序,優先順序越高,該策略越先進行處理,預設值為0
},
default: {
minChunks: 2, // 覆蓋全域性配置,將最小chunk引用數改為2
priority: -20, // 優先順序
reuseExistingChunk: true // 重用已經被分離出去的chunk
}
}
}
}
}
很多時候,快取組對於我們來說沒什麼意義,因為預設的快取組就已經夠用了。
但是我們同樣可以利用快取組來完成一些事情,比如對公共樣式
的抽離。
js
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
styles: {
test: /\.css$/, // 匹配樣式模組
minSize: 0, // 覆蓋預設的最小尺寸,這裡僅僅是作為測試
minChunks: 2 // 覆蓋預設的最小chunk引用數
}
}
}
},
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"
})
]
}
- 完整案例
webpack-note/examples/5.4-自動分包 at master · PantherVkin/webpack-note (github.com)
配合多頁應用
雖然現在單頁應用是主流,但免不了還是會遇到多頁應用。
由於在多頁應用中需要為每個html頁面指定需要的chunk,這就造成了問題。
js
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["index~other", "vendors~index~other", "index"]
})
我們必須手動的指定被分離出去的chunk名稱,這不是一種好辦法。
幸好html-webpack-plugin
的新版本中解決了這一問題。
shell
$ npm i -D html-webpack-plugin@next
做出以下配置即可:
js
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["index"]
})
它會自動的找到被index
分離出去的chunk,並完成引用。
目前這個版本仍處於測試解決,還未正式釋出。
原理
自動分包的原理其實並不複雜,主要經過以下步驟:
- 檢查每個chunk編譯的結果
- 根據
分包策略,找到那些滿足策略的模組
- 根據
分包策略,生成新的chunk打包這些模組
(程式碼有所變化) - 把
打包出去的模組從原始包中移除
,並修正原始包程式碼
在程式碼層面,有以下變動
- 分包的程式碼中,加入一個全域性變數,型別為陣列,其中包含公共模組的程式碼
- 原始包的程式碼中,使用陣列中的公共程式碼
程式碼壓縮
- 為什麼要進行程式碼壓縮?
減少程式碼體積;破壞程式碼的可讀性,提升破解成本。
- 什麼時候要進行程式碼壓縮?
生產環境。
- 使用什麼壓縮工具?
目前最流行的程式碼壓縮工具主要有兩個:UglifyJs
和Terser
。
UglifyJs
是一個傳統的程式碼壓縮工具,已存在多年,曾經是前端應用的必備工具,但由於它不支援ES6
語法,所以目前的流行度已有所下降。
Terser
是一個新起的程式碼壓縮工具,支援ES6+
語法,因此被很多構建工具內建使用。webpack
安裝後會內建Terser
,當啟用生產環境後即可用其進行程式碼壓縮。
Terser
在Terser
的官網可嘗試它的壓縮效果。
Terser官網:http://terser.org/
webpack+Terser
webpack自動集成了Terser。
如果你想更改、新增壓縮工具,又或者是想對Terser進行配置,使用下面的webpack配置即可。
```js const TerserPlugin = require('terser-webpack-plugin'); const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = { optimization: { // 是否要啟用壓縮,預設情況下,生產環境會自動開啟 minimize: true, minimizer: [ // 壓縮時使用的外掛,可以有多個 new TerserPlugin(), new OptimizeCSSAssetsPlugin() ], }, }; ```
tree shaking
壓縮可以移除模組內部的無效程式碼。
tree shaking 可以移除模組之間的無效程式碼。
- 背景?
某些模組匯出的程式碼並不一定會被用到。
```js // myMath.js export function add(a, b){ console.log("add") return a+b; }
export function sub(a, b){ console.log("sub") return a-b; } ```
js
// index.js
import {add} from "./myMath"
console.log(add(1,2));
tree shaking 用於移除掉不會用到的匯出。
- 使用
webpack2
開始就支援了tree shaking
。
只要是生產環境,tree shaking
自動開啟。
原理
webpack會從入口模組出發尋找依賴關係。
當解析一個模組時,webpack會根據ES6的模組匯入語句來判斷
,該模組依賴了另一個模組的哪個匯出。
webpack之所以選擇ES6的模組匯入語句,是因為ES6模組有以下特點:
- 匯入匯出語句只能是頂層語句
- import的模組名只能是字串常量
- import繫結的變數是不可變的
這些特徵都非常有利於分析出穩定的依賴。
在具體分析依賴時,webpack堅持的原則是:保證程式碼正常執行,然後再儘量tree shaking。
所以,如果你依賴的是一個匯出的物件,由於JS語言的動態特性,以及webpack
還不夠智慧,為了保證程式碼正常執行,它不會移除物件中的任何資訊。
因此,我們在編寫程式碼的時候,儘量:
- 使用
export xxx
匯出,而不使用export default {xxx}
匯出 - 使用
import {xxx} from "xxx"
匯入,而不使用import xxx from "xxx"
匯入
依賴分析完畢後,webpack
會根據每個模組每個匯出是否被使用,標記其他匯出為dead code
,然後交給程式碼壓縮工具處理。
程式碼壓縮工具最終移除掉那些dead code
程式碼。
使用第三方庫
某些第三方庫可能使用的是commonjs
的方式匯出,比如lodash
。
又或者沒有提供普通的ES6方式匯出。
對於這些庫,tree shaking
是無法發揮作用的。
因此要尋找這些庫的es6
版本,好在很多流行但沒有使用的ES6
的第三方庫,都發布了它的ES6
版本,比如lodash-es
。
作用域分析
tree shaking
本身並沒有完善的作用域分析
,可能導致在一些dead code
函式中的依賴仍然會被視為依賴。
外掛webpack-deep-scope-plugin
(個人開發的)提供了作用域分析,可解決這些問題。
副作用問題
webpack在tree shaking
的使用,有一個原則:一定要保證程式碼正確執行 。
在滿足該原則的基礎上,再來決定如何tree shaking
。
因此,當webpack
無法確定某個模組是否有副作用時,它往往將其視為有副作用 。
因此,某些情況可能並不是我們所想要的 。
```js //common.js var n = Math.random();
//index.js import "./common.js" ```
雖然我們根本沒用有common.js
的匯出,但webpack
擔心common.js
有副作用,如果去掉會影響某些功能。
如果要解決該問題,就需要標記該檔案是沒有副作用的。
在package.json
中加入sideEffects
。
json
{
"sideEffects": false
}
有兩種配置方式:
-
false
當前工程中,所有模組都沒有副作用。
注意,這種寫法會影響到某些css檔案的匯入。
-
陣列
設定哪些檔案擁有副作用,例如:
["!src/common.js"]
,表示只要不是src/common.js
的檔案,都有副作用。這種方式我們一般不處理,通常是一些第三方庫在它們自己的
package.json
中標註。
css tree shaking
webpack
無法對css
完成tree shaking
,因為css
跟es6
沒有半毛錢關係。
因此對css
的tree shaking
需要其他外掛完成。
例如:purgecss-webpack-plugin
。
注意:
purgecss-webpack-plugin
對css module
無能為力。
懶載入
- 案例
點選之後載入。
js
const btn = document.querySelector('button')
btn.onclick = async function () {
//動態載入
//import 是ES6的草案
//瀏覽器會使用JSOP的方式遠端去讀取一個js模組
//import()會返回一個promise (* as obj)
// const { chunk } = await import(/* webpackChunkName:"lodash" */"lodash-es");
const {chunk} = await import('./util')
const result = chunk([3, 5, 6, 7, 87], 2)
console.log(result)
}
- 原理
點選後把遠端的模組放到 webpackJsonp
陣列中,這樣主模組就能使用了。
- 完整案例
webpack-note/examples/5.5-懶載入 at master · PantherVkin/webpack-note (github.com)
gzip
gzip是一種壓縮檔案的演算法。
- B/S結構中的壓縮傳輸
優點:傳輸效率可能得到大幅提升
缺點:伺服器的壓縮需要時間,客戶端的解壓需要時間
- 使用webpack進行預壓縮
使用compression-webpack-plugin
外掛對打包結果進行預壓縮,可以移除伺服器的壓縮時間
。
js
const {CleanWebpackPlugin} = require('clean-webpack-plugin')
constCmpressionWebpackPlugin = require('compression-webpack-plugin')
module.exports = {
mode: 'production',
optimization: {
splitChunks: {
chunks: 'all'
}
},
plugins: [
newCleanWebpackPlugin(),
newCmpressionWebpackPlugin({
test: /\.js/,
minRatio: 0.5
})
]
}
打包結果分析
-
安裝
js $ npm i -D webpack-bundle-analyzer
-
webpack.config.js
```js const { CleanWebpackPlugin } = require("clean-webpack-plugin"); const WebpackBundleAnalyzer = require("webpack-bundle-analyzer") .BundleAnalyzerPlugin;
module.exports = { mode: "production", optimization: { splitChunks: { chunks: "all" } }, plugins: [new CleanWebpackPlugin(), new WebpackBundleAnalyzer()] }; ```