其他章節請看:
性能
本篇主要介紹 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
}
其他章節請看: