🏎️

Webパフォーマンスアップの話

2024/05/14に公開

Webのスピードアップについて(Next.jsの場合)

はじめに

筆者はここ2年くらいNext.jsを使用してWebアプリケーションを作ることが多い。
ここでは、Webアプリケーションの高速化に関して、アプリケーションのパフォーマンス(以下、Webパフォーマンスとする)、即ち 物理的にページが実行可能な速度 とユーザーから見た 体感速度(以下、体感速度とする) に分け、それぞれに関して実際にとあるWebアプリケーションを開発した上での方法論に関して記述していく。

使用されたFW、ライブラリのうちこの議論に関係のある主なものを列挙する

  • React.js
  • Next.js
  • ReactQuery
  • axios
  • Tailwind CSS

ここでは言及しないこと

  • APIサーバーの高速化
  • React.js, Next.jsなどのライブラリやFWの基本的な使い方
  • インフラ構成に関して

Webパフォーマンスと体感速度

一般論としては下記のようなことが言われている。

  • パフォーマンス: レスポンス速度数値で測定可能な要素
  • 体感速度: ユーザーがどれだけWebサイトが「速い」と感じるか、という主観的な要素

パフォーマンス向上といえば、例えば以下のようなことが思い浮かぶ

  • APIサーバーリクエストへのレスポンスの高速化
  • ページのロード時間の短縮
  • リソースの最適化によるアセット(スクリプト、画像など)の通信にかかる時間の短縮化
  • スクリプト自体のchunk(小さく分けること)
    クライアントサイドでのそれらは概ね、Lighthouseで測定可能

スクリーンショット 2023-09-18 23.27.53.png

体感速度を向上させる方法としては、概ね以下が思い浮かぶ

  • ユーザーを待たせている間の工夫
  • 画面遷移の工夫
    体感速度とは要するに、どう見せるか、見せ方の工夫といえる。

測定方法はユーザーテストやフィードバックなどで主観的に決める。開発者がユーザー目線を持ち合わせているとよりスムーズ。

Next.jsアプリケーションのパフォーマンスアップ策の一例

以下のような技術スタックで高速化できそうなところはどこか考えてみる。

  • React.js
  • Next.js
  • ReactQuery
  • axios
  • Tailwind CSS

サーバーサイドでの高速化の例

Next.jsとVercel、Next.jsとAWS Amplify Hostingの組み合わせでISRも利用可能だが、ISRは使えないことが多いためISRに関しては使わないこととする

Next.jsではgetServerSidePropsの関数を使用して、サーバーサイドでデータをフェッチすることができる。これにより、クライアントサイドでのデータフェッチが不要となり、体感速度は向上する。

では、ReactQueryを利用している場合はどうか。

ReactQueryの公式ドキュメントによると、
getServerSideProps関数内でReactQueryのfetchを実行し、その状態を dehydratedStateとしてクライアントサイドに渡すと、getServerSideProps内でFetchしたデータをクライアントサイドでも再利用することができる。

ただし、API FetchのエラーハンドリングはErrorBoundaryなどに切り分けて別で処理していることとする。以下の例で言うとfetchPosts内でFetchの共通処理の中でエラーハンドリングしているということにする。

import { useQuery, hydrate, dehydrate } from 'react-query';

export async function getServerSideProps() {
  const queryClient = new QueryClient();
  // fetchPostsはAPI Fetchの関数 axiosで実装したためAxiosResponse型のdataだけを返すように実装
  // apiKeys.posts.all.queryKey へのアクセスでユニークなqueryKeyが生成、それにアクセスしていることとする
  const res = await queryClient.fetchQuery(apiKeys.posts.all.queryKey, () => fetchPosts());
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
      posts, 
    },
  };
}

// JSX式...

Fetch関数はuseQueryを利用する前提で以下のように実装した

export const fetchPosts: () => Promise<ResponseGetPosts> = async () => {
  const res = await postsClient.getPosts();
  if (res.status !== 200 || !res.data) {
    return Promise.reject(res);
  }
  // snakeToCamelはAPIレスポンスデータがsnake_caseで返ってくるためこれらをcamelCaseに変換するための自作モジュール
  return snakeToCamel(res.data.data); 
};

export const useQueryPosts = (
  option?: UseQueryOptions<unknown, APIError, ResponseGetPosts>,
) => {
  const { queryKey } = apiQueryKeys.posts.all;
  const queryFn = async () => fetchPosts();
  return useQuery({
    queryKey,
    queryFn,
    ...option,
  });
};

クライアントサイドでの高速化の例

画像の最適化(Imageコンポーネント)

Next.jsが提供するNext/Imageコンポーネントを使用すると、画像の最適化が自動的に行われる。
公式によると以下のような簡単な記述で勝手に最適化してくれる。

import Image from 'next/image';

// なんらかの処理...

const OptimazedImage = () => <Image src="/exampleImage.png" alt="exampleImage" width={500} height={300} />;

Next.jsのImageコンポーネントは表示する端末の画面幅によって画像の出し分けが可能。

公式によるとImageコンポーネントの sizespropsを設定する場合 next.config.jsdeviceSizesのプロパティをいじることで制御できる。
imageSizesdeviseSizesで指定したより小さい幅の

デフォルトでは以下の設定になっている。

next.config.js
module.exports = {
  images: {
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
  },
}

今回は以下のように設定

next.config.js
module.exports = {
  images: {
    deviceSizes: [640, 828, 1080, 1200],
    // デザイン上で既知のサイズが以下の3パターンあったため追加
    imageSizes: [115, 200, 270],
  },
  // ...その他の設定
}

デザイン上の工夫

例えば、以下のように画像スライダーとサムネイルがある画面があるとする。

Screenshot 2024-05-10 at 22.27.49.png

こういうときはセオリー通りにやると、スライダー画像とサムネイルの画像の解像度を分けたくなるものだが、
今回は、画像へのリクエスト数を減らし、__サムネイルで読み込んだ画像キャッシュをスライダーで再利用する観点__から、サムネイルもスライダーと同じ解像度とした。

プリフェッチとプリロード

Next.jsには、Link コンポーネントが備わっている。このprefetch属性を用いれば、リンク先のページデータを事前にフェッチすることが可能

import Link from 'next/link';

const Navigation = () => (
  <nav>
    <Link href="/about" prefetch>
      <a>About</a>
    </Link>
    {/* 他のリンク */}
  </nav>
);

また、<link rel="preload"> を用いることで、必要なリソース(例:フォント、スクリプト)を事前にロードすることも可能である。

import Head from 'next/head';

<Head>
  <link rel="preload" href="/path/to/font.woff2" as="font" type="font/woff2" crossorigin />
  {/* その他でhead要素内にいれる要素 */}
</Head>

コードスプリッティングとダイナミックインポート

Next.jsはコードスプリッティングを自動で行う。しかし、ダイナミックインポートを用いれば、特定のコンポーネントやライブラリを遅延ロードすることもできる。

import dynamic from 'next/dynamic';

const DynamicComponent = dynamic(() => import('../components/DynamicComponent'));

function Page() {
  return <DynamicComponent />;
}

Tailwind CSSによる最適化

Tailwind CSSはパージ(不要なCSSの削除)機能を有している。tailwind.config.jsにおいてpurgeオプションを設定することで、本番環境において不要なCSSを削除することが可能である。

// tailwind.config.js
module.exports = {
  purge: ['./components/**/*.tsx', './pages/**/*.tsx'],
  // その他の設定
};

まとめ

Next.jsとその周辺技術(ReactQuery, axios, Tailwind CSS等)を用いることで、多角的なパフォーマンス最適化が可能である。サーバーサイドでのデータフェッチ、クライアントサイドでのリソース最適化、デザインの工夫など、多角的に最適化を行うことが重要である。

特に、体感速度を向上させるためには、ユーザー目線での最適化が必要である。プリフェッチ、プリロード、ダイナミックインポートなどを駆使し、ユーザーに快適な体験を提供することが求められる。

Discussion