サービスのトップページを高速化するためにやったこと(フロントエンドパフォーマンスチューニング)
Tech
現在、長期インターン先のスタートアップで Gatsby(React)Typescript という技術スタックで新プロダクトの開発(ベータ版を改良中という段階)を行っています。先日、その業務の中でトップページのパフォーマンスチューニングを担当することになりました。
パフォーマンスチューニングに関しては業務レベルの経験はありませんでした。しかし、今年の 3 月にリクルートのフロントエンド高速化ハッカソンに参加していたため、多分できるだろう、ぐらいの意識で「やる」と言ってしまいました。次に示す記事は参加したハッカソンに関する記事です。
結果として、パフォーマンスを改善することはできたのですが、改善するまでにかなり紆余曲折したため、具体的に何をやったのかを本記事でまとめたいと思います。
1.事前準備
ハッカソンに参加したとは言え,やったことはぼんやりとしか覚えておらず、パフォーマンス改善に関する知識はかなり怪しいと自分でも思っていたので、始める前に以下の本を読むことにしました。
- Web フロントエンドハイパフォーマンスチューニング
- HTML コーダー-ウェブ担当者のための Web ページ高速化超入門
- Web サイトパフォーマンス実践入門-高速な Web ページを作りたいあなたに-
どの本も熟読したわけではありません、拾い読み程度です。特に 1 冊目のハイパフォーマンスチューニングに書いてあった「レンダリングエンジンが描画をする仕組み」の章が非常に面白かったです。
2.高速化までのプロセス
本章では、1 で得た知識を踏まえて、サイトを実際に高速化するまでにやったことを説明していきます。
2.1.推測するな、計測せよ
「推測するな、計測せよ」
これは、Web パフォーマンスチューニングにおける鉄則です。フロントエンドのパフォーマンスのボトルネックになりうる物としては、画像や css,js の読み込みや計算処理、js の実行など様々な要因が考えられます。そういった要因の中でも果たして、どこが最たるボトルネックなのか?という点は、推測ではなく、計測することにより明らかにする必要があるのです。
計測するにあたって、以下のツールを用いました
・chrome のネットワークタブ
パフォーマンスタブを観察することによって、js や画像,css の初期ロード時にかかる時間や Ajax によりサーバーサイドの API からデータを取得する上で要する時間を計測する事ができます
- chrome のパフォーマンスタブ
- js の performance API
performance API は以下のような使い方をする事で任意の処理にかかる時間を計測する事が可能です
const start = performance.now()
doHoge()
const end = performance.now()
console.log(end-start) // doHoge()に要した時間が表示される
Date.now()
でも同様に所要時間を図る事ができるのですが,performance.now()
の方が精度が高くチューニングの計測向きと言えるでしょう。
より詳しい記述については以下の Link が参考になると思います。
以上のツールを用いて計測を行いました。
私が担当するプロダクトでは、株価をサーバサイドから Fetch し、フロントエンドで計算し、Google Chart で表示させています。計測を行う前は、「計算ロジックに時間がかかっている」と思っていたのですが、比較すると
- 株価データの取得(0.95s) >>> 計算 (0.00000001s)
と、圧倒的にデータの取得にかかっている時間の方が長かったのです。計算処理を最適化することは効果が薄いと判断し,株価データや JS・CSS の取得を最適化することに絞って、チューニングを行っていくことにしました。
2.2.「リソースの取得」(Fetch)を最適化する
リソースの取得の最適化については本やネット上にもたくさん情報が載っています。また、リクルートのハッカソンも主にそれが中心的な内容でした。
ボトルネックを特定し、「よし、最適化していくぞ!」と意気込んでいました。試した/試そうとしたことを説明していきます。
http/2 化
http/2 は http/1.1 の後継となるプロトコルです。http/1.1 と比較した際以下の点で有利であると言えます
- http1.1 がテキストベースで通信するプロトコルであったのに対し、http2 はバイナリベースでデータの送受信を行うためデータの取り扱い効率が高い。
- http1.1 以前では一つの tcp 接続に対して同時に一つの http リクエストしか扱えなかったが、http2 では同時に複数の http リクエストを並列的に扱うことができる
- hpack によるヘッダーの圧縮
しかし、フロントエンドのリソースを読み込む際も、Ajax(axios)によってデータを API から取得する際にも,http/2 は既に適用済みでした。
gzip 化
gzip は HTTP レスポンスを圧縮する手法の一つです。圧縮を施すことによって通常のテキストファイルであれば、60~80%程度データサイズを圧縮することが可能です。
Chrome のネットワークタブを使うと、HTTP レスポンスのヘッダーを確認することができます。レスポンスに gzip が施されていた場合、ヘッダーに以下の記述を確認することができます。
しかし、こちらも既に適用済み。
html/css/js の minify
html/css/js を webpack や gulp を用いて最小化することによって、リソースの読み込みを高速化することができます。
最小化により削除されるのは空白やインデントなどコーディングする上では重要だが挙動には影響しない記述のことです。
しかし、webpack で Gatsby のアプリをビルドする際に最小化が実行されるため、これも適用済みでした。
画像の最適化
画像の鮮明度を犠牲にしますが、画像のファイルサイズを下げることでリソースの取得を高速化することができます。画像圧縮のサービスで有名なところで言うと TinyPNG などが挙げられます。
しかし、私が担当していたサイトは画像の数があまり多くなく(1 枚のみ)そのため、効果は薄いだろうと結局試しませんでした。
以上から分かるとおり、現状ある知識で色々試そうとしたことは既に適用済みであるものがほとんどでした。
え、もうやることないんだけど…と言う感じで割と途方に暮れたのを覚えています。
2.3.方向転換(Fetch→Render)
想定していたチューニングの知識が通用しない…とどハマりの森に迷い込んでいた頃、ビジネスサイドや開発リーダーと話し合う機会がありました。そこで改めて「具体的にどの点をチューニングすべきか?」について、質問したところ、以下のような回答が得られました。
- 初期のローディングはそれほど問題ない。
- スマートフォンでのスクロールが非常に重たい。
私はそれまで、リソースの取得を高速化する方法ばかり考えていました。しかし、リソース取得の高速化により得られる効果は主に「初期ローディング時の読み込みが早くなることによる UX の向上」です(問題ないと指摘された点)。
一方でスマートフォンにおけるスクロールが重たいという問題は、リソース取得が遅いことではなく、クライアントサイドにおける処理が重たいことにより生じます。その事実をもとに
リソースの取得を高速化する ⇒ レンダリング回数を最適化する
という方向転換をすることにしました。
2.4.「レンダリング回数」(Render)を最適化する
レンダリング回数を最適化するにあたって、以下の情報源が非常に参考になりました。
まずはレンダリング回数の「計測」です。
React コンポーネントを指定し、レンダリングされたら component が highlight される React Dev Tools の機能を活用しました。
また、計測と直接は関係ないのですが、スマートフォンの挙動を PC で再現するために chrome の performance タブで CPU を「6x slotdown」しました。
先にも述べたとおり、今回チューニングしているサイトは株価をサーバーサイドから取得し、フロントエンドで計算を行い、分足グラフとして表示しています。トップページには 6 つの分足グラフが表示されているのですが、そのレンダリング回数を知るため、分足グラフコンポーネントに console.log を設置しました。
console.log が実行された=そのコンポーネントが rendering されたということです。したがって、この出力の数を数えることでレンダリング回数を知ることができます。
以上のことを踏まえて,ローカルで起動し、console を確認したところ、
!!!
6 つしかコンポーネントないのに 200 回以上レンダリングされている…という事実が発覚しました。
実際の console です。
「株価グラフの過剰レンダリング」が「スマートフォンにおけるスクロールが重たい」ことの重大な要因の一つであることがこのタイミングでわかりました。
2.5.原因
さて、ではなぜ 6 つしかないコンポーネントが 200 回以上もレンダリングされるという事態が生じたのでしょうか?
原因は一言で言うと「コンポーネントからの redux の参照範囲が広すぎる」ことにありました。
まず、私が担当しているサイトの redux 構造を見ていきましょう。
{
stock:{
stockMinutes:{
categoryHoge:[categoryHoge の分足株価],
categoryFuga:[categoryFuga の分足株価]
....
}
....
}
news:{
...
}
}
redux の”stock”内にサイト全体の株価データ,“news”内にサイト全体のニュースデータが格納されています。
ここで、修正する以前株価のコンポーネントは以下のような実装になっていました。
const StockMinute: React.FC<Props> = { category } => {
const dispatch = useDispatch()
const { stockMinutes } = useSelector(
(state: AppState) => state.stock
)
// ここで redux を参照!
useEffect(() => {
dispatch(actions.getStockMinutesCategory({ category }))
// 株価データを API から Fetch する
}, [dispatch, category])
const stockMinutesCategory = stockMinutes[category]
...
}
StockMinutes に props として与える category が 6 種類存在し、6 つの分足グラフがトップページには表示されています。
ここで重要なのはコンポーネントが変数として特定のカテゴリーの株価(stockMinutes[category])だけでなく、stockMinutes 全体を参照しているという点です。したがって、stockMinutes の値に少しでも変化が生じるとコンポーネントは再レンダリングされてしまいます。すなわち各コンポーネントの useEffect が実行され、各カテゴリーの株価データが Fetch される都度、トップページに表示されている 6 つの株価グラフ全てが再レンダリングされていたということになります。アニメーションにすると、以下の通りです。
そのため、この問題は株価グラフからの redux の参照範囲を小さくするだけで解消することができます。redux の参照範囲を小さくしたコードが以下の通りです。
const StockMinute: React.FC<Props> = { category } => {
const dispatch = useDispatch()
const stockMinutesCategory = useSelector(
(state: AppState) => state.stock.stockMinutes[category]
)
useEffect(() => {
dispatch(actions.getStockMinutesCategory({ category }))
// 株価データを API から Fetch する
}, [dispatch, category])
...
}
このような修正を施すだけで、各カテゴリーの株価が Fetch される ⇒ 各カテゴリーの株価グラフがレンダリングされるという以下のような状態に改良することができました。
実際に修正を施し、ローカルで起動したところ、
「す、スクロール軽い…」
また実際に console を確認すると,200 回以上あったレンダリング回数は 12 回まで減少していました。
この状態でデプロイしたところ、ビジネスサイドからも同様の感想を得ることができました。ここまで、結構時間をかけたパフォーマンスチューニングでしたが、実際に修正し、効果があったのはたったの数行でした。かなり拍子抜けです。
3.まとめ
最後に反省とまとめです。
反省点としては次の通りです。
・コンポーネントの再レンダリング過剰という可能性を初期段階で考慮していなかった点
記事を読んでいただくと分かる通り、取り組み始めの頃は、本やリクルートのハッカソンで得た知識の範囲のみでチューニング方法を考えていました。また、「フロントエンド 高速化」などとググると圧縮周りの話ばかりが出てきます。そのためレンダリングの最適化に意識が回りませんでした。視野が狭かったため、ボトルネックを突き止めるまでにかなりの時間を要してしまいました。(performance タブをきちんと読み込めば、把握できていたかも)
どこのパフォーマンスを改善すべきなのか(目標設定の部分)を事前に明確化しなかった点「パフォーマンス」と一口に行っても
- Lighthouse のスコアなのか
- ファーストビューの表示が遅いのか
- スクロール時の挙動が重たいのか
- ボタンを押した際の反応やアニメーションなどが遅いのか
など様々なバリエーションが存在します。Lighthouse のスコアが高いに越したことはありませんが、それが UX の向上や実務上の価値に直接つながるとは限らないのです。そのため具体的にどこの挙動が遅いかをしっかりと観察し、PM や開発リーダーと事前に話し合い理想状態を明確化しておくべきでした。
このように反省点もありますが、自分の手で工夫を施し、サイト全体が軽くなり UX が向上するというのはかなり嬉しい経験でした。
また機会があれば、パフォーマンスチューニングに取り組みたいです!
以上