webpack 快速入門 系列 —— 性能


其他章節請看:

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 快速入門 系列


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM