在上一篇文章Webpack設定全解析介紹了Webpack中loader和plugins的一些基本用法,當loader和plugins使用較多後項目也會越來越耗時,因此這次我們繼續學習如何優化webpack的設定來讓我們的專案執行的更快耗時更短。
本文將從縮小檔案搜尋範圍、減少打包檔案、快取和多進程四個方面來了解Webpack的優化設定。
Webpack會從Entry入口出發,解析檔案中的匯入模組語句,再遞回解析;每次遇到匯入語法時會做兩件事情:
require('vue')
就去引入/node_modules/vue/dist/vue.runtime.common.js
檔案當專案只有幾個檔案時,解析檔案流程只有幾百毫秒,然而隨着專案規模的增大,解析檔案會越來越耗時,因此我們通過webpack的設定來縮小我們搜尋模組的範圍
在上一篇中,我們介紹了使用include/exclude
將node_modules中的檔案進行包括/排除。
{
rules: [{
test: /\.js$/,
use: {
loader: 'babel-loader'
},
// exclude: /node_modules/,
include: [path.resolve(__dirname, 'src')]
}]
}
複製程式碼
include
表示哪些目錄中的檔案需要進行babel-loader,exclude
表示哪些目錄中的檔案不要進行babel-loader。這是因爲在引入第三方模組的時候,很多模組已經是打包後的,不需要再被處理,比如vue、jQuery等;如果不設定include/exclude
就會被loader處理,增加打包時間。
如果一些第三方模組沒有使用AMD/CommonJs規範,可以使用noParse
來標記這個模組,這樣Webpack在匯入模組時,就不進行解析和轉換,提升Webpack的構建速度;noParse可以接受一個正則表達式或者一個函數:
{
module: {
//noParse: /jquery|lodash|chartjs/,
noParse: function(content){
return /jquery|lodash|chartjs/.test(content)
}
}
}
複製程式碼
對於jQuery、lodash、chartjs等一些庫,龐大且沒有採用模組化標準,因此我們可以選擇不解析他們。
注:被不解析的模組檔案中不應該包含
require
、import
等模組語句
經過多次打包嘗試,打包效能大概能提升10%~20%;本範例完整程式碼demo,
modules用於告訴webpack去哪些目錄下查詢參照的模組,預設值是["node_modules"]
,意思是在./node_modules
查詢模組,找不到再去../node_modules
,以此類推。
我們程式碼中也會有大量的模組被其他模組依賴和引入,由於這些模組位置分佈不固定,路徑有時候會很長,比如import '../../src/components/button'
、import '../../src/utils'
;這時我們可以利用modules進行優化
{
resolve: {
modules: [
path.resolve(__dirname, "src"),
path.resolve(__dirname, "node_modules"),
"node_modules",
],
},
}
複製程式碼
這樣我們可以簡單的通過import 'components/button'
、import 'utils'
進行匯入,webpack會會優先從src
目錄下進行查詢
alias通過建立import或者require的別名,把原來匯入模組的路徑對映成一個新的匯入路徑;它和resolve.modules
不同的的是,它的作用是用別名代替前面的路徑,不是省略;這樣的好處就是webpack直接會去對應別名的目錄查詢模組,減少了搜尋時間。
{
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
}
複製程式碼
這樣我們就能通過import Buttom from '@/Button'
來引入元件了;我們不光可以給自己寫的模組設定別名,還可以給第三方模組設定別名:
{
resolve: {
alias: {
'vue$': isDev ? 'vue/dist/vue.runtime.js' : 'vue/dist/vue.runtime.min.js',
},
},
}
複製程式碼
我們在import Vue from 'vue'
時,webpack就會幫我們去vue依賴包的dist檔案下面 下麪引入對應的檔案,減少了搜尋package.json的時間。
mainFields用來告訴webpack使用第三方模組中的哪個欄位來匯入模組;第三方模組中都會有一個package.json
檔案用來描述這個模組的一些屬性,比如模組名(name)、版本號(version)、作者(auth)等等;其中最重要的就是有多個特殊的欄位用來告訴webpack匯入檔案的位置,有多個欄位的原因是因爲有些模組可以同時用於多個環境,而每個環境可以使用不同的檔案。
mainFields的預設值和當前webpack設定的target
屬性有關:
webworker
或web
(預設),mainFields預設值爲["browser", "module", "main"]
["module", "main"]
這就是說當我們require('vue')
的時候,webpack先去vue下面 下麪搜尋browser欄位,沒有找到再去搜尋module欄位,最後搜尋main欄位。
爲了減少搜尋的步驟,在明確第三方模組入口檔案描述欄位時,我們可以將這個欄位設定儘量少;一般第三方模組都採用main
欄位,因此我們可以這樣設定:
{
resolve: {
mainFields: ["main"],
}
}
複製程式碼
extensions欄位用來在匯入模組時,自動帶入後綴嘗試去匹配對應的檔案,它的預設值是:
{
resolve: {
extensions: ['.js', '.json']
}
}
複製程式碼
也就是說我們在require('./utils')
時,Webpack先匹配utils.js
,匹配不到再去匹配utils.json
,如果還找不到就報錯。
因此extensions
陣列越長,或者正確後綴的檔案越靠後,匹配的次數越多也就越耗時,因此我們可以從以下幾點來優化:
以上範例完整程式碼demo。
在我們專案中不可避免會引入第三方模組,webpack打包時也會將第三方模組作爲依賴打包進bundle中,這樣就會增加打包檔案尺寸和增加耗時,如果能合理得處理這些模組就能提升不少webpack的效能。
我們的專案通常有多個頁面或者多個頁面模組(單頁面),多個頁面之間通常都有公用的函數或者第三方模組,在每個頁面中都打包這些模組會造成以下問題:
在Webpack4之前,都是通過CommonsChunkPlugin外掛來提取公共程式碼,然而存在着以下問題
Webpack4引入了SplitChunksPlugin
外掛進行公共模組的抽取;由於webpack4開箱即用的特性,它不用單獨安裝,通過optimization.splitChunks
進行設定即可,官方給的預設設定參數如下:
module.exports = {
optimization: {
splitChunks: {
// 程式碼分割時預設對非同步程式碼生效,all:所有程式碼有效,inital:同步程式碼有效
chunks: 'async',
// 程式碼分割最小的模組大小,引入的模組大於 20000B 才做程式碼分割
minSize: 20000,
// 程式碼分割最大的模組大小,大於這個值要進行程式碼分割,一般使用預設值
maxSize: 0,
// 引入的次數大於等於1時才進行程式碼分割
minChunks: 1,
// 最大的非同步請求數量,也就是同時載入的模組最大模組數量
maxAsyncRequests: 30,
// 入口檔案做程式碼分割最多分成 30 個 js 檔案
maxInitialRequests: 30,
// 檔案生成時的連線符
automaticNameDelimiter: '~',
enforceSizeThreshold: 5000,
cacheGroups: {
vendors: {
// 位於node_modules中的模組做程式碼分割
test: /[\\/]node_modules[\\/]/,
// 根據優先順序決定打包到哪個組裏,例如一個 node_modules 中的模組進行程式碼
priority: -10
},
// 既滿足 vendors,又滿足 default,那麼根據優先順序會打包到 vendors 組中。
default: {
// 沒有 test 表明所有的模組都能進入 default 組,但是注意它的優先順序較低。
// 根據優先順序決定打包到哪個組裏,打包到優先順序高的組裏。
priority: -20,
//如果一個模組已經被打包過了,那麼再打包時就忽略這個上模組
reuseExistingChunk: true
}
}
}
}
};
複製程式碼
我們在home、list、detail三個頁面分別引入了vue.js、axios.js和公用的工具函數模組utils.js;我們首先將使用到的第三方模組提取到一個單獨的檔案,這個檔案包含了專案的基礎執行環境,一般稱爲vendors.js
;在抽離第三方模組後我們將每個頁面都依賴的公共程式碼提取出來,放到common.js
中。
module.exports = {
optimization: {
splitChunks: {
chunks: 'initial',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: 10,
name: 'vendors'
},
common: {
test: /[\\/]src[\\/]/,
priority: 5,
name: 'common'
}
}
}
}
}
複製程式碼
有時候專案依賴模組比較多,vendors.js
檔案會特別大,我們還可以對它進一步拆分,按照模組劃分:
{
//省略其他設定
cacheGroups: {
//涉及vue的模組
vue: {
test: /[\\/]node_modules[\\/](vue|vuex|vue-router)/,
priority: 10,
name: 'vue'
},
//其他模組
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: 9,
name: 'vendors'
},
common: {
test: /[\\/]src[\\/]/,
priority: 5,
name: 'common'
}
}
}
複製程式碼
DLL即動態鏈接庫(Dynamic-Link Library)的縮寫,熟悉Windows系統的童鞋在電腦中也經常能看到後綴是dll的檔案,偶爾電腦彈框警告也是因爲電腦中缺失了某些dll檔案;DLL最初用於節約應用程式所需的磁碟和記憶體空間,當多個程式使用同一個函數庫時,DLL可以減少在磁碟和記憶體中載入程式碼的重複量,有助於程式碼的複用。
在Webpack中也引入了DLL的思想,把我們用到的模組抽離出來,打包到單獨的動態鏈接庫中去,一個動態鏈接庫中可以有多個模組;當我們在多個頁面中用到某一個模組時,不再重複打包,而是直接去引入動態鏈接庫中的模組。
Webpack中整合了對動態鏈接庫的支援,主要用到的兩個外掛:
我們首先使用DllPlugin來建立動態鏈接庫檔案,在專案下新建webpack.dll.js
檔案:
const path = require("path");
const webpack = require("webpack");
module.exports = {
mode: "production",
entry: {
vue: ["vue", "vuex", "vue-router"],
vendor: ["dayjs", "axios", "mint-ui"],
},
output: {
path: path.resolve(__dirname, "public/vendor"),
// 指定檔名
filename: "[name].dll.js",
//暴露全域性變數的名稱
library: "[name]_dll_lib",
},
plugins: [
new webpack.DllPlugin({
path: path.join(__dirname, "public", "vendor", "[name].manifest.json"),
name: "[name]_dll_lib",
}),
],
};
複製程式碼
這裏entry
設定了多個入口,每個入口也有多個模組檔案;然後在package.json
新增打包命令
{
"scripts":{
"build:dll": "webpack --config=webpack.dll.js"
}
}
複製程式碼
執行npm run build:dll
後,我們在/public/vendor
目錄下得到了我們打包後的動態鏈接庫的檔案:
├── vendor.dll.js
├── vendor.manifest.json
├── vue.dll.js
└── vue.manifest.json
複製程式碼
生成出來的打包檔案正好是以兩個入口名來命名的,以vue爲例,看一下vue.dll.js
的內容:
var vue_dll_lib =
/******/ (function(modules) {
// 省略webpackBootstrap程式碼
/******/ })
/******/ ({
/***/ "./node_modules/vue-router/dist/vue-router.esm.js":
/***/ (function(module, exports, __webpack_require__) {
//省略vue-router模組程式碼
/***/ }),
/***/ "./node_modules/vue/dist/vue.runtime.esm.js":
/***/ (function(module, exports, __webpack_require__) {
//省略vue模組程式碼
/***/ }),
/***/ "./node_modules/vuex/dist/vuex.esm.js":
/***/ (function(module, exports, __webpack_require__) {
//省略vuex模組程式碼
/***/ }),
/******/ });
複製程式碼
可以看出,動態鏈接庫中包含了引入模組的所有程式碼,這些程式碼存在一個物件中,通過模組路徑作爲鍵名來進行參照;並且通過vue_dll_lib暴露到全域性;vue.manifest.json則是用來描述動態鏈接庫檔案中包含了哪些模組:
{
"name": "vue_dll_lib",
"content": {
"./node_modules/vue-router/dist/vue-router.esm.js": {
"id": "./node_modules/vue-router/dist/vue-router.esm.js",
"buildMeta": {}
},
"./node_modules/vue/dist/vue.runtime.esm.js": {
"id": "./node_modules/vue/dist/vue.runtime.esm.js",
"buildMeta": {}
},
"./node_modules/vuex/dist/vuex.esm.js": {
"id": "./node_modules/vuex/dist/vuex.esm.js",
"buildMeta": {}
},
}
}
複製程式碼
manifest.json描述了對應js檔案包含哪些模組,以及對應模組的鍵名(id),這樣我們在模板頁面中就可以將動態鏈接庫作爲外連引入,當Webpack解析到對應模組時就通過全域性變數來獲取模組:
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<!-- 引入動態鏈接庫 -->
<script src="./vendor/vendor.dll.js"></script>
<script src="./vendor/vue.dll.js"></script>
</body>
</html>
複製程式碼
最後我們在打包時,通過DllReferencePlugin
將動態鏈接庫引入到主設定中:
//webpack.config.js
{
plugins: [
new webpack.DllReferencePlugin({
context: path.join(__dirname),
manifest: require('./public/vendor/vendor.manifest.json')
}),
new webpack.DllReferencePlugin({
context: path.join(__dirname),
manifest: require('./public/vendor/vue.manifest.json')
}),
]
}
複製程式碼
注:動態鏈接庫打包到
/public/vendor
目錄下,還需要通過CopyWebpackPlugin
外掛將它拷貝到生成後的目錄中,否則會出現參照失敗的報錯;打包動態鏈接庫檔案只需要執行一次,除非以後模組升級或者引入新的模組。
引入動態鏈接庫可以將專案中一些不經常更新的模組放到外部檔案中,我們再次打包頁面邏輯程式碼時會發現構建速度有了比較大的提升,大概30%~40%,相關程式碼在demo10。
我們在專案打包時,有一些第三方的庫會從CDN引入(比如jQuery等),如果在bundle中再次打包專案就過於臃腫,我們就可以通過設定externals
將這些庫在打包的時候排除在外。
{
externals: {
'jquery': "jQuery",
'react': 'React',
'react-dom': 'ReactDOM',
'vue': 'Vue'
}
}
複製程式碼
這樣就表示當我們遇到require('jquery')
時,從全域性變數去參照jQuery
,其他幾個包也同理;這樣打包時就把jquery、react、vue和react-dom從bundle中剔除了,本範例完整程式碼demo。
Tree Shaking最早由rollup實現,後來webpack2頁實現了這項功能;Tree Shaking的字面意思是搖樹,一棵樹上有一些樹葉雖然還掛着,但是它可能已經死掉了,通過搖樹方式把這些死掉的樹葉去除。
我們專案中也是同樣的,我們並沒有用到檔案的所有模組,但是webpack仍會將整個檔案打包進來,檔案中一直用不到的程式碼就是「死程式碼」;這種情況就用用到Tree Shaking
幫我們剔除這些用不到的程式碼模組。
比如我們定義了一個utils.js
檔案導出了很多工具模組,然後在index.js
中只參照了某些模組:
//utils.js
var toString = Object.prototype.toString;
export function isArray(val) {
return toString.call(val) === '[object Array]';
}
export function isFunction(val) {
return toString.call(val) === '[object Function]';
}
export function isDate(val) {
return toString.call(val) === '[object Date]';
}
//index.js
import { isArray } from './utils'
isArray()
複製程式碼
我們希望在程式碼中只打包isArray
函數到bundle中;需要注意的是,爲了讓Tree Shaking生效,我們需要使用ES6模組化的語法,因爲ES6模組語法是靜態化載入模組,它有以下特點:
如果是require
,在執行時確定模組,那麼將無法去分析模組是否可用,只有在編譯時分析,纔不會影響執行時的狀態。
使用ES6模組後還有一個問題,因爲我們的程式碼一般都採用babel進行編譯,而babel的preset預設會將任何模組型別編譯成Commonjs,因此我們還需要修改.babelrc
組態檔:
{
"presets": [
[
"@babel/preset-env",
{
//新增modules:false
"modules": false
}
]
]
}
複製程式碼
設定好babel後我們需要讓webpack先將「死程式碼」標識出來:
{
//其他設定
optimization: {
usedExports: true,
sideEffects: true,
}
}
複製程式碼
執行打包命令後,當我們開啓輸出的bundle檔案時,我們發現雖然一些「死程式碼」還存在裏面,但是加上了一個unused harmony export
的標識
/* unused harmony export isFunction */
/* unused harmony export isDate */
var toString = Object.prototype.toString;
function isFunction(val) {
return toString.call(val) === '[object Function]';
}
function isDate(val) {
return toString.call(val) === '[object Date]';
}
複製程式碼
雖然webpack給我們指出了哪些函數用不上,但是還需要我們通過外掛來剔除;由於uglifyjs-webpack-plugin
不支援ES6語法,這裏我們使用terser-webpack-plugin
的外掛來代替它:
const TerserJSPlugin = require("terser-webpack-plugin");
module.exports = {
optimization: {
usedExports: true,
sideEffects: true,
minimize: true,
minimizer: [
new TerserJSPlugin({
cache: true,
parallel: true,
sourceMap: false,
}),
],
}
}
複製程式碼
這樣我們發現打包出來的檔案就沒有多餘的程式碼了。
注: Tree Shaking在生產環境(production)是預設開啓的
對於我們常用的一些第三方模組,我們也可以實現Tree Shaking;以lodash
爲例,它整個包有非常多的函數,但並不是所有的函數都是我們所用到的,因此我們也需要對它沒有用到的程式碼進行剔除。
//index.js
import { chunk } from 'lodash'
console.log(chunk([1,2,3,4], 2))
複製程式碼
打包出來發現包的大小還是能達到70+kb,如果只參照了chunk不應該有這麼大;我們開啓/node_modules/lodash/index.js
發現他還是使用了require的模式匯入導出模組,因此導致Tree Shaking失敗;我們先安裝使用ES6模組版本的lodash:npm i -S lodash-es
,然後修改引入包:
//index.js
import { chunk } from 'lodash-es'
console.log(chunk([1,2,3,4], 2))
複製程式碼
這樣我們生成的bundle包就小很多;本範例完整程式碼demo。
我們知道webpack會對不同的檔案呼叫不同的loader進行解析處理,解析的過程也是最耗效能的過程;我們每次改程式碼也只是修改專案中的少數檔案,專案中的大部分檔案改動的次數不是那麼頻繁;那麼如果我們將解析檔案的結果快取下來,下次發現同樣的檔案只需要讀取快取就能極大的提升解析的效能。
cache-loader可以將一些對效能消耗比較大的loader生產的結果快取在磁碟中,等下次再次打包時如果是相同的程式碼就可以直接讀取快取,減少效能消耗。
注:儲存和讀取快取也會產生額外的效能開銷,因此cache-loader適合用於對效能消耗較大的loader,否則反而會增加效能消耗
cache-loader的使用也非常簡單,安裝後在所需要快取的loader前面新增即可(因爲loader載入的順序是反向的),比如我們需要給babel-loader
新增快取:
{
//省略其他程式碼
rules: [
{
test: /\.js/,
use: [
{
loader: 'cache-loader'
},
{
loader: "babel-loader",
},
],
},
],
}
複製程式碼
然而我們發現第一次打包的速度並沒有發生明顯變化,甚至可能還比原來打包的更慢了;同時還多了/node_modules/.cache/cache-loader/
這個目錄,看名字就是一個快取檔案;我們繼續打包,下面 下麪圖表記錄了我幾次打包的耗時:
我們發現第一次打包時間都差不多,但是第二次開始快取檔案就開始發揮了重要的作用了,直接減少了75%的耗時。
除了使用cache-loader,babel-loader也提供快取功能,通過cacheDirectory
進行設定:
{
rules: [
{
test: /\.js/,
use: [
{
loader: "babel-loader",
options: {
cacheDirectory: true
}
},
],
},
],
}
複製程式碼
在/node_modules/.cache/babel-loader
也多了快取檔案。經過兩個使用結果的對比,cache-loader的效能提升更加出色一些;本範例完整程式碼demo。
HardSourceWebpackPlugin也可以爲模組提供快取功能,同意也是將檔案快取在磁碟中
首先通過npm i -D hard-source-webpack-plugin
來安裝外掛,並且在設定中新增外掛:
var HardSourceWebpackPlugin =
require('hard-source-webpack-plugin');
module.exports = {
plugins: [
new HardSourceWebpackPlugin()
]
}
複製程式碼
一般HardSourceWebpackPlugin預設快取是在/node_modules/.cache/hard-source/[hash]
目錄下,我們可以設定它的快取目錄和何時建立新的快取雜湊值。
module.exports = {
plugins: [
new HardSourceWebpackPlugin({
//設定快取目錄的路徑
//相對路徑或者絕對路徑
cacheDirectory: 'node_modules/.cache/hard-source/[confighash]',
//構建不同的快取目錄名稱
//也就是cacheDirectory中的[confighash]值
configHash: function(webpackConfig) {
return require('node-object-hash')({sort: false}).hash(webpackConfig);
},
//環境hash
//當loader、plugin或者其他npm依賴改變時進行替換快取
environmentHash: {
root: process.cwd(),
directories: [],
files: ['package-lock.json', 'yarn.lock'],
},
//自動清除快取
cachePrune: {
//快取最長時間(預設2天)
maxAge: 2 * 24 * 60 * 60 * 1000,
//所有的快取大小超過size值將會被清除
//預設50MB
sizeThreshold: 50 * 1024 * 1024
},
})
]
}
複製程式碼
通過嘗試多次打包,發現能節省大概90%的時間;本範例完整程式碼demo。
我們在事件回圈中講到過,js是一門單執行緒的語言,在同一事件線上只有一個執行緒在處理任務;因此在webpack解析到JS、CSS、圖片或者字型檔案時,它需要一個個的去解析編譯,不能同時處理多個任務;我們可以通過外掛來將任務分給多個子進程去併發執行,子進程處理完成後再將結果發送給主進程。
happypack會自動幫我們分解任務和管理進程,通過名字我們也能看出來,這是一款能夠帶來快樂的外掛。
我們通過npm i -D happypack
後就能在webpack中進行設定了:
const happypack = require("happypack");
module.exports = {
module: {
rules: [
{
test: /\.js/,
exclude: /node_modules/,
//將js檔案處理給id爲js的happypack範例
use: "happypack/loader?id=js",
}
],
},
plugins: [
//通過id標識當前happypack是處理什麼檔案的
new happypack({
id: "js",
//呼叫處理檔案的loader,用法和rules中一致
loaders: [{
loader: "babel-loader",
},
{
loader: "eslint-loader",
},
],
}),
],
}
複製程式碼
我們將rules/loader
的處理全部交給了happypack進行處理,並且通過id來呼叫具體的範例,然後在範例中設定具體的loader進行處理;在happypack的範例中除了id和loaders我們還可以設定進程數量:
//共用進程池,進程池中包含5個子進程
var happyThreadPool = happypack.ThreadPool({
size: 5
});
{
plugins: [
new happypack({
id: "js",
//開啓幾個子進程,預設3個
threads: 3,
//共用進程池
threadPool: happyThreadPool,
//是否允許 HappyPack 輸出日誌
verbose: true,
loaders: [{
loader: "babel-loader",
},
{
loader: "eslint-loader",
},
],
}),
],
}
複製程式碼
注:threads和threadPool欄位只需要設定一個即可。
我們通過happypack.ThreadPool
建立了一個包含5個子進程的共用進程池,每個happypack範例可以通過共用進程池來處理檔案;相對於給每個happypack範例分配進程,這樣可以防止佔用過多無用的進程;我們打包看一下所耗時間:
我們發現有了happypack耗時居然還增加了20%~30%,說好的多進程帶來快樂呢。
由於我們的專案不夠龐大,而載入多進程也需要耗費時間和效能,因此我們纔會出現使用了happypack反而增加耗時的情況;所以一般happypack適用於比較大的專案中;本範例完整程式碼demo。
把thread-loader放置在其他loader之前,在它之後的loader就會在一個單獨的進程池中執行,但是在進程池中執行的loader有以下限制:
因此,也就是說像MiniCssExtractPlugin.loader
等一些提取css的loader是不能使用thread-loader的;跟happypack一樣,它也只適合用於檔案較多的大專案:
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
"thread-loader",
"babel-loader"
]
}
]
}
}
複製程式碼
本範例完整程式碼demo。
轉發:https://juejin.im/post/6858905382861946894