私の戦闘力は53万です。
メリークリスマス、ミツモアの@teradonburiです。
弊社ミツモアでは多種多様な業種のプロに依頼ができるプラットフォームのミツモアを運営しております。
今夏、Search Console上で改善が必要な約2.5万ページの速度を開発チームで改善しました。
これらのページは主に弊社が提供している各サービスの日本全国の市区町村地域別のランディングページとなります。
今回は特に大きく効果があった2つの施策に関して紹介します。
- クチコミ星☆のsvgをwebfont化
- Reactのクライアントサイドレンダリング無効化
ちなみに他にも以下のような施策を行いました
- APIの無駄なレスポンスデータの削減
- dynamic import
- リソースファイルのdns prefetch, pre-connect
- Google Tag Manegerの整理
前提
弊社のランディングページはReact+NextJSを採用しており、
Headless CMSのような形式でミツモアのメインサーバからページに必要なコンテンツをAPI経由で取得してページに表示させています。
今年の年明けから弊社ランディングページはNextJSのISR(Incremental Static Regeneration)を導入してページの静的再生成を行っていました。
ISRに関しては説明するとそれだけで記事になってしまうのでこちらの記事がまとまっているので参考にしてください。
qiita.com
ざっくり説明するとページアクセス時に静的なhtmlファイルを生成してキャッシュ化するSSG(static site generation)なのですが、それをホスティングしているVercelのインフラレベルで定期的にキャッシュが切れるたびに再生成する仕組みがISR(Incremental Static Regeneration)です。
Headless CMSのようなAPI経由で頻繁に更新されるコンテンツを取得して表示するようなページに適用できます。(かつページパフォーマンスを重視したい)
問題となるのはVercelのISRキャッシュを即時に消したくなる場合があるのですが、この問題もNext12.2以降で実装されたrevalidateの機能を使うことで解消できます。(後述)
パフォーマンス計測について
Googleが提案しているCore Web Vitalsの指標にて計測を行っています。
web.dev
テクニカルSEOの部分になりますが、ページスピードは検索順位に明確に影響するとのことでUX的にも大事な部分となります。
具体的な計測はPageSpeed Insightを使用しています。 pagespeed.web.dev
特に重要な指標が上3つです。
- LCP(Largest Contentful Paint):viewport内で最もサイズが大きいコンテンツ要素が表示するまでの時間です。注意点としてはファーストビューではないので、画面上で見えていないコンテンツでも最も時間がかかるコンテンツのレンダリングまでの時間が計測対象となります。(要はページ全体の最初のhtmlレンダリングが完了する時間)
- FID(First Input Delay):ユーザがページ表示してから入力開始できるまでの遅延時間です。ランディング時にJS実行などがある場合はブロッキングされて入力開始までの時間が遅くなります。
- CLS(Cumulative Layout Shift):コンテンツがレンダリングするときの表示ズレです。ブラウザ上でJSなどで動的にDOM操作する際に発生します。表示ズレが発生するとユーザが意図しないボタンを押してしまったりと好ましくありません
今回、ウェブに関する主な指標の評価を合格することを目的にページパフォーマンスの改善を行っていきました。
また、AutoWebPerfを導入し、それぞれの速度指標に対して定期実行による計測で日々トラッキングする仕組みを作りました。
github.com
クチコミ星☆のsvgをwebfont化
ミツモアのランディングページにはプロのクチコミ評価として☆を表示しています。
ミツモアではUIライブラリとしてMaterial-UIを導入しているのですが、調査の結果、内包しているmaterial-iconでの星のコンポーネントを使用するとレンダリングした際にSVGに展開されて
プロ数×クチコミUIで大量のDOMが生成されていることがわかりました。
Material Icons - Material-UI
そこで星をSVGからWebfont化しました。
以下は星を表示させるためのReactコンポーネントです。
import React from 'react' import clsx from 'clsx' export namespace SimpleRatingStar { export interface Props { rating: number classes?: Partial<Record<'root', string>> } export function Element(props: Props) { const { rating = 0, classes = {} } = props const reviewClassNameMap = { 0: 'reviewRating-zero', 0.5: 'reviewRating-zeroHalf', 1: 'reviewRating-one', 1.5: 'reviewRating-oneHalf', 2: 'reviewRating-two', 2.5: 'reviewRating-twoHalf', 3: 'reviewRating-three', 3.5: 'reviewRating-threeHalf', 4: 'reviewRating-four', 4.5: 'reviewRating-fourHalf', 5: 'reviewRating-five', [undefined]: 'reviewRating-zero', } as const const calcReviewRating = (rating: number) => { if (rating <= 0 || rating > 5) return 0 const interger = Math.floor(Math.abs(rating)) as Extract< keyof typeof reviewClassNameMap, 0 | 1 | 2 | 3 | 4 | 5 > const diff = rating - interger // ex1. 4.4 => 4 // ex2. 4.6 => 4.5 return (interger + (diff >= 0.5 ? 0.5 : 0)) as keyof typeof reviewClassNameMap } const createReviewClassName = (rating: number) => { const calcedReviewRating = calcReviewRating(rating) return reviewClassNameMap[calcedReviewRating] } return ( <p className={clsx('starBase', classes.root)}> <i className={`reviewRatingBase ${createReviewClassName(rating)}`} /> </p> ) } } export default React.memo(SimpleRatingStar.Element)
星のstyleは以下の様にclass定義してます。
Webfontのため、content属性にWebfontの星の文字を表示しています。
以下の例だと\F005
の文字が星1つ分、\F089
の文字がハーフスターとなっています。 (Webfontに格納されている文字によってことなります。)
注意点として、少し面倒な点を上げるとしたら星のサイズの調整がfont sizeでしなければいけなくなるところです。
@font-face { font-family: 'StarIcons'; font-style: normal; font-weight: 400; src: local('StarIcons'), /* サブセットしているので、星のアイコン(F005, F089)しか使用できません。 */ url(/webFont/la-solid-900-s.woff) format('woff'), url(/webFont/la-solid-900-s.ttf) format('truetype'); font-display: block; } .starBase { /* default style */ width: 100px; height: 20px; font-size: 1.3rem; } .starBase::before { display: block; overflow: hidden; content: ''; } .reviewRatingBase { display: block; position: relative; white-space: nowrap; } .reviewRatingBase::before { display: block; top: 0; left: 0; font-style: normal; position: absolute; color: #9e9e9e; font-family: 'StarIcons'; /* reviewの星 */ content: '\F005\F005\F005\F005\F005'; } .reviewRatingBase::after { display: block; top: 0; left: 0; font-style: normal; position: absolute; color: #ffa000; font-family: 'Line Awesome Icons'; } .reviewRating-zero::after { content: ''; } .reviewRating-zeroHalf::after { content: '\F089'; } .reviewRating-one::after { content: '\F005'; } .reviewRating-oneHalf::after { content: '\F005\F089'; } .reviewRating-two::after { content: '\F005\F005'; } .reviewRating-twoHalf::after { content: '\F005\F005\F089'; } .reviewRating-three::after { content: '\F005\F005\F005'; } .reviewRating-threeHalf::after { content: '\F005\F005\F005\F089'; } .reviewRating-four::after { content: '\F005\F005\F005\F005'; } .reviewRating-fourHalf::after { content: '\F005\F005\F005\F005\F089'; } .reviewRating-five::after { content: '\F005\F005\F005\F005\F005'; }
またWebfontファイルはそのまま使うとしばしば不要な文字が大量に含まれておりファイルサイズが肥大化して逆に読み込みパフォーマンスに影響を与えます。
そこでWebfontファイルから不要な文字を削除して軽量化しました。
今回使っているのは2文字のみです。
opentype.jp
また、Webfontをpreloadすることで読み込みを高速化させています。
<link rel='preload' as='font' href={`/static/webFont/la-solid-900-s.ttf`} /> <link rel='preload' as='font' href={`/static/webFont/la-solid-900-s.woff`} />
これによりパフォーマンススコアは43から44と微増ですが、FIDと関連がある
TTI(Time to Interactive)は24.7秒から21.0秒、
TBT(Time Blocking Time)は15.9秒から10秒に大幅減少、
DOM数は8718件から7614件と削減できました。
Reactのクライアントサイドレンダリングの無効化
FIDの速度を改善させるためにはJSの実行時間が最大の課題でした。
NextJSで単純にISRをしていたとしてもこの問題は解決することはできませんでした。
そこでブラウザ上でのJS実行を無効にすることができないか画策したところ、まだunstableな機能ではありますが、bundleの埋め込みをやめてReactJSのクライアントサイドレンダリングを無効にする方法があることがわかりました。
クライアントサイドのレンダリングを無効にするには以下のフラグをページ単位(NextJSのルーティングのパス単位)に追加するだけです。
export const config = { unstable_runtimeJS: false }
ただし、完全な静的HTMLとして払い出しされるためにFE側のReactJSが無効化されてできなくなることがあります。 これはReactの仮想DOMの構築を捨てることと同義です。
- onClickイベントなどのReactでのJSイベントハンドリングやuseState、useEffectなどのReactのhooksなどが使えなくなります(GTMなどの計測トラッキングタグがどうしても必要な系は_document.tsxなどに直接scriptを埋め込む)
- next/router, next/link → 内部ルーティングを行うことができませんのでLinkコンポーネントは使えません。aタグやformタグ(method=GET)に差し替えることで代用できます。Reactの疑似routingが使えないため、都度SSRされることとなります。
- next/image → CSR側でJS実行されてリソース取得が最適化されるため使えません。imgタグで代用します
DX的なデメリットは多く移行も苦労しましたが、 Google Tag Managerを無効化した場合の一部ページの計測ではなんとモバイルでも98点のパフォーマンススコアを叩き出しました。
実際は広告トラッキングや計測ツールの関係上、Google Tag Managerを無効化することはできないのですが、 Google Tag Manager有効の場合でもすべてのランディングページでTime To InteractiveとTime Blocking Timeが大幅に改善されFIDが改善されました!
次のグラフは確定申告の税理士サービスページですが、 特にTTIは半分以下、TBTはほぼ皆無に筆頭にSpeedIndexやすべての指標が数万のランディングページで改善されました。
VercelのISRキャッシュクリア
ページ内コンテンツを更新した際にVercelのISRキャッシュを明示的に消したい場合が往々にしてあります。
NextJS12.2以降はOn-demand Revalidationの機能が登場し、
getStaticProps(ISR)でホスティングされたページのVercelキャッシュをクリアすることができるようになり、この問題は解決しております。
使い方はrevalidate関数を呼び出すVercel APIを作成し、getStaticPropsでホスティングしているページのpathを指定してこのAPIを呼び出すだけです。
// src/pages/api/revalidate.ts import { NextApiRequest, NextApiResponse } from 'next' // Using On-Demand Revalidation // https://vercel.com/docs/concepts/next.js/incremental-static-regeneration#using-on-demand-revalidation export default async function handler( req: NextApiRequest, res: NextApiResponse ) { // revalidate path ex:) `/hoge/fuga` const path = req.query.path as string try { await res.revalidate(path) return res.json({ revalidated: true }) } catch (err) { // If there was an error, Next.js will continue // to show the last successfully generated page return res .status(500) .send(`Error revalidating, ${(err as Error).toString()}`) } }
ただ残念ながら、複数ページのVercelキャッシュクリアに関してはワイルドカードや正規表現指定で行うことができないため、大量のページのキャッシュクリア(再生成)に適していません。
その場合は従来どおりVercelを再デプロイしてすべてのISRキャッシュをリセットした方が早いです。
そもそもrevalidateはgetStaticPropsを裏で実行しているため、大量のページの明示的な事前レンダリングは向いてはいないのでしょう。
まとめ
しばしばパフォーマンスの改善には「推測するな、計測せよ」という格言があります。 これはどこがボトルネックなのかをはっきりさせるまでは、推測でスピードハックをしてはならないという話です。
計測したところ今回のケースに関しては大量のコンテンツ量によるDOM数がパフォーマンスに影響を与えていたのとReactのフロントエンド実行処理が大きなボトルネックとなっていました。
ボトルネックとなっていた本質的な部分の改善をすることで大きなパフォーマンスの改善と繋がりました。
また、Google Tag Managerなど技術面以外の要素で遅くなる点も明らかになりました。
この辺も広告チームと連携を取り、パフォーマンスの意識を持ってもらうことで不要なトラッキングを整理して減らしてもらうことができました。
上記のように弊社ミツモア開発陣は日々より良いプロダクト開発の改善に努めています。
ミツモアでは様々な職種のエンジニアを積極的に採用しています! ご興味がある方はぜひ気軽に面談しましょう!