Webpackのバンドルサイズを大幅に削った話
はじめに
TROCCOではWebアプリケーションのフロントエンド開発においてバンドラーとしてWebpackを利用しています。
昨今ではバンドルサイズが肥大化してきており、ビルド時間や画面表示のパフォーマンスに影響を与えるようになってきていました。
そこで開発環境やビルド時間改善の一環としてバンドルサイズの削減にフォーカスして改善を試みてみました。
今回はまず以下の3つについて対応をすることで全体のバンドルサイズを大幅に削減できました。
- 国際化(i18n)リソースのコード分割
- APIクライアントのコード分割
- 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-size
を 4GiB
に調整してあったからなので上限を上げて再実行すると無事出力されました。
レポート(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つの問題点が明らかになりました:
-
国際化(i18n)リソースの問題:
ビルドされたファイルのほぼ全てにi18nが2MB強のサイズで同梱されていました。ファイル数:117
合計サイズ:254.53 MiB -
APIクライアントの問題:
APIクライアントのコードも同様にほぼ全てのファイルに同梱されていて、かつ大きなサイズを占めていました。ファイル数:690
合計サイズ:138.63 MiB -
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に削減されました。
$ 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に削減されました。
いい感じですね〜
$ 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のロケールファイルを最適化した結果、不要なロケールファイルが除外され、必要な言語のみがバンドルされるようになりました。
設定した内容の補足
今回の最適化で使用した設定について補足しておきます:
- chunks: 'initial':初期ロードに必要なチャンクのみをバンドルして、ロード時間を短縮します。
- enforce: true:splitChunksのロジックを強制適用します。つまり条件を満たしてる時は必ず分割することになります
-
reuseExistingChunk: true:既に存在するチャンクにモジュールが含まれている場合、新しいチャンクを作成せずに既存のチャンクを再利用します。これにより:
- 同じモジュールが複数のチャンクで利用されなくなるため、ブラウザキャッシュの効率が向上します。
- 新しいチャンク生成をしないため、ビルド時間も短縮されます。
まとめ
Webpackのバンドル最適化により、以下の改善が見られました:
- バンドルサイズが443MBから46MBへと約90%削減されされました。
- 開発環境や本番環境でのビルド時間が短縮されました。
- 不要なロケールファイルの除外により、必要な言語のみをバンドルできました。
※ ページの読み込み時間やビルド時間については本記事では焦点にしていないため、ちゃんと書きなさいよと言うお叱りの声があるとは思いつつ割愛しています。ごめんなさい。。。
今後の展望としては、webpack自体をRust製のバンドラーであるrspackに置き換えることも検討しています。これにより、さらなるビルド時間の短縮やパフォーマンスの向上が期待できます。
本記事がWebpackを使用したプロジェクトのパフォーマンス最適化の一助となれば幸いです。
Discussion