webpackのbundle後のJavaScriptのサイズを減らしている話
辻健人
はじめに
リクルートテクノロジーズに4月に新卒入社した 辻 健人です.GitHubではmaxmellonで活動しています. 7月より,やりとりも作成もラクになるシフト管理サービス「Airシフト」
のエンハンス開発を担当しています. 以前は,React製SPAのパフォーマンスチューニング実例という内容で記事を書きました. 今回は同じSPAにおいて,いかにwebpackが生成するJavaScriptのバンドルサイズを減らすかについて紹介していきます.
webpackが,そもそも何のためのツールか,バンドルする理由などについては割愛させていただきます. そういった話は,こちらの記事 (Webpack の考え方について – mizchi’s blog) がわかりやすいと思います.
Airシフトのアーキテクチャ
Airシフトは,React-Reduxで開発されており,かつSSRを行っておりコードの大半がクライアントとサーバーサイドで同じもの(isomorphic)を利用しています.
isomorphicにしている理由は,楽にサーバーサイドレンダリングを行うためです. SSRをしている理由はこちらのスライドYou need to know SSRにあるようにFirst View Performanceを大切にしているために実施しています.
AirシフトにおけるJavaScriptの構成
AirシフトのJavaScriptの構成は,redux-plutoというボイラープレートをforkして開発しています. redux-pluto および,Airシフトでは,react-rotuer (v3) と faceyspacey/react-universal-component と dynamic import と hint コメントを組み合わせたwebpackのchunkを分ける手段 (code-splitting) を利用して,各locationごとにJavaScriptのchunkを分けています. また,分けられたchunkは,そのlocationに初めてアクセスするときに取得されるようになっています.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// src/shared/routes/index.js export default function getRoutes(store) { return ( <Route path="/"> ... <Route path="weeklyshift" data-analytics-ignore-descendants> <IndexRedirect to={thisWeek()} /> <Route path=":week" getComponent={loadWeeklyShift} onEnter={enterHooksShift({ store })} /> </Route> ... </Route> ) } // src/shared/routes/shift.js export function loadWeeklyShift(_, cb) { createUniversalComponent( () => import(/* webpackChunkName: "shift" */ '../components/organisms/WeeklyShift'), () => require.resolveWeak('../components/organisms/WeeklyShift'), chunkName, ).then((result) => cb(null, result), cb) } |
そもそも,今まで考えなしにJavaScriptのバンドルサイズを肥大化させていたのと,それに対して歯止めをかけていないという問題があります. その問題に対しての考え方に Peformance Budgets というものがあります. 参考: Start Performance Budgeting
Performance Budgets は何かと言うと,簡単にいうと利用していい最大のJavaScriptのサイズを予算として捉えてどれだけ使うか考えるというもの, つまりチームが超過してはならないJavaScriptのbundle sizeを設定することです.
気をつけなければ行けないのが,Performnce Budgets は単なるしきい値ではなく,意味のあるものでなければいけません. 例えば,Fast 3G 回線で Lighthouce で 80点を出すために,170KB などというものです.
Lighthouceというのは,google性のパフォーマンスやSEO,PWAに重点を置いた監査ツールで,ChromeのDevtools上のAuditから利用することが可能です.
現状をしらべる
まず,いままで一切 Performance Budgets を意識していなかった,Airシフトですが,どれくらい予算を超過しているか調べてみましょう. webpackには,bundleした結果を分析するためのjsonを吐き出すpluginがあります. (stats-webpack-plugin)
このプラグインを,production build用のwebpack.configに追記してbuildすることにより,stats.jsonが生成されます.
1 2 3 4 5 6 7 8 9 10 |
module.exports = { .... plugins: [ new StatsPlugin('stats.json', { chunkModules: true, }), .... ], .... } |
stats.jsonだけでは,直感的にどこの何が重いのかを調べることが出来ません.そこで,可視化用ツールである webpack-bundle-analyzer を利用します.
次のコマンドを実行することで利用が可能です.
1 |
$ npx webpack-bundler-analyzer ./path/to/stats.json --port 8765 |
すると,サーバーが立ち上がり,webpackのchunkの内訳を見ることが出来ます.
ひとまず,main.jsがとても大きいのでここに焦点を当てて容量を減らしていくことにします. 実際にmain.jsがどれくらいの多きさかは,カーソルを合わせることで確認できます
容量にして529KBです.特に 次のjsが大部分を占めている事がわかります.
- moment-timezone の latest.json
- joi-browser.js
- react-dom.production.min.js
- lodash
- bluebird.js
この中から削れるものを順番に削っていきます
実際に減らしていく
moment-timezone の latest.json を減らす
まずは,このファイルがなんのために存在しているのかを知るために中身を見てみましょう
1 2 3 4 5 6 7 8 9 10 |
{ "version": "2018e", "zones": [ "Africa/Abidjan|LMT GMT|g.8 0|01|-2ldXH.Q|48e5", "Africa/Accra|LMT GMT +0020|.Q 0 -k|012121212121212121212121212121212121212121212121|-26BbX.8 6tzX.8 MnE 1BAk MnE 1BAk MnE 1BAk MnE 1C0k MnE 1BAk MnE 1BAk MnE 1BAk MnE 1C0k MnE 1BAk MnE 1BAk MnE 1BAk MnE 1C0k MnE 1BAk MnE 1BAk MnE 1BAk MnE 1C0k MnE 1BAk MnE 1BAk MnE 1BAk MnE 1C0k MnE 1BAk MnE 1BAk MnE|41e5", "Africa/Nairobi|LMT EAT +0230 +0245|-2r.g -30 -2u -2J|01231|-1F3Cr.g 3Dzr.g okMu MFXJ|47e5", "Africa/Algiers|PMT WET WEST CET CEST|-9.l 0 -10 -10 -20|0121212121212121343431312123431213|-2nco9.l cNb9.l HA0 19A0 1iM0 11c0 1oo0 Wo0 1rc0 QM0 1EM0 UM0 DA0 Imo0 rd0 De0 9Xz0 1fb0 1ap0 16K0 2yo0 mEp0 hwL0 jxA0 11A0 dDd0 17b0 11B0 1cN0 2Dy0 1cN0 1fB0 1cL0|26e5", "Africa/Lagos|LMT WAT|-d.A -10|01|-22y0d.A|17e6", "Africa/Bissau|LMT -01 GMT|12.k 10 0|012|-2ldX0 2xoo0|39e4", .... |
中身を見てみると,タイムゾーンの情報が,600行に渡って定義されていることがわかります. jsonは,uglifyが出来ない(短い変数名などに置き換えて圧縮できない)ので非常に大きな容量を占めていという結果になっていると考えれます.
gzipされたあとのサイズで20KBもあるので非常に大きいです.
そもそも,Airシフトは現時点では,現時点では必要ないので,Asia/Tokyoのタイムゾーンさえあれば,他の情報は不要です. なので,これをwebpackのpluginを使ってAsia/Tokyoのjsonのみ定義されたファイルに差し替えましょう. 対応方法は,moment-timezone/issues/356 に掲載されていました.
まず,Asia/Tokyoのみを定義した latest.json を用意します
1 2 3 4 5 6 7 8 9 |
{ "version": "2018e", "zones": [ "Asia/Tokyo|JST JDT|-90 -a0|010101010|-QJJ0 Rb0 1ld0 14n0 1zd0 On0 1zd0 On0|38e6" ], "links": [ "Asia/Tokyo|Japan" ] } |
次に,webpackに標準で入っている,NormalModuleReplacementPlugin
を用いて,latest.json を差し替えます
1 2 3 4 5 6 7 8 9 10 11 |
module.exports = { .... plugins: [ new webpack.NormalModuleReplacementPlugin( /moment-timezone\/data\/packed\/latest\.json/, require.resolve('./misc/timezone-definitions'), ), .... ], .... } |
これで,bundle後のJavaScriptを見てみると,
before | after |
---|---|
と,latest.json が見えないほど小さくなったことがわかります 容量にして20KB近く減らすことが出来ました
bluebird を削る
bluebirdもかなり大きくて,Gzipした状態で20KB近くを占めるライブラリです. 機能としては,高速なPromise兼polyfillです.
Promiseのpolyfillはbabel-polyfillでやっているので blurbird はそもそも不要と考えました. そこで,package.json を見て npm コマンドで bluebird を削除しようとしたのですが,そもそもpackage.jsonにblurbirdはありませんでした つまり,依存先の依存先にbluebirdが存在すると推測できます.
依存先にそのモジュールがあるかどうかを調べるには,npm ls
が便利です.今回の場合,次のコマンドを実行しました. 依存先の依存先の依存先の… までしっかり見たいので,depthを雑に大きめの数字に設定しておきます.
1 2 |
$ npm ls bluebird --prod --depth=10 |
npm ls
の詳細のオプションは,ドキュメントを参照してください
調べてみると,then-sleep というライブラリが,native-or-bluebird というものに依存しており,そのモジュールが bluebird に依存している事がわかりました.
then-sleep は
1 |
await sleep(100) |
みたいな,await可能なsleepを提供しているだけだったので,自分でサクッと書き直しました.
1 2 3 4 5 6 7 |
// @flow function sleep(ms: number): Promise<void> { return new Promise((resolve: () => void) => setTimeout(resolve, ms)) } export default sleep |
これにより,依存モジュールからbluebirdを削除することが出来ました.実際に減ったか確認してみましょう.
before | after |
---|---|
無事,blurbird がなくなりました.
joi-browser を削る
そもそもjoi-browserは,何かと言うとバリデーションライブラリのjoiのブラウザ用のjsです. なぜこんなに容量が大きくなっているかというと,バリデーションのエラーメッセージがi18n対応のために,非常に豊富に用意されているからです. これは,i18n対応をしているサービスでは非常に便利ですが,Airシフトは今のところ日本語のみをサポートしているので,i18nで容量が膨らんでしまっていることが不本意でした.容量にすると,なんと43KBです.
そこで,バリデーション用のライブラリを完全に別のものに置き換えることにしました.置き換える先のライブラリは,favalidにしました. favalidの特徴としては,
- 機能が少なく軽量
- 関数型ライクなAPI
- エラーメッセージを出力するインターフェースのみを定義しておりエラーメッセージを持たない
このライブラリの方針が,本サービスとマッチしている感じて採用しました.
詳しくは,フロントエンド向けvalidator: favalidの紹介を参照してください.
joiを置き換えるときは,既存のバリデーションに対してvalid/invalidの全パターンをテストコードで定義して,そのテストコードがpassするように置き換えました. 置き換えた例のコードをすこし紹介します.
joiで書かれた次のようなバリデーターは
1 2 3 4 5 6 |
const email = Joi.string() .regex(/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~\-[\]]+@[a-z0-9-_]+\.[a-z0-9-_.]+$/) .max(256) .options({ language: { string: { regex: { base: 'メールアドレスとして無効です。正しいメールアドレスを入力してください。' } } }, }) |
favalidで書くとこのようになります
1 2 3 4 5 6 7 8 9 10 |
const email = combine( tester( (email: string) => /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~\-[\]]+@[a-z0-9-_]+\.[a-z0-9-_.]+$/.test(email), () => 'メールアドレスとして無効です。正しいメールアドレスを入力してください。', ), tester( (email: string) => email.length <= 256, () => '256文字を超えています。', ), ) |
これによりどれだけ減ったかを見てみましょう
before | after |
---|---|
joi-browser がなくなったことが確認できました.
babel-polyfill から polyfill.io に変える
そもそもbabel-polyfillは,IE11を始めとしたレガシーなブラウザで不足している機能を,レガシーなブラウザで動くコードで再実装して 同じように動かせるというものです.(例えば,Array.prototype.includes
や Promise
)
容量を見てみると,28KB になります
ただ,モダンなブラウザではこれらは不要です.そこで,便利なpolyfillを配布しているサービス,polyfill.ioを利用します.
polyfill.ioとは,User-Agentを見て配布するpolyfillを変えてくれるというものです. モダンなブラウザで,アクセスすると空になり,レガシーなブラウザでアクセスすると,フルで入っているみたいなものです. モダンなブラウザでアクセスしたときのみですが,取得するJavaScriptの容量を大きく減らせます.
before | after |
---|---|
モダンブラウザのみですが,28Kb減らすことが出来ました.
今後の課題
これまでまとめてきた施策を案件開発の合間合間で行ってきました.機能も日に日に増え,ビジネスコードもましているので, 減らした分純粋には減っていませんが,現状で 100KB ほど減らすことが出来ました.(polyfill.io に関しては,いま時点ではまだ対応されたものがリリースされていません)
before | after |
---|---|
今後やっていきたいものとしては,
- Autolinker はもう少しミニマル要件のものを作り直す
- lodashはどうにかしてTree-Shaking出来ないか調査する
- moment を date-fnsなど別ライブラリに変える
というのがあります.lodashに関しては,依存先の依存先のlodashとかも絡んできていてかなり問題が複雑な現状があります.
react-dom.production.min.js が大きい問題は,React16.x のロードマップでモダナイズして小さくするというのがあったので期待しています.
現状,パフォーマンスバジェットをまもるというよりは,収まるように目指している状況ですが,これから bundle後のJavaScriptのサイズを定期的に観測していき,不要なライブラリを削ぎ落として行きたいと考えています.
この改善が直接的に業務価値を与えるとは思いませんが,こういった細々とした点を改善していくことが,よりよりプロダクトを作る上で大事だと思います. 特に,Airシフトの場合,忙しい店長が使うサービスなので,時間に関わるものについてはシビアに捉えていきたいです.
これをきっかけに,まずbundleされたjsがどれほどの大きさか調べてみてはいかがでしょうか?