其他章節請看:
效能
本篇主要介紹 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
> webpack-example3@1.0.0 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
> webpack-example3@1.0.0 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
> webpack-example3@1.0.0 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
> webpack-example3@1.0.0 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
> webpack-example3@1.0.0 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:懶載入其實用到的就是上文介紹的動態匯入
預獲取
思路可能是這樣:
- 首先使用普通模式
- 普通模式下,一次性載入太多,而 a.js 這個檔案又有點大,於是就使用懶載入,需要使用的時候在載入 a.js
- 觸發點選事件,懶載入 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
> webpack-example3@1.0.0 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
}
其他章節請看: