こんにちは、フロントエンドエンジニアの秋山です。 主にレストラン検索サービスのフロントエンドのテックリードをしています。
ぐるなびのレストラン検索は2021年10月にフルリニューアルしました。 レスポンシブウェブ対応、地図検索機能の拡充、絞り込みの改善、検索の高速化と盛り沢山のプロジェクトとなりました。これからも随時アップデートをかけて、より早くより使いやすい検索を提供していきますので、これからもよろしくお願いします。
さて、今回はリニューアルに当たってフロントエンドのパフォーマンスを上げるために試行錯誤して、比較的効果の大きかった施策をいくつかピックアップしてお伝えしていきます。
目次
- レストラン検索のフロントエンド構成
- パフォーマンスの改善
- インラインSVGをやめる
- ファーストビューから非同期コンポーネントをなくす
- re-export をやめる
- クライアント JavaScript のロジックを サーバサイドへ移動
- GraphQL クライアントライブラリの利用をやめる
レストラン検索のフロントエンド構成
リニューアル前までは PHP の Slim Framework で構築されたシステムをバックエンドが担当し、フロントエンドは View 部分の HTML/CSS/JavaScript を納品するという業務フローとなっていました。分業と言えば聞こえはいいですが、分業による様々な弊害がありました。この弊害は以前掲載した記事「レガシーなシステムとの向き合い方 リニューアル案件を通して」で解説されています。記事の内容は別のサービスですが課題となった点は同様な部分が多かったので、よろしければご参考ください。
今回のリニューアルは全体のアーキテクチャを全て見直してほぼゼロベースから開発となり、稼働基盤もオンプレミス上の Kurbernetes でコンテナ環境が前提だったため、これを機にフロントエンドの担当領域をコンテナ管理まで拡張して問題の解決に当たることになりました。
そしてせっかくのゼロベース開発なので… ということで技術スタックも
- TypeScript
- Next.js (React)
- Recoil
- GraphQL
- Jest (React Testing Library)
- StoryBook
- Cypress
上記のようにモダンフロントエンド全部載せのような構成となっています。
フロントエンドで今から書くなら TypeScript を選択肢から外すことをまず考えられないですし、SEO 要件や表示速度の兼ね合いからサーバサイドレンダリングが必須要件となっていたため、 TypeScript で書ける SSR 対応フレームワークという条件でほぼ Next.js 一択の状態でした。
BFF (Backend for Frontend) も今回のリニューアルで新たに設けられました。GraphQL を採用したことでフロントエンド側のリクエスト処理の可用性と TypeScript の型推論による開発効率が大幅に向上したことを実感できています。
パフォーマンスの改善
前置きが長くなりましたが、本題に入ります。 フロントエンドのパフォーマンス上げる方法はいろいろありますが、主に
- 画面のレンダリング実行処理を減らして表示速度を上げる
- クライアントサイド JavaScript のバンドルサイズを削減してスクリプト解析・実行時間を減らす
あたりが鉄板の方法になります。 リリースされる前の開発中にいろいろやってみたパフォーマンス改善の中で、比較的効果の高かったものをいくつか紹介していきます。
インライン SVG をやめる
React で SVG と言えば SVGR が有名です。レストラン検索でも UI で使われるアイコンは SVG だったので、当初は SVGR を利用していました。 しかし開発を進めていく中で画面表示が重くなる状況がたびたび発生するようになり、原因を確認したところ SVGR で表示しているローディングアイコンに原因があることが分かりました。
画像の読み込み中を示すアイコンは以下のような CSS アニメーションの style が定義された 13個の DOM で作られていました。
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> <style> @keyframes svgIconSpinner { 0% { opacity: 1; } 100% { opacity: 0; } } .st0 { animation: svgIconSpinner 1.2s linear infinite; } .st0:nth-child(1) { animation-delay: -1.1s; } .st0:nth-child(2) { animation-delay: -1s; } .st0:nth-child(3) { animation-delay: -0.9s; } .st0:nth-child(4) { animation-delay: -0.8s; } .st0:nth-child(5) { animation-delay: -0.7s; } .st0:nth-child(6) { animation-delay: -0.6s; } .st0:nth-child(7) { animation-delay: -0.5s; } .st0:nth-child(8) { animation-delay: -0.4s; } .st0:nth-child(9) { animation-delay: -0.3s; } .st0:nth-child(10) { animation-delay: -0.2s; } .st0:nth-child(11) { animation-delay: -0.1s; } .st0:nth-child(12) { animation-delay: 0s; } </style> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(0 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(30 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(60 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(90 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(120 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(150 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(180 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(210 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(240 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(270 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(300 8 8)" class="st0" /> <rect x="7.35" y="0" rx="0.1" ry="0.1" width="1.3" height="4" fill="#333" transform="rotate(330 8 8)" class="st0" /> </svg>
ベクターアイコンをアニメーションさせるには一番手っ取り早い構成だったのですが、実は SVGR は SVG の中に記述された style 要素をそのままグローバルに登録する挙動となっています。
そして検索画面でローディングアイコンは主に店舗情報の画像部分で使われているのですが、一つの店舗には最大8個の画像が表示され、店舗情報は1ページに対して最大24個表示されるため、初回表示時に最大で 8画像×24店舗×13DOM=2496 Elements がページに追加される(しかもアイコンの数の分だけグローバルに style が重複登録されるというオマケ付き)という明らかにヤバい状態が発生していました。
インライン SVG は CSS によるコントロールが可能になるといったメリットがありますが、検索で使用するアイコン SVG は一度表示されればほとんど可変しないため、敢えて SVGR を使ってインライン SVG とするメリットがありません。また、インライン SVG にすることで、SVG が Virtual DOM として取り込まれるため JavaScript の実行コストの増加にも繋がります。
そこで SVG を img 要素に base64 で埋め込みをするようにしました。 その結果 Lighthouse の計測値も以下のようになり、動作も見違えるくらいに軽くなりました(before が相当に酷かっただけという話もありますが…)
before | after | |
Avoid an excessive DOM size | 9,493 elements | 6,253 elements |
Reduce JavaScript execution time | 14.9 s | 5.5 s |
Minimize main-thread work | 86.1 s | 16.1 s |
Style & Layout | 37,384 ms | 1,827 ms |
Script Evaluation | 23,049 ms | 5,690 ms |
Other | 17,062 ms | 2,413 ms |
Rendering | 7,078 ms | 5,602 ms |
Garbage Collection | 748 ms | 68 ms |
Parse HTML & CSS | 618 ms | 399 ms |
Script Parsing & Compilation | 137 ms | 125 ms |
また、ローディングアイコンの base64 の記述を CSS に逃がして HTML 上に登場する base64 埋め込み img 要素自体を減らすことで、HTML のファイルサイズも 400 KB 以上削減することができました。
アイコン UI は個々のパーツとしては小さく軽微なもので、一度作ったらあまり意識をしないコンポーネントですが、登場頻度の多いものはチリも積もれば山となりパフォーマンスに影響を与えるので、取り扱いに気を付ける必要があると感じました。
この件に関して検証した結果とデモを以下の記事にまとめてありますのでよろしかったらご参照ください。
https://zenn.dev/okamoai/articles/a8d5cf1b094edd
ファーストビューから非同期コンポーネントをなくす
Google が推奨する Core Web Vitals というユーザー体験の質を測定する指標があります。
- ページの有力なコンテンツの読み込み速度を測る Largest Contentful Paint (LCP)
- ユーザーの初回入力遅延を測る First Input Delay (FID)
- ページのレイアウトずれの発生を測る Cumulative Layout Shift (CLS)
の3つから構成され、数値が低ければ低いほど良いとされています。
当初は非同期で読み込まれる店舗情報がありましたが、この要素がファーストビューの表示に掛かっていたため、Core Web Vitals の LCP、CLS の数値を悪化させていました。 レストラン検索のリニューアルにおいては、この Core Web Vitals の Good ラインを堅守することが目標となっていたこともあり、ファーストビューから非同期コンポーネントを極力無くしていく形で改善を進めていくことになりました。 該当する非同期コンポーネントをクライアントサイドレンダリングからサーバサイドレンダリングに移行することで数値が安定するようになりました。
また、合わせて検索結果の先頭の店舗情報の画像の遅延読み込みも停止しました。画像の遅延読み込みはパフォーマンスを向上させますが、ファーストビューに掛かる画像まで遅延読み込みを設定すると、遅延読み込み処理の分だけ画像表示が遅くなり、そのぶん LCP の悪化に繋がるためです。
これらの改善を実行した結果、Core Web Vitals の数値は
Good ライン | before | after | |
LCP (Largest Contentful Paint) | 2500ms 以下 | 3125ms | 2031ms |
FID (First Input Delay) | 100ms 以下 | 43ms | 49ms |
CLS (Cumulative Layout Shift) | 0.1 以下 | 0.113 | 0.0166 |
となり、全て Good ラインを達成しています。
re-export をやめる
開発当初、レストラン検索ではファイル構成として、ディレクトリ直下の index.ts にディレクトリ配下の関連するスクリプトを再エスクポートするためだけのファイルがありました。
例を挙げると、utils ディレクトリ配下のファイルを集約して export する index.ts があり、
// utils/index.ts export * from './utils-a' export * from './utils-b'
呼び出し側は utils/index.ts から utils-a, utils-b を参照することができる、というものです。
import { a, b } from './utils'
参照する側は ./utils を指定するだけでディレクトリ配下を参照できるため、import の定義がシンプルになります。いわゆる re-export というやつです。
ただし、この re-export は、使用しているパッケージやファイル構成によっては Tree Shaking が上手くいかないケースが発生します。結果、意図しない未使用コードを巻き込み、ビルドする JavaScript ファイルのサイズ肥大化の原因になります。
チーム開発において Tree shaking が正しく機能しているかどうかを常に意識しながら書くよりも、一律禁止にしてしまった方が早かったため、re-export 用の index を撤廃して直接個々のファイルを参照するようにしました。
import { a } from './utils/utils-a' import { b } from './utils/utils-b'
便利な hooks を集めた react-use というライブラリがあり、レストラン検索でも使用していたのですが、こちらもまとめて呼び出すことで未使用コードを巻き込んでいたため、
import { useClickAway, useKeyPressEvent } from 'react-use'
各パッケージを個別に呼び出すように変更しました。
import useClickAway from 'react-use/lib/useClickAway' import useKeyPressEvent from 'react-use/lib/useKeyPressEvent'
この re-export を止めただけで、クライアント JavaScript の初回読み込みファイルサイズ を 84 KB も削減することができました。
クライアント JavaScript のロジックを サーバサイドへ移動
開発当初は getServerSideProps 内で GraphQL から取得した情報をそのまま pageProps に引き渡して、データ加工はコンポーネント側で処理をするようにしていました。 データ加工はコンポーネントに依存するロジックであったため、コンポーネント内に持った方が責務としても良いと考えていたためです。 また、GraphQL Code Generator から生成した型情報をそのまま pageProps に引き渡せるため、開発する上での都合も良かったという点もあります。
しかし、レストラン検索ではメインとなる店舗情報のほか、指定条件に連動するエリアや駅などの付帯情報も多くあり、比較的ロジックが複雑だったため、こうしたデータ加工処理をコンポーネント側に持たせたことで予想よりも多くのロジックがクライアント JavaScript 側に漏れ出ていった結果、ファイルサイズの肥大化を招いていました。
比較的重たいデータ加工ロジックをピックアップしてコンポーネントから切り出し、getServerSideProps 内に移動して、そこでデータ加工を実行してから pageProps で渡すように変更しました。
結果、コンポーネントは pageProps から渡されたデータをレンダリングするだけのコンポーネントとなり、クライアント JavaScript の初回読み込みファイルサイズを 36 KB 削減することができました。
GraphQL クライアントライブラリの利用をやめる
レストラン検索では BFF に GraphQL (Apollo Server) を採用しています。 そういったこともありフロントエンドの GraphQL クライアントとして Apollo Client を採用していました。
GraphQL を使う上で気をつけねばならないのは、悪意のあるクエリへの対策です。 フロントエンド側で自由にクエリを記述できるということは、サーバに過剰な負荷をかけるクエリを送信することも可能なため、サーバ側で対策が必要になります。
一般的には
- 予め許可したクエリしか通さない「ホワイトリスト方式」
- クエリの負荷量(complexity)を計算して閾値以下のクエリしか通さない「complexity 方式」
の2つが挙げられます。
「ホワイトリスト方式」はフロントと BFF の両方でクエリの許可リストを共有する必要があり、その連携に一手間があるものの悪意のあるクエリは確実に除外できます。 「complexity 方式」は GraphQL スキーマ側でコスト定義を綿密に行う必要があるものの、一度設定してしまえば、ホワイトリスト方式のような都度連携を取る必要がなく、フロントエンドの高い可用性を確保できるのが魅力です。
レストラン検索での GraphQL の利用はサービス内で限定されていたため、前者の「ホワイトリスト方式」を採用しました。 Apollo Server には Persisted queries という、ハッシュ化されたクエリをやりとりするプロトコルがあり、この仕様に則ってフロントエンドと GraphQL でやりとりをしています。 フロントエンド側では GraphQL Code Generator のプラグイン graphql-codegen-persisted-query-ids を使って、GraphQL クエリをハッシュ化しマッピングした2つの JSON ファイルを生成します。
// client.json { "getAreaLabel": "f5671689a8ce71f13d3274494f21ced39b49f5129d8e8bc4b2a69783c0d27a9a" }
// server.json { "f5671689a8ce71f13d3274494f21ced39b49f5129d8e8bc4b2a69783c0d27a9a": "query getAreaLabel($id: ID!) { …中略… }" }
server.json ファイルを Apollo Server が参照し、ハッシュ値とクエリの展開を行うことでホワイトリスト方式を実現しています。
また、この形になるとフロントエンドと GraphQL の間はクエリではなくハッシュ値を使ってやりとりをすることになるため、最終的には GraphQL のエンドポイントには以下のような GET 送信でやりとりができるようになります。
/endpoint?operationName=getAreaLabel&variables=%7B%22id%22%3A1%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22f5671689a8ce71f13d3274494f21ced39b49f5129d8e8bc4b2a69783c0d27a9a%22%7D%7D
こうなるとフロントエンド側では GraphQL Code Generator で生成されたクエリのハッシュとクエリの型情報しか参照しないため、クライアント稼働環境で生の GraphQL クエリを直接扱う必要がなくなります。つまり、GraphQL とのやりとりのための GraphQL クライアントは必要なく、fetch API でも十分になります。
Apollo Client を使うメリットの一つとして、強力なクライアントキャッシュがありますが、レストラン検索では GraphQL 以外にも REST API を参照している箇所があり、そちらのクライアントキャッシュに SWR を利用していたため、GraphQL への fetch はこの SWR を中継することでクライアントキャッシュ機能を集約しました。これにより、Apollo Client を使う理由がなくなったため、パッケージから除外を行った結果、
- Apollo Client 本体
- GraphQL クエリ
- GraphQL 関連 hooks
これらのバンドルがなくなったことでクライアント JavaScript の初回読み込みファイルサイズを 185 KB 削減することができました。
もともとはパフォーマンス改善を見越した上でのホワイトリスト方式の採用ではありませんでしたが、結果的に JavaScript ファイルの大幅な容量削減を実現できたのは大きなメリットになりました。
まとめ
これらの改善の結果、2022年5月現在では以下のような数値になっています。
- JavaScript の初回読み込みサイズ: 219 KB
- Core Web Vitals:LCP 1843 ms / FID 47 ms / CLS 0.0083
- Lighthouse パフォーマンススコア:SP 46 / PC 61
リリースまでに実施したいくつかの改善を紹介しましたが、実は他にも React レンダリングの最適化やコンポーネントの整理、キャッシュの見直しなど、まだやり切れてない改善が残っています。 引き続きユーザーが使いやすく快適な動作のレストラン検索を追求していきますので、今後ともよろしくお願いします。