株式会社COMPASS
👶

小さく始めるパフォーマンス改善

2024/04/12に公開
2

はじめに

こんにちは!株式会社 COMPASS でエンジニアをしているやじはむです。私はシステム開発部というエンジニアリングの組織に所属をしており、現在はフロントエンドエンジニアとして先生向けのアプリケーション開発を行っています。

今回は、最近の業務の中で小さく始めていたパフォーマンス改善方法について紹介したいと思います。

この記事はこんな方におすすめ

  • Webパフォーマンスの具体的な改善方法を知りたい人
  • Core Web Vitalsの改善方法を知りたい人
  • ちょっと良いコードを少ないエネルギーで書きたい人

筆者は最近Web Speed Hackathon 2024に参加したのですが、そこで学んだ知見も踏まえて記事を書きました。

パフォーマンスについて興味がある人もない人もこの記事を見れば、根拠を持って「ちょっと良いコード」を書けるようになるかも知れません。書いてある内容はどれも小さく始められるため、日々の積み重ねを大事にして大きくパフォーマンスを上げていきましょう✊
ぜひ参考にしてもらえると幸いです!

記事の内容で何か間違っていることや気になることがあれば遠慮なくコメントしてくださると助かります!

Core Web Vitalsについて

Webパフォーマンスを計る指標としてWeb Vitalsがありますが、その中でも最も重要な指標として以下の3つのCore Web Vitalsが存在します。

今回はそれぞれの指標を改善する方法をいくつか紹介します。

Largest Contentful Paint(LCP)の改善

先ほど挙げた指標のうちの1つであるLCPの改善方法について紹介します。

LCPとは

この指標は、Webページが読み込まれてから最も大きなコンテンツ(画像やテキストブロックなど)が画面に表示されるまでの時間を測定する指標です。

Suspenseを適切に分割する

まず最初にSuspenseに関連する改善方法について紹介します。
みなさんご存知の通り、SuspenseはReactの便利な機能です。
しかし、Suspenseを適切に分割しないと寧ろパフォーマンスを悪くしてしまう状態に陥る可能性があります。

例えば以下のように同じSuspenseコンポーネントの配下に1秒の非同期処理を待つコンポーネントと10秒の処理を待つコンポーネントを配置します。

BaseComponent.tsx
export const BaseComponent: React.FC = () => {
  return (
    <div>
      <Suspense fallback={<p>Loading...</p>}>
        <OneSecondWaitComponent />
        <TenSecondsWaitComponent />
      </Suspense>
    </div>
  );
};
各コンポーネントの内容
OneSecondWaitComponent.tsx
export const OneSecondWaitComponent: React.FC = () => {
  const { data } = useSuspenseQuery({
    queryKey: ['one-second-wait'],
    queryFn: () => getMssage(1),
  });

  return <p>{data.message}</p>;
};
TenSecondsWaitComponent.tsx
export const TenSecondsWaitComponent: React.FC = () => {
  const { data } = useSuspenseQuery({
    queryKey: ['ten-seconds-wait'],
    queryFn: () => getMssage(10),
  });

  return <p>{data.message}</p>;
};
lib.ts
export const getMssage = async (
  seconds: number
): Promise<{ message: string }> => {
  await new Promise((resolve) => setTimeout(resolve, 1000 * seconds));
  return {
    message: `${seconds} seconds passed.`,
  };
};

今回はフェッチライブラリにTanstack Queryを使用しています。

この状態で実際のページを見てみます。
Loadingの文字の10秒後に「1 seconds passed. 10 seconds passed.`」が表示される

ずっとLoading...の文字が表示されおり、10秒後に1 seconds passed. 10 seconds passed.とそれぞれ表示されます(画像のは少し早送りしています)。
これはあまり良くない状態です。というのも、1秒後には処理が完了しているOneSecondWaitComponentコンポーネントを差し置いて10秒が過ぎるまでそのコンテンツを表示できないからです。

改善策は次のようになります。

BaseComponent.tsx
export const BaseComponent: React.FC = () => {
  return (
    <div>
      <Suspense fallback={<p>Loading one second data...</p>}>
        <OneSecondWaitComponent />
      </Suspense>
      <Suspense fallback={<p>Loading ten seconds data...</p>}>
        <TenSecondsWaitComponent />
      </Suspense>
    </div>
  );
};

Suspenseは子要素でキャッチされた非同期処理が全て完了するまでfallBackを表示するという機能を持っているため、適切に分割して使用する必要があります。
ある非同期処理の結果が別の処理に関係しない場合は別々にコンテンツ表示を行えるはずなので、その際は囲うSuspenseも別々にすると良いでしょう。
逆にバラバラに表示するのではなく、ローディングをまとめて管理したいような場合は同じSuspenseで囲む方が良いです。

このSuspenseの分割がどうLCPに関係するかというと、例えばOneSecondWaitComponentが最も大きなコンテンツとなり、LCPの対象である場合に有効です。
非同期処理を待っている間にローディングを表示して適切なハンドリングをしながら、早く表示できるものはすぐに表示させることが重要です。

また、特にSSRを使用している場合もSuspenseを併用するとStreamingHTMLが有効になるため、適切に分割を行うとLCPの改善につながります。

https://tech.anotherworks.co.jp/article/react-suspense-react18

画像を最適化する

LCPの対象となるものが画像の場合は最適化をすると読み込みの遅さを改善できます。

例えば以下のように改善すると良いでしょう。

  • <img> 要素に fetchpriority="high"を設定する[1]
  • pngやjpgの画像をwebpにする
  • 落としても良い画質まで落とす(圧縮する)
    • 拡張子の変更や圧縮には@squoosh/cliが便利

https://zenn.dev/catnose99/scraps/2647fa64b1fe27

  • 画像のリサイズを行う
    • ページ上で表示する画像のサイズと実際の画像のサイズがなるべく等しくなるようにする
    • macOSの場合はデフォルトでsipsコマンドがある

https://qiita.com/livlea/items/53b755e5067d4ebc5b43

  • 優先度が低い<img> 要素にはloading='lazyを設定する

一番最初以外はLCP対象でなくても有効です!

また、Next.jsを使用している場合は<Image>コンポーネントを使用することでpngをwebpにしてくれたり、ビューポートごとにリサイズされた画像を設定してくれるため、そちらも使うと良いでしょう。

https://nextjs.org/docs/app/api-reference/components/image

巨大なBase64エンコーディングを避ける

例えばFigmaで作成したデザインをエクスポートする際にSVGでエクスポートすると場合によってはファイルが巨大になる可能性があります。

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" fill="none">
~~~
<image id="image0_19_43" width="1024" height="1024" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQAEAYAAAAvjffAAAABgWlDQ1BzUkdCIElFQzYxOTY2LTIuMQAAKJF1kblLQ0EQh78kiqIRC1OksEihVoloFNFOEjQKapFESNQmeeYQcjzeSxCxFWwDFh6NV2Fjra1gLQiCF+JfYKVoI/KcTYSImFl299vfzszuzoI9ltPyZtMA5AslIxwKeGLxBU/LI3bcOBFLaKY+G5mM0tA+brGp+cancjX2+9fal1OmBrZW4XFNN0rCU8JzqyVd8Y6wS8smloVPhb2GXFD4XunJGr8ozlTZrnK6jGg4KOwS9mR+cfIXa1kjLzws3JPPlbWf+6iXOFOF+YjSpXdjEiZEAA/
~~~
</svg>

上記にあるBase64エンコーディングで表現されている部分が巨大な文字列になっています。ファイルサイズが大きくなるとバンドルサイズが増え、読み込みに時間がかかってしまい、パフォーマンスが低下します。
Base64エンコードを使用している箇所があれば画像にデコードするか、最初からpngやjpg,webpでエクスポートして使用するのが良いかもしれません。

Cumulative Layout Shift(CLS)の改善

CLSとは

CLSについて軽く紹介します。
この指標はビューポート内に表示されているコンテンツのズレやレイアウトシフトがどれくらい起きているかを表すものです。例えば記事を読んでいる際に、最初にレンダリングされた段階では表示されていなかったコンテンツが突如画面の真ん中に表示され、読んでいた箇所がずれて見失ってしまうなどの現象があります。
これはユーザーの体験を損ねるものなので、なるべく解消することが望ましいです。

スケルトンを追加する

先ほど出てきたSuspenseのfallBackでは「Loading...」という文字を表示するだけでした。しかし、これだけだと例えば非同期処理が完了し、OneSecondWaitComponentコンポーネントの内容が表示されるとそのコンテンツの分だけズレが生じます。このズレはLighthouseで以下のように「直した方がいいよ」と言われます。

LighthouseによるCLSの原因の表示
web.devから引用

このズレを解決する方法として、表示するコンテンツの分だけのスペースを確保することが有効です。
広告の分だけズレたスペースを確保することでレイアウトシフトを減らしている図
web.devから引用

以下の例を見て見ます。
「loading...」という文字が表示された3秒後に表示されるダミーテキストと、即座に表示される「ditto」という文字列とメタモンの画像
最初は2つの「loading...」という文字が表示され、しばらく経つとメタモンと長いダミーテキストが順々に表示されます。
見てわかるように、コンテンツのズレが生じています。

コードの中身
BaseComponent.tsx
import { Suspense } from 'react';

import { Ditto } from './components/Ditto';
import { ThreeSecondsWaitComponent } from './components/ThreeSecondsWaitComponent';

export const BaseComponent: React.FC = () => {
  return (
    <div className="grid grid-cols-1 gap-10">
      <Suspense fallback={<p>Loading...</p>}>
        <ThreeSecondsWaitComponent />
      </Suspense>
      <Suspense fallback={<p>Loading...</p>}>
        <Ditto />
      </Suspense>
    </div>
  );
};
Ditto.tsx
import { useSuspenseQuery } from '@tanstack/react-query';

export const Ditto: React.FC = () => {
  const { data } = useSuspenseQuery({
    queryKey: ['ditto'],
    queryFn: async () => {
      const res = await fetch('https://pokeapi.co/api/v2/pokemon/ditto');
      return await res.json();
    },
  });

  return (
    <section className="grid grid-cols-1 justify-items-center">
      <h2 className="text-center text-2xl font-bold">{data.name}</h2>
      <img
        src={data.sprites.front_default}
        alt={data.name}
        width={96}
        height={96}
      />
    </section>
  );
};

では、レイアウトシフトを改善してみます。

ThreeSecondsWaitComponentDittoコンポーネントのスケルトンコンポーネントを作成します。
今回はmin-heightを使用して最低限の高さを設定しました。(ThreeSecondsWaitComponentではわかりやすいように背景色も追加しています)

// ThreeSecondsWaitComponent
export const ThreeSecondsWaitSkeleton: React.FC = () => {
  return <div className="min-h-[338px] w-full bg-gray-300" />;
};

// Ditto
export const DittoSkeleton: React.FC = () => {
  return <div className='min-h-[128px]' />;
};

コンポーネントをfallBackに設定します。

export const BaseComponent: React.FC = () => {
  return (
    <div className='grid grid-cols-1 gap-10'>
      <Suspense fallback={<ThreeSecondsWaitSkeleton />}>
        <ThreeSecondsWaitComponent />
      </Suspense>
      <Suspense fallback={<DittoSkeleton />}>
        <Ditto />
      </Suspense>
    </div>
  );
};

この状態で画面を見てみると...
ダミーテキスト部分にスケルトンが表示されレイアウトシフトが無くなっている

コンテンツが用意されるまでスケルトンが表示されており、メタモンの位置にズレが生じなくなっていることがわかります。これで簡易的なスケルトンを作成することができました!

また、もう少しデザイン的に凝ったスケルトンを用意するのであれば↓このようなスケルトンを用意するのが良いと思います。
スケルトンの例
Tailwind CSSの公式ドキュメントから引用

スケルトンやローディングの表示はデザインに関わるところなので、デザイナーがいる場合はスケルトンのデザイン設計も必要になってくるかと思います。

適切な画像サイズの設定

CLSを削減する方法として、適切な画像サイズの設定も必要です。画像サイズを設定するとブラウザが要素の最終的なレイアウトを計算する前に、その要素の空間を確保することができるためレイアウトシフトを防ぐことができます。

画像サイズを設定する方法として以下の2つがあります。

  1. img要素に直接width,heightを指定する
<img src="foo.png" width="300" height="200" alt="foo">
  1. CSSでaspect-ratioを設定する
img {
  aspect-ratio: 16 / 9;
  width: 100%;
  height: auto;
}

この2つの方法はどちらも同じ効果を持っています。というのも、1に関してはブラウザがデフォルトでimg要素に対してaspect-ratioを設定してくれるためです。

例えばwidth,heightが設定されている以下のimg要素があるとします。

<img src="https://images.site.yajihum.dev/rorisu.png" width="200" height="200" alt="正面から見て左に体を傾け左腕をあげているロリスの画像" class="h-36 w-36 md:h-60 md:w-60">

ChromeのDevToolsでこの要素を見てみると、
img要素にaspect-rationのCSSが追加されている
aspect-ratioが設定されていることがわかります。
画像を使用する場合はwidth,heightを設定するかaspect-ratioを設定するようにしましょう。

また、レスポンシブ画像については以下に詳しく書かれているためそちらを参照してみてください!
https://web.dev/articles/optimize-cls?hl=ja#responsive-images

Interaction to Next Paint(INP)の改善

最後にINPの改善方法について紹介します。

INPとは

INPについて軽く説明します。
INPとは、ユーザーのインタラクションに対してどれくらいのスピードで応答できているかを示す指標です。例えばアコーディオンを開いたときに、すぐに中身を表示できるかという即座の応答性が確かめられます。以前まで存在していたFIDとは違い、ページ操作がいつ発生するかに関係なく、全体的な応答性を測るものです。

キャッシュの利用

INPの改善に限定したことではありませんが、今回はここでキャッシュの話を取り上げます。
キャッシュを利用することでページの読み込み速度(TBT)が速くなり、ユーザーのインタラクションへの応答性を上げることができます。[2]

キャッシュには複数種類があります。

  1. ブラウザキャッシュ
    ブラウザがユーザの各デバイスにあるディスクにコンテンツを一時的に保存するもの
  2. メモリキャッシュ
    RAM内にデータを一時的に保存するキャッシュです。メモリキャッシュはディスクキャッシュよりも高速だが、限られた容量しか持っていない
  3. CDNキャッシュ
    Content Delivery Network(CDN)は、世界中のデータセンターにコンテンツのコピーを保存しており、最も近いデータセンターからコンテンツを取得できる

その他、サーバー側でも行われているキャッシュが他にも存在しますが、割愛します。
今回はそこまで懸念事項が少なく、比較的導入しやすい「2. メモリキャッシュ」を使用してパフォーマンス改善を行う方法を考えていきます。

staleTimeの設定

弊社ではフェッチライブラリーにTanstack Queryを使用しているのですが、staleTimecacheTimeを特に設定せずに使用していました。

  • staleTime:
    データが「古い」と見なされるまでの時間をミリ秒単位で指定する。staleTimeが設定された時間が経過すると、次にそのデータを参照する際にバックグラウンドで自動的にデータを更新(再フェッチ)する。
  • cacheTime:
    データがキャッシュから完全に削除されるまでの時間をミリ秒単位で指定する。データが「古い」と見なされ、かつこのcacheTimeが経過すると、そのデータはキャッシュから削除される。

それぞれデフォルト値が0と5分になるため、APIへのリクエストは都度行われてrevalidate(再検証)されているといった状態です。
ビルド時にのみ決定されるデータの場合は再検証は必要なかったり、そこまで頻繁に更新されるものでなければ短い時間でもstaleTimeを設定してサーバーの負担を減らした方が良いだろうということで今回からstaleTimeを設定してみることにしました。

これまでサービス全体では、特にフロントエンドのキャッシュを活用することがありませんでした。そこで、まずは段階的に小さく始めることを決定し、ディレクターと相談の上、更新頻度が低い特定のAPIに対してのキャッシュ設定を試してみました。

具体的には、staleTimeを30分、cacheTimeを1時間に設定しました。今回は、ユーザーがタブ間を移動する度に発生するサーバーへのリクエストを少しでも減らすことが目的であったため、この時間設定に決定しました。

// chacheTime.ts
export const thirtyMinutesCacheTime = 1_000 * 60 * 30;
export const oneHourCacheTime = 1_000 * 60 * 60;

// Pokemon.tsx
const { data } = useQuery(
  [queryKeys.pokemon, params],
  () => getPokemon(params),
  {
    staleTime: thirtyMinutesCacheTime,
    cacheTime: oneHourCacheTime,
  },
);

Tanstack Queryでのキャッシュはメモリ上でのキャッシュなので、リロードすればキャッシュは消えて新しくフェッチされます。なので、ブラウザやCDNキャッシュと比べて古いままのキャッシュが残ってしまうなどの懸念が比較的軽く済むため、設定できるところがあれば積極的に導入する方が良さそうです。

SWRの場合は、staleTimeをrefreshIntervalで、cacheTimeをdedupingIntervalで設定できるようです。

https://swr.vercel.app/ja/docs/api#options

不要なライブラリを削除する

各フレームワークで利用できるバンドルアナライザーを使うとバンドル内の各モジュールが占めるサイズを確認できます。バンドルサイズが大きいということは、ファイルの読み込みに時間がかかる他、JavaSciptを解析する時間が多くなり、ユーザーのインタラクションへの応答性を低くしてしまうことに繋がります。サイズの多いライブラリや、Vanilla JSで代替可能なライブラリがある場合は削除すると良いでしょう。

Viteアプリでのバンドルアナライザーの例
Viteでバンドルアナライザーを導入した例

例えば、弊社の例で言うと、恐らくアプリを作り始めた頃に導入されたであろうreact-helmetなどを最近削除してNext.jsのnext/headに置き換えました。
このように既に使用しているフレームワークで代替可能な場合は不要なライブラリを削除することで、更新の作業が減る他、バンドルサイズも減るためパフォーマンス改善を行うことができます。
今一度バンドルアナライザーを確認し、不要なライブラリがないかを確認してみてはいかがでしょうか!

可能な限りCSSを使用する

CSSで表現できる処理をJavaScriptを使って書いているコードがある場合は、可能な限りCSSを使用することをお勧めします。CSSはブラウザによって最適化されており、JavaScriptよりもパフォーマンスが高い場合が多いです。不要なJavaScriptの使用を控える[3]ことで、「控えめなJavaScriptを提供する」という点でProgressive Enhancementの考え方にも結びつきます。
ただし、モダンなCSSを使用する場合はブラウザによってサポートされていない場合があるため注意が必要です。

CSSで代替できそうな例として、以下が挙げられます

  1. アニメーションとトランジション
    CSSはアニメーションとトランジションを効率的に処理するための機能があります。JavaScriptでこれらを実装すると、ブラウザの再描画が頻繁に発生し、パフォーマンスが低下する可能性があります。

  2. レイアウトとスタイリング
    レイアウトやスタイリングは基本的にCSSで行うべきです。JavaScriptでスタイルを動的に変更すると、ブラウザはスタイルの変更ごとに再描画を行う必要があります。

  3. 擬似クラスの使用
    擬似クラスを使用することによってCSSで「ある条件の場合にこのスタイルを適用する」という状態を簡単に作ることができます。

  4. 要素の表示と非表示
    要素を表示または非表示にする場合、JavaScriptでDOMを操作するのではなく、CSSのdisplayプロパティを使用することが推奨されます。CSSでの制御で問題ない場合は積極的にdisplayプロパティを使用すると良いでしょう。

終わりに

この記事では、パフォーマンス改善のためのいくつかの基本的な手法を紹介しました。これらの手法はすべて小さく始めることができ、日々の開発ですぐに実践できるものとなっていると思います。

参考にしていただけると嬉しいです!

脚注
  1. https://web.dev/articles/fetch-priority?hl=ja#increase_the_priority_of_the_lcp_image ↩︎

  2. TBTとINPは相関関係が高いと言われています > 注: ページ読み込み時にスクリプト評価が過剰に発生しているかどうかを把握するうえで役立つ指標の 1 つに「Total Blocking Time(TBT)」があります。これは読み込みの応答性の指標です。TBT は INP と相関性が高いため、TBT が高いページは、読み込み中に高い INP 値が発生し、スクリプトの評価作業に関連している可能性があることを示す合理的な指標となります。 ↩︎

  3. web.devで「通常どおり、できる限り JavaScript の提供は最小限に留めてください。」と述べられています ↩︎

GitHubで編集を提案
株式会社COMPASS
株式会社COMPASS

Discussion

tommy34tommy34

紹介されているパフォーマンスの改善手法で何ms速くなったのか気になりました

やじはむやじはむ

コメントありがとうございます!
今回業務で改善を行なった部分はそもそも既存と仕様を変えたところであり、完全な比較が出来ておらず具体的な計測をしていないためお答えすることが出来ません。申し訳ないです🙇

ですが、Web Speed Hackathonで実際にパフォーマンス改善をした時はスコアを93.45(赤)→462.30(緑)まで持っていくことができました。
勿論、記事で紹介した方法以外でも改善をしたので正確な値ではないですが、少しでも参考になれば幸いです!

今後、パフォーマンス改善する機会があれば計測してどれくらい速くなったか追記しようと思います!