webpack 拆包:關於 splitChunks 的幾個重點屬性解析

deepfunc發表於2022-07-12

為什麼需要 splitChunks?

先來舉個簡單的栗子,wepack 設定中有 3 個入口檔案:a.jsb.jsc.js,每個入口檔案都同步 import 了 m1.js,不設定 splitChunks,配置下 webpack-bundle-analyzer 外掛用來檢視輸出檔案的內容,打包輸出是這樣的:

從分析圖中可以比較直觀的看出,三個輸出 bundle 檔案中都包含了 m1.js 檔案,這說明有重複的模組程式碼。splitChunks 的目的就是用來把重複的模組程式碼分離到單獨的檔案,以非同步載入的方式來節省輸出檔案的體積。splitChunks 的配置項很多而且感覺官方文件的一些描述不是很清楚,下面通過一些重點配置屬性和場景解釋來幫助大家理解和弄懂如何配置 splitChunks。為方便理解和簡單演示,webpack 和 splitChunks 的初始設定如下:

const path = require('path');
const BundleAnalyzerPlugin =
  require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'development',
  entry: {
    a: './src/a.js',
    b: './src/b.js',
    c: './src/c.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'async',
      
      // 生成 chunk 的最小體積(以 bytes 為單位)。
      // 因為演示的模組比較小,需要設定這個。
      minSize: 0,
    },
  },
  plugins: [new BundleAnalyzerPlugin()],
};

chunks

splitChunks.chunks 的作用是指示採用什麼樣的方式來優化分離 chunks,常用的有三種常用的取值:asyncinitialallasync 是預設值,接下來分別看下這三種設定的區別。

async

chunks: 'async' 的意思是隻選擇通過 import() 非同步載入的模組來分離 chunks。舉個例子,還是三個入口檔案 a.jsb.jsc.js,有兩個模組檔案 m1.jsm2.js,三個入口檔案的內容如下:

// a.js
import('./utils/m1');
import './utils/m2';

console.log('some code in a.js');

// b.js
import('./utils/m1');
import './utils/m2';

console.log('some code in a.js');

// c.js
import('./utils/m1');
import './utils/m2';

console.log('some code in c.js');

這三個入口檔案對於 m1.js 都是非同步匯入,m2.js 都是同步匯入。打包輸出結果如下:

對於非同步匯入,splitChunks 分離出 chunks 形成單獨檔案來重用,而對於同步匯入的相同模組沒有處理,這就是 chunks: 'async' 的預設行為。

initial

把 chunks 的這隻改為 initial 後,再來看下輸出結果:

同步的匯入也會分離出來了,效果挺好的。這就是 initialasync 的區別:同步匯入的模組也會被選中分離出來。

all

我們加入一個模組檔案 m3.js,並對入口檔案作如下更改:

// a.js
import('./utils/m1');
import './utils/m2';
import './utils/m3'; // 新加的。

console.log('some code in a.js');

// b.js
import('./utils/m1');
import './utils/m2';
import('./utils/m3'); // 新加的。

console.log('some code in a.js');

// c.js
import('./utils/m1');
import './utils/m2';

console.log('some code in c.js');

有點不同的是 a.js 中是同步匯入 m3.js,而 b.js 中是非同步匯入。保持 chunks 的設定為 initial,輸出如下:

可以到看 m3.js 單獨輸出的那個 chunks 是 b 中非同步匯入的,a 中同步匯入的沒有被分離出來。也就是在 initial 設定下,就算匯入的是同一個模組,但是同步匯入和非同步匯入是不能複用的。

把 chunks 設定為 all,再匯出康康:

不管是同步匯入還是非同步匯入,m3.js 都分離並重用了。所以 allinitial 的基礎上,更優化了不同匯入方式下的模組複用。

這裡有個問題,發現 webpack 的 mode 設定為 production 的情況下,上面例子中 a.js 中同步匯入的 m3.js 並沒有分離重用,在 mode 設定為 development 時是正常的。不知道是啥原因,如果有童鞋知道的話麻煩解釋下。

我們看到 asyncinitialall 類似層層遞進的模組複用分離優化,所以如果考慮體積最優化的輸出,那就設 chunks 為 all

cacheGroups

通過 cacheGroups,可以自定義 chunk 輸出分組。設定 test 對模組進行過濾,符合條件的模組分配到相同的組。splitChunks 預設情況下有如下分組:

module.exports = {
  // ...
  optimization: {
    splitChunks: {
      // ...
      cacheGroups: {
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

意思就是存在兩個預設的自定義分組,defaultVendorsdefaultdefaultVendors 是將 node_modules 下面的模組分離到這個組。我們改下配置,設定下將 node_modules 下的模組全部分離並輸出到 vendors.bundle.js 檔案中:

const path = require('path');
const BundleAnalyzerPlugin =
  require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'development',
  entry: {
    a: './src/a.js',
    b: './src/b.js',
    c: './src/c.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 0,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
          name: 'vendors',
        },
      },
    },
  },
  plugins: [new BundleAnalyzerPlugin()],
};

入口檔案內容如下:

// a.js
import React from 'react';
import ReactDOM from 'react-dom';

console.log('some code in a.js');

// b.js
import React from 'react';

console.log('some code in a.js');

// c.js
import ReactDOM from 'react-dom';

console.log('some code in c.js');

輸出結果如下:

所以根據實際的需求,我們可以利用 cacheGroups 把一些通用業務模組分成不同的分組,優化輸出的拆分。

舉個例子,我們現在輸出有兩個要求:

  1. node_modules 下的模組全部分離並輸出到 vendors.bundle.js 檔案中。
  2. utils/ 目錄下有一系列的工具模組檔案,在打包的時候都打到一個 utils.bundle.js 檔案中。

調整 webpack 中的設定如下:

const path = require('path');
const BundleAnalyzerPlugin =
  require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'development',
  entry: {
    a: './src/a.js',
    b: './src/b.js',
    c: './src/c.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 0,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
          name: 'vendors',
        },
        default: {
          test: /[\\/]utils[\\/]/,
          priority: -20,
          reuseExistingChunk: true,
          name: 'utils',
        },
      },
    },
  },
  plugins: [new BundleAnalyzerPlugin()],
};

入口檔案調整如下:

// a.js
import React from 'react';
import ReactDOM from 'react-dom';
import('./utils/m1');
import './utils/m2';

console.log('some code in a.js');

// b.js
import React from 'react';
import './utils/m2';
import './utils/m3';

console.log('some code in a.js');

// c.js
import ReactDOM from 'react-dom';
import './utils/m3';

console.log('some code in c.js');

輸出如下:

maxInitialRequests 和 maxAsyncRequests

maxInitialRequests

maxInitialRequests 表示入口的最大並行請求數。規則如下:

  • 入口檔案本身算一個請求。
  • import() 非同步載入不算在內。
  • 如果同時有多個模組滿足拆分規則,但是按 maxInitialRequests 的當前值現在只允許再拆分一個,選擇容量更大的 chunks。

舉個例子,webpack 設定如下:

const path = require('path');
const BundleAnalyzerPlugin =
  require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'development',
  entry: {
    a: './src/a.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 0,
      maxInitialRequests: 2,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
          name: 'vendors',
        },
        default: {
          test: /[\\/]utils[\\/]/,
          priority: -20,
          reuseExistingChunk: true,
          name: 'utils',
        },
      },
    },
  },
  plugins: [new BundleAnalyzerPlugin()],
};

入口檔案內容如下:

// a.js
import React from 'react';
import './utils/m1';

console.log('some code in a.js');

打包輸出結果如下:

按照 maxInitialRequests = 2 的拆分過程如下:

  • a.bundle.js 算一個檔案。
  • vendors.bundle.js 和 utils.bundle.js 都可以拆分,但現在還剩一個位,所以選擇拆分出 vendors.bundle.js。

maxInitialRequests 的值設為 3,結果如下:

再來考慮另外一種場景,入口依然是 a.js 檔案,a.js 的內容作一下變化:

// a.js
import './b';

console.log('some code in a.js');

// b.js
import React from 'react';
import './utils/m1';

console.log('some code in b.js');

調整為 a.js 同步匯入了 b.jsb.js 裡再匯入其他模組。這種情況下 maxInitialRequests 是否有作用呢?可以這樣理解,maxInitialRequests 是描述的入口並行請求數,上面這個場景 b.js 會打包進 a.bundle.js,沒有非同步請求;b.js 裡面的兩個匯入模組按照 cacheGroups 的設定都會拆分,那就會算進入口處的並行請求數了。

比如 maxInitialRequests 設定為 2 時,打包輸出結果如下:

設定為 3 時,打包輸出結果如下:

maxAsyncRequests

maxAsyncRequests 的意思是用來限制非同步請求中的最大併發請求數。規則如下:

  • import() 本身算一個請求。
  • 如果同時有多個模組滿足拆分規則,但是按 maxAsyncRequests 的當前值現在只允許再拆分一個,選擇容量更大的 chunks。

還是舉個例子,webpack 配置如下:

const path = require('path');
const BundleAnalyzerPlugin =
  require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'development',
  entry: {
    a: './src/a.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 0,
      maxAsyncRequests: 2,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
          name: 'vendors',
        },
        default: {
          test: /[\\/]utils[\\/]/,
          priority: -20,
          reuseExistingChunk: true,
          name: 'utils',
        },
      },
    },
  },
  plugins: [new BundleAnalyzerPlugin()],
};

入口及相關檔案內容如下:

// a.js
import ('./b');

console.log('some code in a.js');

// b.js
import React from 'react';
import './utils/m1';

console.log('some code in b.js');

這個時候是非同步匯入 b.js 的,在 maxAsyncRequests = 2 的設定下,打包輸出結果如下:

按照規則:

  • import('.b') 算一個請求。
  • 按 chunks 大小再拆分 vendors.bundle.js

最後 import './utils/m1' 的內容留在了 b.bundle.js 中。如果將 maxAsyncRequests = 3 則輸出如下:

這樣 b.js 中匯入的 m1.js 也被拆分出來了。實際情況中,我們可以根據需求來調整 maxInitialRequestsmaxAsyncRequests,個人覺得預設設定已經夠用了。

總結

splitChunks 的設定非常複雜。通過以上的規則講解和舉例,相信大家已經明白拆包中幾個關鍵屬性的使用,我個人覺得也是官方文件解釋比較迷的幾個,剩餘的其他屬性大家可以通過官方文件找到答案。
我的 JS 部落格:小聲比比 JavaScript

相關文章