webpack 快速入門 系列 —— 效能

彭加李發表於2021-07-19

其他章節請看:

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

> 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:懶載入其實用到的就是上文介紹的動態匯入

預獲取

思路可能是這樣:

  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

> 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
}

其他章節請看:

webpack 快速入門 系列

相關文章