フロントエンドを100倍速くした( ^ω^)


おはようございます、なのくろです。年の瀬ですね。
この記事は ABEJA Advent Calendar 2020 の最終日です。

追記:おかげさまで Qiita LGTM賞 を受賞いたしました、ありがとうございます!

私は2020年01月にABEJAへ入社しました。チームではフロントエンド開発全般を任されています。
参入してちょうど1年が経過しましたので、今年取り組んだことをまとめました。

フロントエンドを100倍速く」というタイトルは誇張気味なのですが、難しいことはせず、基本的なパフォーマンス改善を素直に実践したという話を書きます。
本稿では事例とやったことを紹介するのみですが、何かしらの知見や改善のきっかけに役立てば幸いです。

サービスについて

話をする前に、どんなサービスを開発しているかについて少しだけ触れます。
ABEJA社では「Insight for Retail」という、小売業向けに店舗の収益改善を支援するSaaSを開発しています。
店舗の天井にカメラを設置し、取得した情報をAIで分析、どうすれば売上を改善できるかといった情報を提供しています。
「来客属性、動線分析、リピート推定」といった顧客行動データの取得・分析を基軸とする、AIを活用した店舗解析サービスです。

抱えていた課題

既存サービスが抱える課題は山積していましたが「動作がめっちゃ重い」のが最も致命的な課題でした。
当時は新機能を次々と追加しているフェーズでしたが、パフォーマンス面については意識されていなかった為、

初期描画(FirstContentfulPaint)まで30秒前後掛かる
画面がフリーズして操作を受け付けない
使いづらい、使ってもらえない

といった問題が発生していました。
※補足:30秒というのは条件によって大きく変わりうる値で、実際はもう少し短いケースが多かったです。

改善していく雰囲気作り

手を動かす前に、まずはメンバーやマネージャーの理解を得て、改善に向かう雰囲気を作る必要があります。
現状がどうなっているのかを調べ、問題点をざっくり洗い出し、チームに共有するなど、いろいろ動き出す準備を進めました。

toCではないから遅くてもいい」という意見は明確に否定すべきで、SEOが無関係でもパフォーマンスが悪くてよいということは基本的にありません。動作が遅く使いづらければ利用者は困りますし去ってしまいます。
新機能の開発を優先したい」という意見は当然理解できますが、増やすだけでは重くなるばかりです。少しずつでも取り組まなければ悪化し続け、いずれサービスの死を招きます。
工数が足りない」も理解できますが、放置すればさらに工数を圧迫します。できる範囲でやっていくしかないのです。

言うまでもないですが、ここまでサービスを成長させてきたメンバーに敬意を忘れてはいけません。負債が溜まるのは仕方のないことです。

ファイルを軽くする

動作が重い」と言っても原因は様々です。
推測するな計測せよと言いますが、今回のケースではバンドルファイルのサイズが異様に大きいのが明らかな主原因でした。
当時は Vue.js で実装されていましたが、 app.js が 22MB あり、読み込みが完了するまで画面が白い状態でした。

ファイルサイズの削減はパフォーマンス改善にわかりやすく寄与します。
まずは読み込まれているライブラリを洗い出し、サイズが大きいところから見直していきました。

plotly.js

plotly はチャートを描画するライブラリです。機械学習を扱うPythonエンジニアに支持されているようです。
しかしライブラリのサイズは7MB近い重量級なのでどうにか対処する必要があります。

plotly は様々なタイプのチャートを幅広くカバーしているライブラリで、3Dでの描画などもサポートしています。
対して、サービス内部で利用されているのは棒グラフや円グラフといった標準的なものだけでした。
ライブラリ全部をimportする必要はなく、ライブラリから必要な部分だけをimportするように修正しました。
結果として「6.7MB」が「2.5MB」まで減りました。(さらにgzip後は630kbまで軽くなります)

lodash

有名かつお世話になったライブラリですが、これも重量級です。
異なるバージョンの lodash が2つバンドルされていた為、余計にファイルサイズを圧迫していました。
lodash に関しては最新の JavaScript に書き換えることで、ほとんど取り除くことができます。

また一部の便利関数(_.throttleなど)を使い続けたい場合、それ単体をimportすることで軽量化できます。

Before
import _ from 'lodash' // 全部読み込んでいる
_.throttle()
After
import throttle from 'lodash/throttle' // 必要なやつだけ
throttle()

ちなみに throttle を使うようなケースでは requestAnimationFrame に代替できるかもしれません。

moment.js

おそらく皆さんご存知、有名な日時を扱うライブラリです。
こちらも利用していないロケールファイルなど全てバンドルしていたため重量級となっていました。
そもそも moment.js は既にメンテナンスが終了しているため、使い続けるのは好ましくありません。

日付を扱うライブラリは「day.js」「date-fns」「luxon」といった軽量な選択肢がいくつもあります。
moment.js を置き換えるならば、APIが似ている day.js の採用は有力な選択肢です。
date-fns の場合は標準組み込みの Date 型を返してくれるといった違いがあるので、好みで選ぶと良いでしょう。

jQuery

Vue.js で実装されているにもかかわらず jQuery がバンドルされていました。
何かしらの歴史的経緯で使われているのですが、これに関しては素直に Vue.js で実装し直していく必要がありました。
また非同期通信については jQuery を使う必要はなく、FetchAPI や axios などのライブラリに書き換えることが可能です。

FontAwesome

大変便利なアイコンライブラリです。私個人も大好きでよく使います。
しかし安易にimportしてしまうと大量のアイコンを全部取り込んでしまいます。
regular, solid, light といった太さの異なるアイコンも全て取り込んでしまっていたので肥大化していました。
これに関しても同様に、使うアイコンだけをimportすることで軽量化できました。

Before
import { library } from '@fortawesome/fontawesome-svg-core'
import { far } from '@fortawesome/free-regular-svg-icons'
import { fas } from '@fortawesome/free-solid-svg-icons'
library.add(far, fas) // アイコン全部
After
import { library } from '@fortawesome/fontawesome-svg-core'
import { faStar } from '@fortawesome/free-regular-svg-icons'
import { faHeart } from '@fortawesome/free-solid-svg-icons'
library.add(faStar, faHeart) // つかうアイコンだけ

Firebase

細かいですが、Firebase ライブラリも全て読み込むのではなく、本番環境向けに必要なものだけimportすると良いでしょう。

Before
import firebase from 'firebase'
After
import firebase from 'firebase/app'
import 'firebase/auth'
import 'firebase/firestore'

その他ライブラリ

その他多数のライブラリに依存しており、一つ一つは軽量でも数が多いゆえにサイズを圧迫していました。
ライブラリ依存が多いとサイズの問題だけでなく、更新に追従できずセキュリティリスクを抱える可能性もあります。

できる範囲で、削れるライブラリを剥がしていく作業を地道に行いました。
加えて npm audit を確認しながら依存ライブラリのアップデートを行いました。

コンポーネントの見直し

ライブラリをざっくり削減しても、ファイルサイズは依然として大きいままでした。
さらに調査を進めていくと、そもそも実装の大部分が削減可能なコードで占めていたことがわかりました。

具体的には、色・サイズ・文字などがちょっと違うだけのUIが必要になるたび、それをコピーした新たなコンポーネントが生成され続けている状態でした。
コピペ駆動開発を禁止し、コードが重複するコンポーネントを洗い出し、Props で制御できるコンポーネントに作り変えることで、かなりのコンポーネントを排除できました。

またコンポーネント群は AtomicDesign に基づいているように見えましたが、正しく理解し運用されなければ上記のような問題が発生しえます。
(AtomicDesign は銀の弾丸ではなく、Atoms,Morecules,Organisms というディレクトリを作ればよいというものではありません)

アセットファイルの見直し

今回の主原因ではなかったのですが、読み込まれる画像ファイルも不必要にサイズが大きいものがありました。
表示する上で十分なサイズまで小さくリサイズし、さらに見た目が区別できないレベルで画像の最適化も行いました。
一般的に.jpgファイルであれば、元ファイルの80%ぐらいまで圧縮しても違いがわからない状態で圧縮できるかと思います。
アイコンのような画像については SVG にしてしまうのも良い方法です。

日本語Webフォント

きれいなフォントで表示したいのは大変良くわかりますが、パフォーマンスを犠牲にしてはいけません。
日本語フォントの収録文字数はとても多いため、当然ダウンロードに時間がかかります。
さらにフォントの太さごとに別のフォントをダウンロードしていたため、なおさら時間が掛かっていました。

これについては潔く利用しない決断をしました。利用者は必ずしも高速な回線ではないのです。
もし本気で取り組むならば、サービス内で利用している文字を抜き出したカスタムフォントファイルを生成するなどのアプローチは考えられます。
近い将来5G回線が普及する頃になったら、嬉々として好きなフォントを使いたいですね!

動作を軽くする

ファイルサイズを削るだけでは完璧ではありません。
描画後の動作を速くするためにも、併せていくつかのアプローチを行いました。

環境の違いを知る

まず先に、実際に利用者がどういった環境で利用しているのか知っておくのは重要です。
開発環境では「高速な通信回線」「ハイスペックな開発マシン」「大きめのディスプレイ」で作業しています。
対して利用者は「モバイル回線」「タブレット端末」で利用していることがわかりました。

利用者の立場で動作確認しなければ、実運用での問題に気がつけません。
開発者と利用者の接点が薄いと発生しがちですが、実際に確認したり、ログなどから得られる情報もあります。

不要なレンダリングをしない

内容が変わるたびにDOMを全部消し飛ばして再描画するような実装がちらほらあり、もっさりした挙動になっていました。
特にチャート表示は描画が最も重く、再描画するたびにフリーズする原因となっていました。
VirtualDOM の良さも損なわれてしまう為、差分がある箇所だけ再描画されるように修正を行いました。

また再描画のたびに再計算する必要がないものは computed に移植するなどの修正もおこないました。

非同期ロード

通信を見てみるとAPIを一つづつ読み込んでいたことがわかりました。
Promise の実装を見直し、並列に読み込めるものは .all().race() などに書き換えることで短縮化しました。
すぐに読み込む必要のないファイルは async オプションを用いて同期的に読み込まないなどの修正も行いました。

app.js に全部まとめるのではなく、複数ファイルに分割ビルドして読み込むなどの工夫も有効です。
さらに踏み込むならば、ServiceWorkerを活用して、別プロセスで投機的に読み込むといったアプローチも考えられます。

リクエストの削減

毎度APIから取得する必要のないデータに関しては Vuex で値を保持するように修正しました。
通信しなくて済むので当然速くなりますし、リクエスト数が減るのでコスト削減にも繋がります。

さらに踏み込むならば LocalStorage や ServiceWorker を活用してリロード後も通信をスキップできる可能性があります。
とはいえキャッシュ戦略は諸刃の剣です。安易にやると問題を生みやすいので、データの更新頻度などを踏まえて活用する必要があります。

体感としての速さ

重い処理を伴うAPIは、どうしても時間が掛かってしまうことがあります。
通信中に何も案内をしないと、画面がフリーズしたかのように見えるため体験を著しく損ねます。

予め時間がかかることが予見されている場合は、適切なローディング表示などを用意することで体感速度を改善できます。
デザインやアニメーションの工夫でも、体感としてのパフォーマンス改善に寄与することが可能です。

効果はあった

上記のような泥臭い改善を行った結果、実測値で初期描画が3秒台になりました。
まだまだ遅いですが着実にパフォーマンスは改善されました。

しかしながら、まだ着手できていないコードが大量に残っており修正作業に時間が掛かってしまいます。
新機能の開発も進める必要がありますが、このまま現状のコードに追加し続けるのは困難です。
なにより、レガシーな実装に引きずられ続けると開発速度が上がりませんし、お気持ちも上がりづらいです。
採用面においても、仲間を集める前に胸を張って紹介できる技術スタックを導入したいという思いがありました。

未来に向けて

紆余曲折を経て目処が立った為、次期リリースの新機能から別リポジトリで開発を進める判断をしました。
技術スタックは「React + Next.js + TypeScript + Recoil」などを採用しています。

TypeScript との相性の良さから React を利用しています。
ステート管理は大半のケースで Hooks だけで完結しており、必要な場合だけ Recoil を利用しています。
(※Recoil はまだ Experimental なので導入は慎重にすべきですが、強力な武器になる予感がします。)

Next.js は大変素晴らしく、パフォーマンス面でのベストプラクティスの大半を担ってくれるため、自らチューニングせずとも、素直に実装すれば高速に動作するアプリケーションを構築できます。

なにより最初から TypeScript で実装している為、開発体験が著しく向上しました。
コンパイル時にエラーに気がつけるだけでなく、実装中にエディターが叱ってくれる為、実装速度も速くなったと感じます。
noImplicitAny は適用する、複雑すぎる型定義(Genericsの入れ子とか)は避けるなど、いい感じのバランスを探っています。

現在フロントエンド担当は私だけですが、近い将来新たなメンバーが参入しても迷わず始められるよう、できるだけ一般的な設計としシンプルな実装ルールだけ定めました。
Linter や CI/CD まわりの設定も先に済ませたため、細かくデプロイする流れが自然にできたのは良かったです。

パフォーマンスが良い状態を維持するため、Lighthouse および WebVitals と向き合い、問題があればリリースしないという目標をもって開発を進めています。

まとめ

比較対象がフェアではないので全く威張れないのですが、、

===============
当初「30秒」前後掛かっていた初期描画は、

改善によって「3秒」台になり、

次期リリースでは「0.3秒」台まで短縮する見込み!
(今のところ達成できています)
===============

( ^ω^)やったね!100倍 速くなったね!!!

とはいえ、FCPの次はFMP(FirstMeaningfulPaint)の短縮が待っています(こちらのほうが重要です)。
フロントエンドだけでなくバックエンド含めて全体を改善していく必要があるので、引き続きチーム全員で頑張ります。
ABEJAでは一緒に爆速なサービスを作る仲間を募集しています。気軽にお声がけください

おまけ(本題)

ここまで読んでくれてありがとうございます。
でもな、大事なことを忘れているような。。

・・・

・・・・・・俺たちが本当に軽くしたかったのは、コードより自分の体重だよな?
(いますぐ左上に表示されているLGTMの数だけスクワットするのです)

食事量減らしてみたり、運動してみたりしたんだけど、一向に減らないのよね(震え声
しかも年末年始といえば、おもち!酒!おもち!!おもち!!!
おすすめの減量ハックなどあれば是非コメントお待ちしています(ぴえん

ーーーーー
末筆ながら、皆様のご健康をお祈り申し上げ締めの挨拶といたします。
来年は良い年になりますように!!!!!
なのくろ (Twitter / GitHub / Qiita / プロフィール)