其他章節請看:

webpack 快速入門 系列

效能

本篇主要介紹 webpack 中的一些常用效能,包括熱模組替換、source map、oneOf、快取、tree shaking、程式碼分割、懶載入、漸進式網路應用程式、多程序打包、外部擴充套件(externals)和動態連結(dll)。

準備本篇的環境

雖然可以僅展示核心程式碼,但筆者認為在一個完整的環境中邊看邊做,舉一反三,效果更佳。

這裡的環境其實就是實戰一一文完整的示例,包含打包樣式、打包圖片、以及打包javascript

專案結果如下:

webpack-example3
- src // 專案原始碼
- index.html // 頁面模板
- index.js // 入口
- package.json // 存放了專案依賴的包
- webpack.config.js // webpack配置檔案

程式碼如下:

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=`, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<p>請檢視控制檯</p>
<span class='m-box img-from-less'></span>
</body>
</html>
// index.js
console.log('hello');
// package.json
{
"name": "webpack-example3",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack",
"dev": "webpack-dev-server"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/preset-env": "^7.14.2",
"babel-loader": "^8.2.2",
"core-js": "3.11",
"css-loader": "^5.2.4",
"eslint": "^7.26.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-webpack-plugin": "^2.5.4",
"file-loader": "^6.2.0",
"html-loader": "^1.3.2",
"html-webpack-plugin": "^4.5.2",
"less-loader": "^7.3.0",
"mini-css-extract-plugin": "^1.6.0",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"postcss-loader": "^4.3.0",
"postcss-preset-env": "^6.7.0",
"url-loader": "^4.1.1",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.2"
}
}
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin'); process.env.NODE_ENV = 'development' const postcssLoader = {
loader: 'postcss-loader',
options: {
// postcss 只是個平臺,具體功能需要使用外掛
// Set PostCSS options and plugins
postcssOptions:{
plugins:[
// 配置外掛 postcss-preset-env
[
"postcss-preset-env",
{
// browsers: 'chrome > 10',
// stage:
},
],
]
}
}
} module.exports = {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.css$/i,
// 將 style-loader 改為 MiniCssExtractPlugin.loader
use: [MiniCssExtractPlugin.loader, "css-loader", postcssLoader],
},
{
test: /\.less$/i,
loader: [
// 將 style-loader 改為 MiniCssExtractPlugin.loader
MiniCssExtractPlugin.loader,
"css-loader",
postcssLoader,
"less-loader",
],
},
{
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
// 指定檔案的最大大小(以位元組為單位)
limit: 1024*6,
},
},
],
},
// +
{
test: /\.html$/i,
loader: 'html-loader',
},
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
// +
{
// 配置處理polyfill的方式
useBuiltIns: "usage",
// 版本與我們下載的版本保持一致
corejs: { version: "3.11"},
"targets": "> 0.25%, not dead"
}
]
]
}
}
}
]
},
plugins: [
new MiniCssExtractPlugin(),
new OptimizeCssAssetsPlugin(),
new HtmlWebpackPlugin({
template: 'src/index.html'
}),
// new ESLintPlugin({
// // 將啟用ESLint自動修復功能。此選項將更改原始檔
// fix: true
// })
],
mode: 'development',
devServer: {
open: true,
contentBase: path.join(__dirname, 'dist'),
compress: true,
port: 9000,
},
};

Tip: 由於本篇不需要 eslint,為避免影響,所以先註釋。

在 webpack-example3 目錄下執行專案:

// 安裝專案依賴的包
> npm i
// 啟動服務
> npm run dev

瀏覽器會自動開啟頁面,如果看到”請檢視控制檯“,控制檯也輸出了“hello”,說明環境準備就緒。

:筆者執行 npm i 時出現了一些問題,在公司執行 npm i 驗證此文是否正確,結果下載得很慢(好似卡住了),於是改為淘寶映象 cnpm i,這次僅花少許時間就執行完畢,接著執行 npm run dev 卻在終端報錯。於是根據錯誤提示安裝 babel-loader@7 ,再次重啟服務,問題仍舊沒有解決。回家後,執行 npm i,依賴安裝成功,可能環境也很重要。

// 終端報錯
...
babel-loader@8 requires Babel 7.x (the package '@babel/core'). If you'd like to use Babel 6.x ('babel-core'), you should install 'babel-loader@7'.

熱模組替換

模組熱替換(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允許在執行時更新所有型別的模組,而無需完全重新整理。

Tip: HMR 不適用於生產環境,這意味著它應當用於開發環境

下面我們就從 html、css 和 js 三個角度來體驗熱模組替換。

啟用 hmr

此功能可以很大程度提高生產效率。我們要做的就是更新 webpack-dev-server 配置, 然後使用 webpack 內建的 HMR 外掛。

配置 hot: true 就能啟用 hmr。

// webpack.config.js
module.exports = {
devServer: {
// 開啟熱模組替換
hot: true
}
}

css 使用 hmr

新建一個 css 檔案,通過 index.js 引入:

// a.css
p{color:blue;}
// index.js
import './a.css'

首先我們先不開啟 hmr,重啟服務(npm run dev),瀏覽器文字顯示藍色。如果改為紅色(color:red;),你會發現整個頁面都重新整理了,文字變為紅色。

接著開啟hmr(hot: true),重啟服務,再次修改顏色,文字的顏色會改變,但整個頁面不會重新整理。

Tip:如果覺得每次重啟服務,都會自動開啟瀏覽器頁面,你可以註釋掉 open: true 來關閉這個特徵。

這裡 css 熱模組之所以生效,除了在 dev-server 中開啟了 hmr,另一個是藉助了 mini-css-extract-plugin 這個包;而藉助 style-loader 使用模組熱替換來載入 CSS 也這麼簡單。

html 使用 hmr

沒有開啟熱模組替換之前,修改 index.html 中的文字,瀏覽器頁面會自動重新整理;而開啟之後,修改 html 中的文字,瀏覽器頁面就不會自動重新整理。

將 index.html 也配置到入口(entry)中:

// webpack.config.js
module.exports = {
- entry: './src/index.js',
// 將 index.html 也作為入口檔案
+ entry: ['./src/index.js', './src/index.html'],
}

重啟服務,再次修改 index.html,瀏覽器頁面自動重新整理,熱模組替換對 html 沒生效。

// index.html

- <p>請檢視控制檯</p>
+ <p>請檢視控制檯2</p>

Tip:熱模組替換,就是一個模組發生了變化,只變更這一個,其他模組無需變化;而 index.html 不像 index.js 會有多個模組,index.html 只有一個模組,就是它自己,所以也就不需要熱模組替換。

js 使用 hmr

首先在 dev-server 中開啟 hmr,然後建立一個 js 模組,接著在 index.js 中引入:

// a.js
const i = 1;
console.log(i);
// index.js
// 引入 a.js 模組
import './a';

此刻,你若修改 i 的值(const i = 2;),則會發現瀏覽器頁面會重新整理。

要讓熱模組替換在 js 中生效,我們需要修改程式碼:

// index.js

// 引入 a.js 模組
import './a'; if (module.hot) {
module.hot.accept('./a', () => {
console.log('Accepting the updated printMe module!');
});
}

再次修改 i 的值,控制檯會輸出新的值,但瀏覽器頁面不會再重新整理。

此時,如果你嘗試給入口檔案(index.js)底部增加一條語句 console.log('a');,你會發現瀏覽器還是會重新整理。

所以這種方式對入口檔案無效,只能處理非入口 js。

:如果一個 js 模組沒有 HMR 處理函式,更新就會冒泡(bubble up)。

小結

模組熱替換比較難以掌握。

社群還提供許多其他 loader,使 HMR 與各種框架和庫平滑地進行互動:

  • Vue Loader: 此 loader 支援 vue 元件的 HMR,提供開箱即用體驗。
  • React Hot Loader: 實時調整 react 元件。

source map

source map,提供一種原始碼到構建後代碼的對映,如果構建後代碼出錯了,通過對映可以方便的找到原始碼出錯的地方。

初步體驗

我們先故意弄一個語法錯誤,看瀏覽器的控制檯如何提示:

// a.js
const i = 1;
// 下一行語法錯誤
console.log(i)();
// 控制檯提示 a.js 第3行出錯
Uncaught TypeError: console.log(...) is not a function a.js:3

點選“a.js:3”,顯示內容為:

var i = 1; // 下一行語法錯誤

console.log(i)();

定位到了原始碼,很清晰。

假如換成 es6 的語法,點選進入的錯誤提示就沒這麼清晰了。請看示例:

// a.js
class Dog {
constructor(name) {
this.name = name;
} say() {
console.log(this.name)();
}
} new Dog('xiaole').say();
...
var Dog = /*#__PURE__*/function () {
function Dog(name) {
_classCallCheck(this, Dog); this.name = name;
} _createClass(Dog, [{
key: "say",
value: function say() {
console.log(this.name)(); // {1}
}
}]); return Dog;
}(); new Dog('xiaole').say();

錯誤提示會定位了行{1},我們看到的不在是自己編寫的原始碼,而是通過 babel 編譯後的程式碼。

接下來我們通過配置 devtool,選擇一種 source map 格式來增強除錯過程。不同的值會明顯影響到構建(build)和重新構建(rebuild)的速度。

Tip:Devtool 控制是否生成,以及如何生成 source map。

// webpack.config.js
module.exports = {
devtool: 'source-map'
}

重啟服務,通過錯誤提示點選進去,則會看到如下程式碼:

class Dog {
constructor(name) {
this.name = name;
} say() {
console.log(this.name)(); // {1}
}
} new Dog('xiaole').say();

不在是編譯後的程式碼,而是我們的原始碼,而且在行{1}處,對錯誤也有清晰的提示。

不同的值

source map 格式有多種不同的值,以下是筆者對其中幾種值的研究結論:

  • devtool: 'source-map'
> npm run build

1. 會生成一個 dist/main.js.map 檔案
2. 在 dist/main.js 最後一行,有如下一行程式碼:
//# sourceMappingURL=main.js.map
3. 上文我們知道,除錯能看到原始碼,官網文件的描述是 `quality 是 original`
4. 構建(build)速度和重建(rebuild)速度都是最慢(slowest)
5. 官網推薦其可作為生產的選擇
  • devtool: inline-source-map
> npm run build

1. 沒生成一個 dist/main.js.map 檔案
2. 在 dist/main.js 最後一行,有如下一行程式碼:
//# sourceMappingURL=data:application/json;charset=
3. 除錯能看到原始碼
4. 構建(build)速度和重建(rebuild)速度都是最慢(slowest)
  • devtool: eval-source-map
> npm run build

1. 沒生成一個 dist/main.js.map 檔案
2. 在 dist/main.js 中有 15 處 sourceMappingURL。而 inline-source-map 只有一處。
3. 除錯能看到原始碼
4. 構建(build)速度最慢(slowest),但重建(rebuild)速度正常(ok)
5. 官網推薦其可作為開發的選擇
  • devtool: hidden-source-map
> npm run build

1. 生成一個 dist/main.js.map 檔案
2. 點選錯誤提示,看到的是編譯後的程式碼
Uncaught TypeError: console.log(...) is not a function main.js:11508
3. 構建(build)速度和重建(rebuild)速度都是最慢(slowest)

:官網說 hidden-source-map 的品質是 original,但筆者這裡卻是編譯後的!

如何選擇

source map 有很多不同的值,我們該如何選擇?

幸好官網給出了建議。

開發環境,我們要求構建速度要快,方便除錯:

  • eval-source-map,每個模組使用 eval() 執行,並且 source map 轉換為 DataUrl 後新增到 eval() 中。初始化 source map 時比較慢,但是會在重新構建時提供比較快的速度,並且生成實際的檔案。行數能夠正確對映,因為會對映到原始程式碼中。它會生成用於開發環境的最佳品質的 source map。

生成環境,考慮到程式碼是否要隱藏,是否需要方便除錯:

  • source-map,整個 source map 作為一個單獨的檔案生成。它為 bundle 添加了一個引用註釋,以便開發工具知道在哪裡可以找到它。官網推薦其可作為生產的選擇。
  • (none)(省略 devtool 選項),不生成 source map,也是一個不錯的選擇

Tip:若你還有一些特別的需求,就去官網尋找答案

oneOf

oneof 與下面程式的 break 作用類似:

let count = 1
for(; count < 10; count++){
if(count === 3){
break;
}
}
console.log(`匹配了${count}次`) // 匹配了3次

這段程式碼,只要 count 等於 3,就會被 break 中斷退出迴圈。

通常,我們會這樣定義多個規則:

module: {
rules: [{
test: /\.css$/i,
loader: ...
},
{
test: /\.css$/i,
loader: ...
},
{
test: /\.less$/i,
loader: ...
},
{
test: /\.(png|jpg|gif)$/i,
loader: ...
}
...
]

當 a.css 匹配了第一個規則,還會繼續嘗試匹配剩餘的規則。而我希望提高一下效能,只要匹配上,就不在匹配剩餘規則。則可以使用 Rule.oneOf,就像這樣:

module: {
rules: [
{
oneOf: [{
test: /\.css$/i,
loader: ...
},
{
test: /\.less$/i,
loader: ...
},
{
test: /\.(png|jpg|gif)$/i,
loader: ...
}
...
]
}
]

如果同一種檔案需要執行多個 loader,就像這裡 css 有 2 個 loader。我們可以把其中一個 loader 提到 rules 中,就像這樣:

module: {
rules: [
{
test: /\.css$/i,
// 優先執行
enforce: 'pre'
loader: ...
},
{
oneOf: [{
test: /\.css$/i,
loader: ...
},
...
]
}
]

Tip: 可以通過配置 enforce 指定優先執行該loader

快取

babel 快取

讓第二次構建速度更快。

配置很簡單,就是給 babel-loader 新增一個選項:

{
loader: 'babel-loader',
options: {
presets: [
...
],
// 開啟快取
cacheDirectory: true
}
}

Tip:因為要經過 babel-loader 編譯,如果程式碼量太少,就不太準確,建議找大量的 es6 程式碼自行測試。

靜態資源的快取

Tip: 本小節講的其實就是 hash、chunkhash和conenthash。

通常我們將程式碼編譯到 dist 目錄中,然後釋出到伺服器上,對於一些靜態資源,我們會設定其快取。

具體做法如下:

通過命令 npm run build 將程式碼編譯到 dist 目錄;

接著通過 express 啟動服務,該服務會讀取 dist 中的內容,相當於把程式碼釋出到伺服器上:

// 安裝依賴
> npm i -D express@4
// 在專案根目錄下建立一個服務:server.js
const express = require('express')
const app = express()
const port = 3001 app.use(express.static('dist')); // 監聽服務
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
})
> nodemon server.js
[nodemon] 2.0.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node server.js`
Example app listening at http://localhost:3001

通過瀏覽器訪問 http://localhost:3001,多重新整理幾次,在網路中會看見 main.js 的狀態是 304,筆者這裡的時間在2ms或5ms之間。

Tip:304 仍然會發送請求,通常請求頭中 If-Modified-Since 的值和響應頭中 Last-Modified 的值是相同的。

If-Modified-Since: Sat, 17 Jul 2021 02:34:06 GMT

Last-Modified: Sat, 17 Jul 2021 02:34:06 GMT

接下來我給靜態資源增加快取,這裡就增加一個 10 秒的快取:

// server.js

- app.use(express.static('dist'));
+ app.use(express.static('dist', { maxAge: 1000 * 10 }));

再次請求,發現 main.js 首先是 304,接下來10秒內狀態碼則是200,大小則指示來自記憶體,時間也變為 0 ms。過10秒後再次請求,又是 304。

現在有一個問題,在強快取期間,如果出現了bug,我們哪怕修復了,使用者使用卻還是快取中有問題的程式碼。

我們模擬一下這個過程圖:先將快取改長一點,比如 1 天,使用者訪問先輸出 1,讓瀏覽器快取後,我們再修改程式碼讓其輸出 2,使用者再次訪問會輸出什麼?

// server.js
app.use(express.static('dist', { maxAge: '1d' }));
// index.js
console.log('1');

重新打包生成 dist,接著使用者通過瀏覽器訪問,控制檯輸出 1。

修改 js,重新打包生成 dist,再次訪問,控制檯還是輸入 1。

// index.js
console.log('2');

:不要強刷,因為使用者不知道強刷,也不會去管。

於是我們打算從檔名入手來解決此問題,我們依次來看看 hash、chunkhash和conenthash。

hash

核心程式碼如下:

// index.js
import './a.css'
console.log('1');
// a.css
p{color:red;}
// webpack.config.js

module.exports = {
output: {
filename: 'main.[hash:10].js',
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].[hash:10].css",
})
]
}

重新打包:

> npm run build

> [email protected] build
> webpack Hash: b2e057d598ca9092abd3
Version: webpack 4.46.0
Time: 4837ms
Built at: 2021-07-14 8:17:54 ├F10: PM┤
Asset Size Chunks Chunk Names
index.html 417 bytes [emitted]
main.b2e057d598.css 12 bytes main [emitted] [immutable] main
main.b2e057d598.js 5.22 KiB main [emitted] [immutable] main
Entrypoint main = main.b2e057d598.css main.b2e057d598.js main.b2e057d598.js.map

主要看生成的 css 和 js 檔案,名字中都帶有相同的值 b2e057d598,取的是生成的 Hash 的前10位。index.html 中也會自動引入對應的檔名。

現在瀏覽器訪問,文字是紅色,控制檯輸出1。

接著模擬修復缺陷,將文字改為藍色,再次打包。

p{color:blue;}
> npm run build

> [email protected] build
> webpack Hash: ed2cd907a36536276d20
Version: webpack 4.46.0
Time: 4771ms
Built at: 2021-07-14 8:29:14 ├F10: PM┤
Asset Size Chunks Chunk Names
index.html 417 bytes [emitted]
main.ed2cd907a3.css 13 bytes main [emitted] [immutable] main
main.ed2cd907a3.js 5.22 KiB main [emitted] [immutable] main

瀏覽器訪問,文字確實變為藍色。但 js 和 css 都重新請求了,再看打包生成的檔案,js 和 css 也都重新生成了新的檔名。這個會導致一個問題,只修改一個檔案,其他的所有快取都會失效。

Tip:這裡修復的是 css,如果修復 js 也同樣會導致所有快取失效。

chunkhash

hash 會導致所有快取失效,我們將其改為 chunkhash,還是存在相同的問題。請看示例:

將 hash 改為 chunkhash:

// webpack.config.js

module.exports = {
output: {
filename: 'main.[chunkhash:10].js',
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].[chunkhash:10].css",
})
]
}

修改 css,然後重新打包,發現 js 和 css 檔案也都重新生成了,雖然 chunkhash 與 hash 值不相同,但 main.js 和 main.css 中的 chunkhash 是一樣的:

> npm run build

> [email protected] build
> webpack Hash: 8c1c035175aae3d36fea
Version: webpack 4.46.0
Time: 5000ms
Built at: 2021-07-14 9:16:46 ├F10: PM┤
Asset Size Chunks Chunk Names
index.html 417 bytes [emitted]
main.619734f520.css 13 bytes main [emitted] [immutable] main
main.619734f520.js 5.22 KiB main [emitted] [immutable] main

Tip: 通過入口檔案引入的模組都屬於一個 chunk。這裡 css 是通過入口檔案(index.js)引入的,所以 main.js 和 main.css 的 chunkhash 值相同。

contenthash

contenthash 是根據檔案內容來的,可以較好的解決以上問題。請看示例:

將 chunkhash 改為 contenthash,然後打包:

// webpack.config.js

module.exports = {
output: {
filename: 'main.[contenthash:10].js',
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].[contenthash:10].css",
})
]
}
> npm run build

> [email protected] build
> webpack Hash: 12994324788654e2ffc4
Version: webpack 4.46.0
Time: 5115ms
Built at: 2021-07-14 9:26:59 ├F10: PM┤
Asset Size Chunks Chunk Names
index.html 417 bytes [emitted]
main.21668176f0.css 12 bytes main [emitted] [immutable] main
main.8983191438.js 5.22 KiB main [emitted] [immutable] main

這次,js 和 css 的 hash 值不在相同。通過瀏覽器訪問多次後,main.js 和 main.css 也都被強快取。

修改css:

p{color:yellow;}

打包發現 js(main.8983191438.js) 沒有變,只有 css 變了:

> npm run build

> [email protected] build
> webpack Hash: 1598c3794090ebc6964c
Version: webpack 4.46.0
Time: 4905ms
Built at: 2021-07-14 9:31:14 ├F10: PM┤
Asset Size Chunks Chunk Names
index.html 417 bytes [emitted]
main.0241bb73c4.css 13 bytes main [emitted] [immutable] main
main.8983191438.js 5.22 KiB main [emitted] [immutable] main

再次通過瀏覽器訪問,發現 css 請求了新的檔案,而 js 還是來自快取。

Tip: 是否要將 hash 清除?

注:此刻執行 npm run build 會報錯,為了不影響下面的介紹,所以將 hash 去除,source map 也不需要,一併刪除。

ERROR in chunk main [entry]
Cannot use [chunkhash] or [contenthash] for chunk in 'main.[contenthash:10].js' (use [hash] instead)

tree shaking

tree shaking 是一個術語,通常用於描述移除 JavaScript 上下文中的未引用程式碼(dead-code)。

使用樹搖非常簡單,只需要滿足兩個條件:

  • 使用 es6 模組化
  • 模式(mode)開啟production

直接演示,請看:

a.js 中匯出 a 和 b,但在index.js 中只使用了a:

// a.js
export let a = 'hello'
export let b = 'jack'
// index.js
import { a } from './a.js'
console.log(a);

首先在開發模式下測試,發現 a.js 中的”hello“和”jack“都打包進去了,請看示例:

module.exports = {
mode: 'development',
}
// dist/main.js
// a 和 b 都被打包進來,儘管 b 沒有被用到 var a = 'hello';
var b = 'jack';

而在生成模式下,只有用到的 a 才被打包進去,請看示例:

module.exports = {
mode: 'production',
}
// dist/main.js
// 只找到 hello,沒有找到 jack console.log("hello")

將檔案標記為 side-effect-free(無副作用)

在一個純粹的 ESM 模組世界中,很容易識別出哪些檔案有副作用。然而,我們的專案無法達到這種純度,所以,此時有必要提示 webpack compiler 哪些程式碼是“純粹部分”。

通過 package.json 的 "sideEffects" 屬性,來實現這種方式。

{
"sideEffects": false
}

如果所有程式碼都不包含副作用,我們就可以簡單地將該屬性標記為 false,來告知 webpack 它可以安全地刪除未用到的 export。

Tip:"side effect(副作用)" 的定義是,在匯入時會執行特殊行為的程式碼,而不是僅僅暴露一個 export 或多個 export。舉例說明,例如 polyfill,它影響全域性作用域,並且通常不提供 export。

我們通過一個例子說明下:

在入口檔案引入 css 檔案:

// index.js
import './a.css'
import { a } from './a.js'
console.log(a);
// a.css
p{color:yellow;}
// webapck.config.js
mode: 'production'

打包會生成 css:

> npm run build

     Asset       Size  Chunks             Chunk Names
index.html 342 bytes [emitted]
main.css 13 bytes 0 [emitted] main
main.js 1.3 KiB 0 [emitted] main

在 package.json 新增 "sideEffects": false,標註所有程式碼都不包含副作用:

{
"sideEffects": false
}

再次打包,則不會生成 css:

> npm run build

     Asset       Size  Chunks             Chunk Names
index.html 303 bytes [emitted]
main.js 1.3 KiB 0 [emitted] main

:所有匯入檔案都會受到 tree shaking 的影響。這意味著,如果在專案中使用類似 css-loader 並 import 一個 CSS 檔案,則需要將其新增到 side effect 列表中,以免在生產模式中無意中將它刪除:

// package.json
{
"sideEffects": [
"*.css",
"*.less"
]
}

程式碼分割

將一個檔案分割成多個,載入速度可能會更快,而且分割成多個檔案後,還可以實現按需載入。

optimization.splitChunks

對於動態匯入模組,預設使用 webpack v4+ 提供的全新的通用分塊策略(common chunk strategy) —— SplitChunksPlugin。

開箱即用的 SplitChunksPlugin 對於大部分使用者來說非常友好。

webpack 將根據以下條件自動拆分 chunks:

  • 新的 chunk 可以被共享,或者模組來自於 node_modules 資料夾
  • 新的 chunk 體積大於 20kb(在進行 min+gz 之前的體積)
  • 當按需載入 chunks 時,並行請求的最大數量小於或等於 30
  • 當載入初始化頁面時,併發請求的最大數量小於或等於 30

Tip: SplitChunksPlugin的預設配置如下:

// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'async',
minSize: 20000,
minRemainingSize: 0,
minChunks: 1,
maxAsyncRequests: 30,
maxInitialRequests: 30,
enforceSizeThreshold: 50000,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};

預設配置很多,如果我們不需要修改,則不用管它們,下面我們來體驗一下 splitChunks.chunks:

Tip:splitChunks.chunks,表明將選擇哪些 chunk 進行優化。當提供一個字串,有效值為 all,async 和 initial。設定為 all 可能特別強大,因為這意味著 chunk 可以在非同步和非非同步 chunk 之間共享。

> npm i lodash@4
// index.js
import _ from 'lodash'; console.log(_);

打包只生成一個 js:

> npm run build

     Asset       Size  Chunks             Chunk Names
index.html 303 bytes [emitted]
main.js 72.7 KiB 0 [emitted] main

配置splitChunks.chunks:

// webapck.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
},
},
};

再次打包,這次生成兩個 js,其中Chunk Names 是 vendors~main 對應的就是 loadsh:

> npm run build

     Asset       Size  Chunks             Chunk Names
1.main.js 71.5 KiB 1 [emitted] vendors~main
index.html 336 bytes [emitted]
main.js 1.9 KiB 0 [emitted] main

同一個 chunk 中,如果 index.js 和 a.js 都引入 loadash,會如何打包?請看示例:

// index.js
import {a} from './a.js'
import _ from 'lodash';
console.log(a)
console.log(_);
// a.js
export let a = 'hello'
export let b = 'jack'
> npm run build

     Asset       Size  Chunks             Chunk Names
1.main.js 71.5 KiB 1 [emitted] vendors~main
index.html 336 bytes [emitted]
main.js 1.92 KiB 0 [emitted] main

同樣是兩個 js,而且 loadash 應該是公用了,因為 main.js 較上次只增加了 0.02 kb。

動態匯入

使用動態匯入可以分離出 chunk。

請看示例:

上文我們知道,這段程式碼打包會生成兩個 js,其中 main.js 包含了 a.js。

// index.js
import {a} from './a.js'
import _ from 'lodash';
console.log(a)
console.log(_);

將其中的 a.js 改為動態匯入的方式:

// index.js

import _ from 'lodash';
// 動態匯入
import(/* webpackChunkName: 'a' */'./a').then((aModule) => {
console.log(aModule.a);
});
console.log(_);

打包:

> npm run build

     Asset       Size  Chunks             Chunk Names
0.main.js 192 bytes 0 [emitted] a
2.main.js 94.6 KiB 2 [emitted] vendors~main
index.html 336 bytes [emitted]
main.js 2.75 KiB 1 [emitted] main

其中 a.js 被單獨打包成一個js(從 Chunk Names 為 a 可以得知)

懶載入

懶載入就是用到的時候在載入。

請看示例:

我們在入口檔案註冊一個點選事件,只有點選時才載入 a.js。

// index.js
document.body.onclick = function () {
// 動態匯入
import(/* webpackChunkName: 'a' */'./a').then((aModule) => {
console.log(aModule.a);
});
};
// a.js
console.log('moduleA');
export let a = 'hello'
export let b = 'jack'

啟動服務,測試:

> npm run dev

第一次點選:moduleA hello

第二次點選:hello

只有第一次點選,才會請求 a.js 模組。

Tip:懶載入其實用到的就是上文介紹的動態匯入

預獲取

思路可能是這樣:

  1. 首先使用普通模式
  2. 普通模式下,一次性載入太多,而 a.js 這個檔案又有點大,於是就使用懶載入,需要使用的時候在載入 a.js
  3. 觸發點選事件,懶載入 a.js,但 a.js 很大,需要等待好幾秒中才觸發,於是我想預獲取來減少等待的時間

將懶載入改為預獲取:

// index.js
document.body.onclick = function () {
// 動態匯入
import(/* webpackChunkName: 'a', webpackPrefetch: true*/'./a').then((aModule) => {
console.log(aModule.a);
});
};

重新整理瀏覽器,發現 a.js 被載入了;觸發點選事件,輸出 moduleA hello,再次點選,輸出 hello。

Tip:瀏覽器中有如下一段程式碼:

// 指示著瀏覽器在閒置時間預取 0.main.a3f7d94cb1.js
<link rel="prefetch" as="script" href="0.main.a3f7d94cb1.js">

預獲取和懶載入的不同是,預獲取會在空閒的時候先載入。

漸進式網路應用程式

漸進式網路應用程式(progressive web application - PWA),是一種可以提供類似於 native app(原生應用程式) 體驗的 web app(網路應用程式)。PWA 可以用來做很多事。其中最重要的是,在離線(offline)時應用程式能夠繼續執行功能。這是通過使用名為 Service Workers 的 web 技術來實現的。

我們首先通過一個包來啟動服務:

> npm i -D http-server@0
// package.json
{
"scripts": {
"start": "http-server dist"
},
}
> npm run build

啟動服務:

> npm run start

> [email protected] start
> http-server dist Starting up http-server, serving dist
Available on:
http://192.168.85.1:8080
http://192.168.75.1:8080
http://192.168.0.103:8080
http://127.0.0.1:8080
Hit CTRL-C to stop the server

:多個 url 與介面卡有關:

> ipconfig

乙太網介面卡 VMware Network Adapter VMnet1:
IPv4 地址 . . . . . . . . . . . . : 192.168.85.1 乙太網介面卡 VMware Network Adapter VMnet8:
IPv4 地址 . . . . . . . . . . . . : 192.168.75.1 無線區域網介面卡 WLAN:
IPv4 地址 . . . . . . . . . . . . : 192.168.0.103

通過瀏覽器訪問 http://127.0.0.1:8080。如果我們將伺服器關閉,再次重新整理頁面,則不能再訪問。

接下來我們要做的事:通過離線技術讓網頁再伺服器關閉時還能訪問。

請看示例:

新增 workbox-webpack-plugin 外掛,然後調整 webpack.config.js 檔案:

> npm i -D workbox-webpack-plugin@6
// webapck.config.js
const WorkboxPlugin = require('workbox-webpack-plugin');
module.exports = {
plugins: [
new WorkboxPlugin.GenerateSW({
// 這些選項幫助快速啟用 ServiceWorkers
// 不允許遺留任何“舊的” ServiceWorkers
clientsClaim: true,
skipWaiting: true,
}),
],
};

完成這些設定,再次打包,看下會發生什麼:

> npm run build

              Asset       Size  Chunks             Chunk Names
0.main.js 192 bytes 0 [emitted] a
2.main.js 94.6 KiB 2 [emitted] vendors~main
index.html 336 bytes [emitted]
main.js 2.75 KiB 1 [emitted] main
service-worker.js 1.11 KiB [emitted]
workbox-15dd0bab.js 13.6 KiB [emitted]

生成了兩個額外的檔案:service-worker.js 和 workbox-15dd0bab.js。service-worker.js 是 Service Worker 檔案。

值得高興的是,我們現在已經創建出一個 Service Worker。接下來我們註冊 Service Worker。

// index.js
document.body.onclick = function () {
// 動態匯入
import(/* webpackChunkName: 'a', webpackPrefetch: true*/'./a').then((aModule) => {
console.log(aModule.a);
});
}; if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js').then(registration => {
console.log('SW registered: ', registration);
}).catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}

再次執行 npm run build 來構建包含註冊程式碼版本的應用程式。然後用 npm start 啟動服務。訪問 http://127.0.0.1:8080/ 並檢視 console 控制檯。在那裡你應該看到:

SW registered

Tip:如果沒有看見 SW registered,可以嘗試強刷

現在來進行測試。停止 server 並重新整理頁面。如果瀏覽器能夠支援 Service Worker,應該可以看到你的應用程式還在正常執行。然而,server 已經停止 serve 整個 dist 資料夾,此刻是 Service Worker 在進行 serve。

Tip:更過 pwa 可以參考 "mdn 漸進式應用程式";淘寶(taobao.com)以前有 pwa,現在卻沒有了。

多程序打包

通過多程序打包,用的好可以加快打包的速度,用得不好甚至會更慢。

這裡使用一個名為 thread-loader 包來做多程序打包。每個 worker 是一個單獨的 node.js 程序,開銷約 600 毫秒,還有一個程序間通訊的開銷。

:僅將此載入器用於昂貴的操作!比如 babel

我們演示一下:

未使用多程序打包時間是 3122ms:

// index.js
import _ from 'lodash'
console.log(_);
> npm run build
Hash: a4868f457d7ce754335b
Version: webpack 4.46.0
Time: 3031ms

加入多執行緒:

> npm i -D thread-loader@3
// webpack.config.js -> module.exports -> module.rules
{
test: /\.js$/,
exclude: /node_modules/,
use: [
'thread-loader',
{
loader: 'babel-loader',
...
}
]
}
> npm run build

Hash: a4868f457d7ce754335b
Version: webpack 4.46.0
Time: 3401ms

構建時間更長。

Tip: 可能是程式碼中需要 babel 的 js 程式碼太少,所以導致多執行緒效果不明顯。

外部擴充套件(externals)

externals 配置選項提供了「從輸出的 bundle 中排除依賴」的方法。

externals

防止將某些 import 的包(package)打包到 bundle 中,而是在執行時(runtime)再去從外部獲取這些擴充套件依賴(external dependencies)。

例如 jQuery 這個庫來自 cdn,則不需要將 jQuery 打包。請看示例:

Tip: 為了測試看得更清晰,註釋掉 pwa 和 splitChunks。

> npm i jquery@3
// index.js
import $ from 'jquery'; console.log($);

打包生成一個 js,其中包含了 jquery:

> npm run build

              Asset       Size  Chunks             Chunk Names
1.main.js 88 KiB 1 [emitted] vendors~main
index.html 336 bytes [emitted]
main.js 1.9 KiB 0 [emitted] main

由於開啟了 splitChunks,這裡 1.main.js 就是 jquery。

使用 external 將 jQuery 排除:

// webpack.config.js
module.exports = {
externals: {
// jQuery 是jquery暴露給window的變數名,這裡可以將 jQuery 改為 $,但 jquery 卻不行
jquery: 'jQuery'
}
};

在 index.html 中手動引入 jquery:

// src/index.html

<script src="https://cdn.bootcdn.net/ajax/libs/jquery/1.7.2/jquery.min.js"></script>

Tip: 我們使用 bootstrap cdn。

再次打包,則不在包含 jquery:

> npm run build

              Asset        Size  Chunks             Chunk Names
index.html 303 bytes [emitted]
main.js 1.35 KiB 0 [emitted] main

Tip:如果你在開發模式(mode: 'development')下打包,你會發現 main.js 中會有如下這段程式碼:

/***/ "jquery":
/*!*************************!*\
!*** external "jQuery" ***!
\*************************/
/*! no static exports found */
/***/ (function(module, exports) { eval("module.exports = jQuery;\n\n//# sourceURL=webpack:///external_%22jQuery%22?"); /***/ })

這裡的 jQuery 來自我們手動通過 <script src=> 引入 jquery 所產生的全域性變數。

動態連結(dll)

所謂動態連結,就是把一些經常會共享的程式碼製作成 DLL 檔,當可執行檔案呼叫到 DLL 檔內的函式時,Windows 作業系統才會把 DLL 檔載入儲存器內,DLL 檔本身的結構就是可執行檔,當程式有需求時函式才進行連結。透過動態連結方式,儲存器浪費的情形將可大幅降低。

對於 webpack 就是事先將常用又構建時間長的程式碼提前打包好,取名為 dll,後面打包時則直接使用 dll,用來提高打包速度

vue-cli 刪除了 dll

在 vue-cli 提交記錄中發現:remove DLL option。

原因是:dll 選項將被刪除。 Webpack 4 應該提供足夠好的效能,並且在 Vue CLI 中維護 DLL 模式的成本不再合理。

Tip: 詳情請看issue

核心程式碼

附上專案最終核心檔案,方便學習和解惑。

webapck.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const WorkboxPlugin = require('workbox-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin'); process.env.NODE_ENV = 'development' const postcssLoader = {
loader: 'postcss-loader',
options: {
// postcss 只是個平臺,具體功能需要使用外掛
// Set PostCSS options and plugins
postcssOptions: {
plugins: [
// 配置外掛 postcss-preset-env
[
"postcss-preset-env",
{
// browsers: 'chrome > 10',
// stage:
},
],
]
}
}
} module.exports = {
entry: './src/index.js',
entry: ['./src/index.js', './src/index.html'],
output: {
filename: 'main.js',
// filename: 'main.[contenthash:10].js', path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.css$/i,
// 將 style-loader 改為 MiniCssExtractPlugin.loader
use: [MiniCssExtractPlugin.loader, "css-loader", postcssLoader],
},
{
test: /\.less$/i,
loader: [
// 將 style-loader 改為 MiniCssExtractPlugin.loader
MiniCssExtractPlugin.loader,
"css-loader",
postcssLoader,
"less-loader",
],
},
{
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
// 指定檔案的最大大小(以位元組為單位)
limit: 1024 * 6,
},
},
],
},
// +
{
test: /\.html$/i,
loader: 'html-loader',
},
{
test: /\.js$/,
exclude: /node_modules/,
use: [
// 'thread-loader',
{
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
// +
{
// 配置處理polyfill的方式
useBuiltIns: "usage",
// 版本與我們下載的版本保持一致
corejs: { version: "3.11" },
"targets": "> 0.25%, not dead"
}
]
],
// 開啟快取
cacheDirectory: true
}
}]
}
]
},
plugins: [
// new MiniCssExtractPlugin(),
new MiniCssExtractPlugin({
// filename: "[name].[contenthash:10].css",
}),
new OptimizeCssAssetsPlugin(),
new HtmlWebpackPlugin({
template: 'src/index.html'
}),
// new ESLintPlugin({
// // 將啟用ESLint自動修復功能。此選項將更改原始檔
// fix: true
// }),
new WorkboxPlugin.GenerateSW({
// 這些選項幫助快速啟用 ServiceWorkers
// 不允許遺留任何“舊的” ServiceWorkers
clientsClaim: true,
skipWaiting: true,
}),
],
mode: 'development',
// mode: 'production',
devServer: {
// open: true,
contentBase: path.join(__dirname, 'dist'),
compress: true,
port: 9000,
},
devServer: {
// 開啟熱模組替換
hot: true
},
// devtool: 'eval-source-map',
optimization: {
splitChunks: {
chunks: 'all',
},
},
externals: {
// jQuery 是jquery暴露給window的變數名,這裡可以將 jQuery 改為 $,但 jquery 卻不行
jquery: 'jQuery'
}
};

package.json

{
"name": "webpack-example3",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack",
"dev": "webpack-dev-server",
"start": "http-server dist"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/preset-env": "^7.14.2",
"babel-loader": "^8.2.2",
"core-js": "3.11",
"css-loader": "^5.2.4",
"eslint": "^7.26.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-webpack-plugin": "^2.5.4",
"express": "^4.17.1",
"file-loader": "^6.2.0",
"html-loader": "^1.3.2",
"html-webpack-plugin": "^4.5.2",
"http-server": "^0.12.3",
"less-loader": "^7.3.0",
"mini-css-extract-plugin": "^1.6.0",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"postcss-loader": "^4.3.0",
"postcss-preset-env": "^6.7.0",
"thread-loader": "^3.0.4",
"url-loader": "^4.1.1",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.2",
"workbox-webpack-plugin": "^6.1.5"
},
"dependencies": {
"jquery": "^3.6.0",
"lodash": "^4.17.21",
"vue": "^2.6.14"
},
"sideEffects": false
}

其他章節請看:

webpack 快速入門 系列