本文所用示例的倉庫地址: gayhub
上一節我們解決了工程的開發除錯問題,專案的生產和開發環境也已配置完成,還約定了 Webpack 配置檔案規範。但它還很粗糙,這一節我們就來一起打磨這套配置。
開發體驗優化
熱模組替換
在之前的配置中我們使用使用 MiniCssExtractPlugin.loader
來代替 style-loader
,因為我們需要把 CSS 從 JS 中分離出來。但 MiniCssExtractPlugin 目前還存在一個隱患,那就是它可能會影響到 hmr (熱模組替換)功能,在它對 hmr 的支援前,我們只能在生產環境中使用它。
webpack.base.conf.js
module.exports = {
module: {
rules: [
{
test: /\.styl(us)?$/,
use: [
process.env.NODE_ENV !== 'production' ?
'vue-style-loader' : {
loader: resolve('node_modules/mini-css-extract-plugin/dist/loader.js'),
options: {
publicPath: '../'
}
},
{
loader: 'css-loader',
options: {
importLoaders: 2 // 在 css-loader 前執行的 loader 數量
}
},
'postcss-loader',
{
loader: 'stylus-loader',
options: {
preferPathResolver: 'webpack' // 優先使用 webpack 用於路徑解析,找不到再使用 stylus-loader 的路徑解析
}
}
]
}
]
}
}
複製程式碼
實際上我上次使用它時看見有 hmr 配置項,就以為已經支援了,具體支援與否請看 MiniCssExtractPlugin Docs
提升構建速度:使用 DllPlugin & DllReferencePlugin 提前打包公共依賴
當專案達到一定體量,打包速度、熱載入效能優化的需求就會被提出來,畢竟誰也不願意修改後花上十幾秒甚至幾分鐘等待修改檢視更新。接下里我會介紹一些通用的優化策略,但需要注意的是,專案本身不能去踩一些無法優化的坑,已知兩坑:超多頁( html-webpack-plugin 熱更新時更新所有頁面)和動態載入未指明明確路徑(打包目錄下所有頁面)。
DllPlugin 和 DllReferencePlugin 絕對是優化打包速度的最佳利器,它可以把部分公共依賴提前打包好,在之後的打包中就不再打包這些依賴而是直接取用已經打包好的程式碼,通常情況能降低 20% ~ 40% 打包時間,當然它也有缺點:
- 需要在初始化和相關依賴更新時,額外執行一條命令
- 通常 dll 是在
.html
檔案中引入,濫用會導致首屏載入變慢
但總歸來說是利大於弊。
-
新增
webpack.dll.conf.js
const webpack = require('webpack') const { CleanWebpackPlugin } = require('clean-webpack-plugin') const { resolve } = require('./utils') const libs = { _frame: ['vue', 'vue-router', 'vuex'], _utils: ['lodash'] } module.exports = { mode: 'production', entry: { ...libs }, performance: false, output: { path: resolve('dll'), filename: '[name].dll.js', library: '[name]' // 與 DllPlugin.name 保持一致 }, plugins: [ new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: [] }), new webpack.DllPlugin({ name: '[name]', path: resolve('dll', '[name].manifest.json'), context: resolve('') }) ] } 複製程式碼
-
在
webpack.common.conf.js
使用 DllReferencePluginwebpack.common.conf.js
const { generateDllReferences, generateAddAssests } = require('./utils') module.exports = { plugins: [ ...generateAddAssests(), ...generateDllReferences() ] } 複製程式碼
# add-asset-html-webpack-plugin 用於把 dll 新增到 `index.html` 的 script 標籤中 # glob 支援正則匹配檔案 yarn add add-asset-html-webpack-plugin glob -D 複製程式碼
utils.js
const webpack = require('webpack') const glob = require('glob') const AddAssestHtmlWebpackPlugin = require('add-asset-html-webpack-plugin') const generateDllReferences = function() { const manifests = glob.sync(`${resolve('dll')}/*.json`) return manifests.map(file => { return new webpack.DllReferencePlugin({ // context: resolve(''), manifest: file }) }) } const generateAddAssests = function() { const dlls = glob.sync(`${resolve('dll')}/*.js`) return dlls.map(file => { return new AddAssestHtmlWebpackPlugin({ filepath: file, outputPath: '/dll', publicPath: '/dll' }) }) } 複製程式碼
-
新增 npm scripts
package.json
"scripts": { "dll": "webpack --config build/webpack.dll.conf.js" }, 複製程式碼
然後就可以用 yarn dll
打包配置好的全域性公共依賴了,打包後會在 src/dll
目錄生成 *dll.js
和 *dll.json
,前者是依賴經壓縮合並後的檔案( mode: production
),後者是 *dll.js
檔案和原始依賴的對映檔案,用於被 DllReferencePlugin 解析建立引用和 *dll.js
之間的對映關係。
構建中最耗時的兩步是 babel 和壓縮, babel 一般會配置忽略 node_modules
所以 DllPlugin 節約的是部分公共依賴的壓縮時間,所以你如果不想用 DllPlugin 也可以在 externals
中將他們配置為外部依賴,用其他方式去壓縮並引入他們
提升構建速度:生成合理的 Source Map
在 webpack 4 中,是否生成 Source Map 以及生成怎樣的 Source Map 是由 devtool
配置控制的,選擇合理的 Source Map 可以有效的縮短打包時間。在選擇前我們還是應該明白,不設定 Source Map 時打包是最快的,之所以需要 Source Map ,是因為打包後的程式碼結構、檔名和打包前完全不一致,當存在報錯時我們只能直接定位到打包後的某個檔案,無法定位到原始檔,極大程度增加了除錯難度。而 Source Map 就是為了增強打包後程式碼的可除錯性而存在的,所以我們在開發環境總是需要它,在生產環境則有更多選擇。
devtool
可選配置有 none
、 eval
、 cheap-eval-source-map
等 13 種,各自功能和效能比較在 文件 中有詳細介紹。
配置項由一個或多個單詞和連字元組成,每個單詞都有其含義和效能損耗,每個配置項最終意義就由這些單詞決定:
none
不生成 Source Map ,效能 +++eavl
每個模組由eval
執行,不能正確顯示行數,不能用生產模式,效能 +++module
報錯顯示原始程式碼,效能 -source
報錯顯示行列資訊,顯示 babel 轉譯後程式碼,效能 --cheap
低開銷模式,不對映列,效能 +inline
不生成單獨的 Source Map 檔案,效能 o
開發環境
由於開發模式建議顯示報錯原始碼和行資訊,所以 module
和 source
都是需要的,為了效能我們又需要 eval
和 cheap
,所以參照配置項能找到最適合開發環境的配置是 devtool: cheap-module-eval-source-map
。
生產環境
生產環境由於幾乎不存在除錯需求( JS 相關除錯),所以建議大家設定 devtool: none
,在需要除錯的時候再更改設定為 devtool: cheap-module-source-map
。
提升構建速度:其他優化
本小節中提到的優化其實幾乎都是我們之前配置中的某一個預設配置
JS 多執行緒壓縮
之前有提到過,壓縮是構建中耗時佔比較大的一環,我們可以啟用 terser-webpack-plugin 的多執行緒壓縮,減少壓縮時間。
module.exports = {
optimization: {
minimizer: [
new TerserJSPlugin({
parallel: true // 開啟多執行緒壓縮
})
]
}
}
複製程式碼
babel 範圍限定和快取
module.exports = {
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
// 不轉譯 node_modules
// exclude: [resolve('node_modules')]
// 轉譯 src 目錄下的檔案
include: [
resolve('src')
],
options: {
cacheDirectory: true // 預設目錄 node_modules/.cache/babel-loader
// cacheDirectory: resolve('/.cache/babel-loader')
}
}
]
}
}
複製程式碼
vue-loader 快取
vue-loader 的 cacheDirectory
配置項依賴 cache-loader
yarn add cache-loader -D
複製程式碼
module.exports = {
module: {
rules: [
{
test: /\.vue$/,
use: {
loader: 'vue-loader',
options: {
prettify: false,
cacheDirectory: resolve('node_modules/.cache/vue-loader'),
cacheIdentifier: 'vue'
}
}
}
]
}
}
複製程式碼
resolve.modules 使用絕對路徑
resolve.modules
告知 Webpack 解析模組時應該搜尋的目錄,可以設定相對路徑和絕對路徑。設定相對路徑時,比如 resolve.modules: [node_modules]
,在解析依賴時會從當前目錄向上查詢,直到找到 node_modules
目錄。設定絕對路徑時能減少了這個遍歷過程,直接定位目錄。
module.exports = {
resolve: {
modules: [resolve('node_modules')]
}
}
複製程式碼
我之前也說了,這個優化項聊勝於無。
使用 ES6
ES6 不僅在原有物件上新增了一些常用方法,還新增了一些新的詞法和語法給開發者帶來了極大便利,它自然是不能缺席本專案的。但不同瀏覽器、不同版本對 ES6 的支援不一致,導致使用 ES6 是還存在些許阻礙,我們需要用 babel-loader 把 ES6 的詞法和語法轉換為 ES5。
前段時間剛介紹過 babel-loader / babel 7 ,這裡就不再重複介紹,詳情見 babel-loader 使用指南
使用 EditorConfig 和 eslint
多人合作專案約定編碼規範是非常重要的,因為它能有效提高協作效率並抑制程式設計師怒氣值增長。當然我認為個人專案也是需要的,因為 6 個月前的程式碼和別人的程式碼一樣。
EditorConfig
EditorConfig 是一個跨編輯器的程式碼規範解決方案,獲得了眾多編輯器的支援(編輯器或外掛實現支援),這意味著不同編輯器可以格式化出同樣風格的程式碼,比如 vscode 和 sublime 。配置方式是在專案根目錄增加一個 .editorconfig
檔案,部分編輯器可以通過命令一鍵生成,通常其配置如下:
.editorconfig
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
複製程式碼
root
是否為頂級配置檔案,通常設定為true
表示搜尋到該配置檔案後不再繼續向上查詢配置indent_style
Tab 縮排方式indent_size
縮排大小,上面示例就表示:縮排表現為兩個空格charset
編碼方式trim_trailing_whitespace
是否刪除行尾空格insert_final_newline
檔案是否以空行結束
eslint
Javascript 是一門動態語言(弱型別語言),靈活但易錯,所以在協作開發中需要制定一些規則保證各個成員輸出風格一致的程式碼。 eslint 正是用於應對這個問題的開源工具,你可以設定規則,它則基於規則檢查 Javascript 是否合法,不合法則返回錯誤或警告。
-
安裝
yarn add eslint -D 複製程式碼
-
初始化配置檔案
npx eslint --init 複製程式碼
根據提示選擇需要的項並安裝對應的 plugin 和 config ,我這裡還需要安裝
eslint-config-standard
和eslint-plugin-vue
yarn add eslint-config-standard eslint-plugin-vue -D 複製程式碼
.eslintrc.js
module.exports = { "env": { "browser": true, "es6": true }, "extends": [ "plugin:vue/essential", "standard" ], "globals": { "Atomics": "readonly", "SharedArrayBuffer": "readonly" }, "parserOptions": { "ecmaVersion": 2018, "sourceType": "module" }, "plugins": [ "vue" ], "rules": { } } 複製程式碼
-
在 rules 配置相應規則
見中文文件
使用 webpack-bundle-analyzer 分析打包資訊
有時候我們會發現有些生成物的大小不對勁,但在控制檯又很難看出來原因,這個時候就需要模組分析工具的幫助。這裡我推薦使用 webpack-bundle-analyzer ,它會啟動一個服務,在瀏覽器中很清楚地展現生成物和原始檔的對映關係和層級,如圖所示(圖來源於 Github ):
安裝
yarn add webpack-bundle-analyzer -D
複製程式碼
使用
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
複製程式碼
使用者體驗優化
在構建層面優化使用者體驗在於以下幾個方面: 縮小生成程式碼體積、合理的載入方式、合理減少 HTTP 請求數,本小節主要講前兩者。
減少 HTTP 的優化,我們在使用 url-loader 處理圖片時就提及到了
壓縮 CSS
Webpack 4 在 production
模式下是會預設壓縮 JS 程式碼的,使用 TerserWebpackPlugin,但 CSS 不會( Webpack 5 會作為內建功能 ),所以我們需要 OptimizeCSSAssetsPlugin 的幫助。
安裝
yarn add optimize-css-assets-webpack-plugin -D
複製程式碼
使用
Webpack 外掛使用大多大同小異,但在 Webpack 4 中使用這個外掛需要特別注意,使用它時會重寫 optimization.minimizer
選項,而壓縮 JS 的外掛 TerserWebpackPlugin 恰好就在這個選項的預設值中,重寫會導致預設值失效,所以你還需要顯式地宣告 TerserWebpackPlugin 例項。
webpack.prod.conf.js
const TerserJSPlugin = require("terser-webpack-plugin")
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin")
module.exports = {
optimization: {
minimizer: [
new TerserJSPlugin({
parallel: true // 開啟多執行緒壓縮
}),
new OptimizeCSSAssetsPlugin({})
]
}
}
複製程式碼
壓縮是生產環境下的優化,開發環境去設定它反而會影響到熱載入效能、適得其反
程式碼分離
程式碼分離有兩個優點:
-
剝離公共程式碼和依賴,避免重複打包
-
避免單個檔案體積過大
總載入體積一致,瀏覽器載入多個檔案通常快於單個檔案
分離前測試
我們先在專案中加上會被 home 和 page-a 公共引用的資源: src/utils/index.js
& src/styles/main.styl
,然後再在兩個頁面分別引用他們,以 page-a.vue
舉例。
為了方便檢查生成程式碼,我們設定 mode: development
以獲得未被壓縮的程式碼
page-a.vue
<template>
<div class="page-a">
<h1>
This is page-a
</h1>
</div>
</template>
<script>
import { counter } from '@/utils'
export default {
name: 'page-a',
created() {
counter()
console.log('page-a:', counter.count)
}
}
</script>
<style lang="stylus" scoped>
@import '~@/styles/main.styl';
.page-a {
background: blue;
}
</style>
複製程式碼
執行 yarn build
打包專案,然後我們就會在 home.vue
對應的生成物( dist/css/views/home.[contentHash].css
和 dist/views/home.[contentHash].js.
)中看到,他們包含了 src/styles/main.styl
和 src/utils/index.js
檔案中的所需內容。然而,我們再去檢查 page-a.vue
對應的生成物,發現他們同樣包含了這些內容,所以一份原始碼被打包到了兩個頁面對應的生成物中。
被重複打包是因為這兩個頁面同時引用了他們,當引用次數是 3 次、 10 次或者更多,這些公共資源(包括公共依賴)甚至可以佔到生成物體積的 95% 以上,這顯然是不可接受的。
SplitChunksPlugin
為了解決公共資源被重複打包問題,我們就需要 SplitChunksPlugin 的幫助,它可以把程式碼分離成不同的 bundle ,在頁面需要時被載入。另外 SplitChunksPlugin 是 webpack 4 的內建外掛,所以我們不需要去獨立安裝它。
使用
webpack.prod.conf.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
minSize: 1, // 正常設定 20000+ 即 20k+ ,但這裡我們的公共檔案只有幾行程式碼,所以設定為 1
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '/',
name(mod, chunks) {
return ${chunks[0].name}
},
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}
}
複製程式碼
執行 yarn build
打包專案,我們可以看到只有 dist/views/home.[contentHash].js.
(或者一個單獨的 JS 中) 才會包含 utils.js
內容,而 dist/views/page-a.[contentHash].js.
中只有引用: _utils__WEBPACK_IMPORTED_MODULE_0__["counter"]
。
更改輸出命名
我們可以使用 VSCode 來除錯打包配置程式碼( nodejs ),得到 name
函式中的 mod / chunks
的物件結構,根據資訊返回我們需要的檔名。
當然你也可以用 -inspect
來除錯程式碼。
// 命名和程式碼分離息息相關,這裡僅為使用示例,具體命名請根據專案情況更改
name(mod, chunks) {
if (chunks[0].name === 'app') return 'app.vendor'
if (/src/.test(mod.request)) {
let requestName = mod.request.replace(/.*\\src\\/, '').replace(/"/g, '')
if (requestName) return requestName
} else if (/node_modules/.test(mod.request)) {
return 'dependencies/' + mod.request.match(/node_modules.[\w-]+/)[0].replace(/node_modules./, '')
}
return null
}
複製程式碼
更多的情況是設定魔法註釋來規定檔名,而不是通過 name 函式設定,因為後者往往會將一些不該分離的程式碼分離
tree shaking
上面我們分離程式碼,解決了專案中部分程式碼被重複打包到多個生成物中的問題,有效地縮小了生成物體積,但其實我們還可以在此基礎上進一步縮小體積,這就涉及本小節的概念 tree shaking 。
tree shaking 是一個術語,通常用於描述移除 JavaScript 上下文中的未引用程式碼(dead-code)。它依賴於 ES2015 模組語法的 靜態結構 特性,例如 import 和 export。
你可以將應用程式想象成一棵樹。綠色表示實際用到的 source code(原始碼) 和 library(庫),是樹上活的樹葉。灰色表示未引用程式碼,是秋天樹上枯萎的樹葉。為了除去死去的樹葉,你必須搖動這棵樹,使它們落下。
我們回頭看在使用 SplitChunksPlugin 時生成的檔案,可以發現 say
函式沒有使用但是卻被打包進來了,它實際上是無用的程式碼,也就是文件中說的 dead-code 。要刪除這些程式碼,只需要把 mode
修改為 production
(讓 tree shaking 生效),再次打包~
不過需要注意的是, tree shaking 能移除無用程式碼的同時,也有一定的副作用(錯誤識別無用程式碼)。比如你可能會遇到 UI 元件庫沒有樣式的問題,這個問題原因在於 tree shaking 不僅對 JS 生效,也對 CSS 生效 。我們通常在匯入 CSS 時使用 import 'xxx.min.css'
, ES6 的靜態匯入 + 生產環境滿足了 tree shaking 的生效條件,並且 Webpack 無法判斷 CSS 有效,所以它被當做了 dead-code 然後被刪除。為了解決這個問題,你可以在 package.json
中新增一個 sideEffects
選項,告知 Webpack 那些檔案是可以直接引入而不用 tree shaking 檢查的,使用如下:
package.json
{
"sideEffects": [
"*.css",
"*.styl(us)?"
]
}
複製程式碼
資源載入
合理的資源載入方式有時比縮小程式碼體積更重要
按需載入
按需載入又名懶載入,是指當需要依賴的頁面被開啟採取載入這個依賴,這樣就減少了主頁的負擔,提升首屏渲染速度。而要做到按需載入,你只需在匯入依賴的時候用 import()
或 require.ensure
這兩種動態載入方式。我們新增 lodash
依賴來做測試: yarn add lodash
page-a.vue
// 靜態載入
import _ from 'lodash'
// 懶載入
// import(/* webpackChunkName: "dependencies/lodash" */ 'lodash')
export default {
name: 'page-a',
created() {
console.log(_.now())
}
}
複製程式碼
此時啟動一下開發服務,我們可以看到,雖然這裡用了靜態載入,但其實 lodash 依賴還是在點選進入了 page-a 才會被載入(懶載入)。因為我們在使用設定路由的時候,就已經使用過了 import()
動態載入(這一點我忘記了),所以 page-a 頁面的靜態資源也一起變作了懶載入。
我們再看一下之前的動態載入語句 import(/* webpackChunkName: "views/home" */ '@/views/home/main.vue')
,這其中有一個值得注意的知識點 /* webpackChunkName: "views/home" */
,它是 Webpack 的魔法註釋,這裡是通過魔法註釋指定生成 chunk 的檔名,所以該 src/views/home/main.vue
檔案打包後的 JS 就在 dist/views/home.[contentHash].js
。
預載入、預取
上面講到使用魔法註釋為生成物命名,其實預載入 preload 和預取 prefetch 也是通過魔法註釋來設定的。這裡是官方文件上有他們的異同介紹:
- preload chunk 會在父 chunk 載入時,以並行方式開始載入。prefetch chunk 會在父 chunk 載入結束後開始載入。
- preload chunk 具有中等優先順序,並立即下載。prefetch chunk 在瀏覽器閒置時下載。
但在我的測試中,無論是 preload 還是 prefetch 都是並行載入的,但他們優先順序會比當前頁面所需依賴更低,不會影響到頁面載入。你可以在 main.js
中新增以下程式碼進行測試:
src/main.js
// 對比測試
// import 'lodash'
// 預載入
// import(/* webpackPreload: true, webpackChunkName: "dependencies/lodash" */ 'lodash')
// 預取
import(/* webpackPrefetch: true, webpackChunkName: "dependencies/lodash" */ 'lodash')
複製程式碼
本小節的測試結果由於和文件不符,希望大家自行驗證,不可偏信
用預載入和預取處理體積較大的依賴效果尤為明顯,比如圖表、富文字編輯器
externals 外部擴充套件
有時我們會在專案中直接引入一些體積不小 JS 庫(本文以 lodash 舉例), Webpack 會去解析並壓縮它們。但仔細想想,lodash 本身就存在已經壓縮好的版本 lodash/lodash.min.js
,再加上其體積也不小沒必要再與其他 JS 合併( 未壓縮 700k ,壓縮後 70k ),我們去解析和壓縮它的意義並不大。所以我們可以把它放到 static
資料夾(或 CDN),並在 index.html
中用 script
標籤引入,如果專案中有引入 lodash ( import 'lodash'
)則可以配置 externals
在打包時忽略它,沒有則不用。
如果這裡想用 link[ref=prefetch/preload]
進行預載入,那麼一定不要忘了在合適的地方再用 script
標籤引入,預載入只是為了快取
-
新增
/static
資料夾,加入lodash.min.js
-
程式碼改動
yarn add copy-webpack-plugin -D 複製程式碼
/build/webpack.prod.conf.js
const CopyWebpackPlugin = require('copy-webpack-plugin') module.exports = { externals: { lodash: { commonjs: 'lodash', umd: 'lodash', root: '_' // 預設執行環境已經存在全域性變數: _ ,瀏覽器中就是 window._ } }, plugins: [ new CopyWebpackPlugin([ { from: 'static/', to: 'static/' } ]) ] } 複製程式碼
/src/main.js
import _ from 'lodash' console.log(_.now()) 複製程式碼
/index.html
<body> <script type="text/javascript" src="static/lodash.min.js"></script> </body> 複製程式碼
有些朋友可能認為解析 lodash 可以讓 Webpack 知道哪些函式是沒用到的,然後 tree shaking 掉它們,但其實 lodash 並不是 ES6 模組語法的靜態匯出,所以 tree shaking 不會生效。如果專案並不是重度依賴 lodash ,只是使用了其中幾個函式,建議匯入單個函式,如下:
import now from 'lodash/now'
console.log(now())
複製程式碼
module.noParse
在文件中被介紹也可以忽略打包某些模組,但遺憾的是當前它還無甚用處。因為要讓它生效你就不能在程式碼中去引用相關依賴,實際上沒有引用就不會記錄到依賴圖中,自然就不會被打包(所以這個配置項什麼都沒做)。