🙌

Webpackのバンドルサイズを大幅に削った話

に公開

はじめに

TROCCOではWebアプリケーションのフロントエンド開発においてバンドラーとしてWebpackを利用しています。
昨今ではバンドルサイズが肥大化してきており、ビルド時間や画面表示のパフォーマンスに影響を与えるようになってきていました。
そこで開発環境やビルド時間改善の一環としてバンドルサイズの削減にフォーカスして改善を試みてみました。

今回はまず以下の3つについて対応をすることで全体のバンドルサイズを大幅に削減できました。

  1. 国際化(i18n)リソースのコード分割
  2. APIクライアントのコード分割
  3. moment.jsのロケールファイル最適化

現状分析:バンドルサイズの問題点

最適化を始める前に、まずは現状のバンドル構成を分析し、問題点を分析してみました。

バンドル全体のサイズ

最初に、ビルド後のファイルサイズを確認したところ、全体で約443MBという非常に大きなサイズになっていました。

$ du -ch -d 1 /path/to/outputs
....
443M    total

Webpack Bundle Analyzerによる可視化

バンドルの内訳を詳細に分析するために、webpack-bundle-analyzerを導入してみました。
webpack-bundle-analyzer については既にいろんな方が記事として世に出されているので詳細は割愛します。
使い方は README.md の内容を見るだけで十分に理解でました。

webpack-bundle-analyzerを追加

$ yarn add -D webpack-bundle-analyzer

webpack.config.jsに以下のように設定を追加します:

// webpack.config.js
plugins: [
  // ...
  new BundleAnalyzerPlugin({
    analyzerMode: 'disabled', // stats.jsonファイルのみを出力
    generateStatsFile: true,
  }),
]

stats.jsonの出力と分析レポートの生成

いざビルドして stats.json を出力しようとしてみると。。。

<--- Last few GCs --->

[29359:0x26d0e8b0]   557323 ms: Scavenge 4087.6 (4131.4) -> 4084.7 (4131.6) MB, 4.2 / 0.0 ms  (average mu = 0.196, current mu = 0.176) allocation failure;
[29359:0x26d0e8b0]   557340 ms: Scavenge 4088.1 (4131.6) -> 4085.3 (4132.1) MB, 3.8 / 0.0 ms  (average mu = 0.196, current mu = 0.176) allocation failure;
[29359:0x26d0e8b0]   557352 ms: Scavenge 4088.5 (4132.1) -> 4085.8 (4132.9) MB, 4.4 / 0.0 ms  (average mu = 0.196, current mu = 0.176) allocation failure;


<--- JS stacktrace --->

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
1: 0xb9c1f0 node::Abort() [webpack]
2: 0xaa27ee  [webpack]
3: 0xd73bc0 v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [webpack]
4: 0xd73f67 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [webpack]
5: 0xf51375  [webpack]
6: 0xf52278 v8::internal::Heap::RecomputeLimits(v8::internal::GarbageCollector) [webpack]
7: 0xf62773  [webpack]
8: 0xf635e8 v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [webpack]
9: 0xf667b5 v8::internal::Heap::HandleGCRequest() [webpack]
10: 0xee491f v8::internal::StackGuard::HandleInterrupts() [webpack]
11: 0x12e51ff v8::internal::Runtime_StackGuardWithGap(int, unsigned long*, v8::internal::Isolate*) [webpack]
12: 0x17123f9  [webpack]

え〜。。。となりましたが原因はTypeScriptの型チェックで --max-old-space-size4GiB に調整してあったからなので上限を上げて再実行すると無事出力されました。

レポート(html)の出力

stats.jsonは出力しただけではただの巨大なJSONファイルにすぎません。これを見てわかってフィルタリングなどが出来る状態にするために report.html としてHTMLを生成します。

# -m staticとする事でreport.htmlが生成されます
>$ yarn webpack-bundle-analyzer /path/to/outputs/stats.json /path/to/outputs -m static

report.htmlを分析した結果、以下の3つの問題点が明らかになりました:

  1. 国際化(i18n)リソースの問題
    ビルドされたファイルのほぼ全てにi18nが2MB強のサイズで同梱されていました。

    ファイル数:117
    合計サイズ:254.53 MiB

    i18nリソース分割前

  2. APIクライアントの問題
    APIクライアントのコードも同様にほぼ全てのファイルに同梱されていて、かつ大きなサイズを占めていました。

    APIクライアント分割前

    ファイル数:690
    合計サイズ:138.63 MiB

  3. moment.jsのロケールファイルの問題 (どちらかというとおまけに近いです):
    moment.jsの全ロケールファイルがバンドルに含まれており、実際に使用する言語は一部だけでした。

    moment.jsロケール最適化前

最適化

問題点を特定したところで、それぞれに対して最適化していきます。

1. 国際化(i18n)リソースのコード分割とその結果

さっそくWebpackのsplitChunks.cacheGroups設定を変更して、i18nリソースを別チャンクに分割します。

// webpack.config.js
module.exports = {
  // ...
  optimization: {
    splitChunks: {
      cacheGroups: {
        i18n: {
          test: /[\\/]i18n[\\/]/,
          name: 'i18n',
          chunks: 'initial',
          enforce: true,
          reuseExistingChunk: true,
        },
        // ...
      }
    }
  }
}

この設定により、i18nリソースが別チャンクに分割されて各ファイルに同梱されなくなります。
分割した i18n リソースの読み込みについてはこの記事では触れません。

すると、なんと言うことでしょう! と言いたくなるような結果になりました。
別チャンクに分割した結果、全体のバンドルサイズが443MBから115MBに削減されました。

i18nリソース分割後

$ du -ch -d 1 /path/to/outputs
....
115M    total

2. APIクライアントのコード分割とその結果

同様にAPIクライアントも別チャンクに分割します。

// webpack.config.js
module.exports = {
  // ...
  optimization: {
    splitChunks: {
      cacheGroups: {
        // ...
        openapi: {
          test: /[\\/]openapi[\\/]/,
          name: 'openapi',
          chunks: 'initial',
          enforce: true,
          reuseExistingChunk: true
        },
      }
    }
  }
}

こちらも別チャンクに分割した結果、さらにバンドルサイズが115MBから46MBに削減されました。
いい感じですね〜

APIクライアント分割後

$ du -ch -d 1 /path/to/outputs
....
46M    total

3. moment.jsのロケールファイル最適化とその結果

moment.jsのロケールファイルを最適化するために、moment-locales-webpack-pluginを使用します。

$ yarn add -D moment-locales-webpack-plugin

webpack.config.jsに以下の設定を追加します:

// webpack.config.js
const webpack = require('webpack');
const MomentLocalesPlugin = require('moment-locales-webpack-plugin')

module.exports = {
  // ...
  plugins: [
    // ...
    new MomentLocalesPlugin({
      localesToKeep: [
        'en-xx',
        'ja',
        'ko'
      ], // 必要なロケールのみを指定
    }),
  ],
}

この設定により、指定したロケールファイルのみがバンドルに含まれるようになります。

moment.jsのロケールファイルを最適化した結果、不要なロケールファイルが除外され、必要な言語のみがバンドルされるようになりました。

moment.jsロケール最適化後

設定した内容の補足

今回の最適化で使用した設定について補足しておきます:

  • chunks: 'initial':初期ロードに必要なチャンクのみをバンドルして、ロード時間を短縮します。
  • enforce: true:splitChunksのロジックを強制適用します。つまり条件を満たしてる時は必ず分割することになります
  • reuseExistingChunk: true:既に存在するチャンクにモジュールが含まれている場合、新しいチャンクを作成せずに既存のチャンクを再利用します。これにより:
    • 同じモジュールが複数のチャンクで利用されなくなるため、ブラウザキャッシュの効率が向上します。
    • 新しいチャンク生成をしないため、ビルド時間も短縮されます。

まとめ

Webpackのバンドル最適化により、以下の改善が見られました:

  1. バンドルサイズが443MBから46MBへと約90%削減されされました。
  2. 開発環境や本番環境でのビルド時間が短縮されました。
  3. 不要なロケールファイルの除外により、必要な言語のみをバンドルできました。

※ ページの読み込み時間やビルド時間については本記事では焦点にしていないため、ちゃんと書きなさいよと言うお叱りの声があるとは思いつつ割愛しています。ごめんなさい。。。

今後の展望としては、webpack自体をRust製のバンドラーであるrspackに置き換えることも検討しています。これにより、さらなるビルド時間の短縮やパフォーマンスの向上が期待できます。
本記事がWebpackを使用したプロジェクトのパフォーマンス最適化の一助となれば幸いです。

株式会社primeNumber

Discussion