Next.js14 の PPR(Partial Prerendering)は、画面表示速度を改善するか?

## Motivation

Next.js 14 の新機能[^1]の一つ、Partial Prerendering(PPR) に関する記事はすでに多数あるが、PPR を導入することで実際に何がどう変わるのかについての情報が乏しいような気がしており、その辺りを自分で動かして確認しようというのが本記事の目的である。

[^1]: 2023/12 現在、Preview 版となっている。

## PPR とは

https://nextjs.org/blog/next-14#partial-prerendering-preview

> We'd like to share a preview of Partial Prerendering — a compiler optimization for dynamic content with a fast initial static response — that we're working on for Next.js.

平たく言うと、静的コンテンツをより速く表示するための最適化のことと理解している。

> Partial Prerendering builds on a decade of research and development into server-side rendering (SSR), static-site generation (SSG), and incremental static revalidation (ISR).

Next.js で実装されてきた数々のアーキテクチャを経て、ここに行き着いたようである。

そして、この後の実証でも詳述するが、**PPR はあくまでも「あるページを表示した時の静的コンテンツを如何に速くサーブするか」を目的としており、それにより動的コンテンツを含めたページ全体の表示速度も変化する**というのが私の理解である。[^2]

参考記事:

https://vercel.com/blog/partial-prerendering-with-next-js-creating-a-new-default-rendering-model

https://nextjs.org/learn/dashboard-app/partial-prerendering


[^2]: この部分は自分でも自信がないため、有識者の方にご意見ご指摘いただきたい。


## 確認してみたこと

静的コンテンツと動的コンテンツが配置されるページの初期表示速度を、PPR あり/なしの場合についてそれぞれ測定し、考察する。

## 前提

- 使用したマシン: Mac(Apple M1 Pro), RAM 32GB
- node -v: v18.18.0
- npm -v: 9.8.1
- ブラウザ: Google Chrome バージョン: 119.0.6045.199（Official Build）

## 準備

### 環境用意

まず Canary Release のバージョンによる Next.js 14 の create next app を行う。

```sh
npx create-next-app@canary
# "next": "^14.0.5-canary.19",
```

今回は src ディレクトリ、App Router を有効にした。

### コードを修正

デフォルトで生成されるコードを修正し、PPR が有効になるように静的コンテンツと動的コンテンツを配置する。

#### `next.config.js` で PPR の設定を有効にする。

```diff_shell
/** @type {import('next').NextConfig} */
const nextConfig = {
+  experimental: {
+    ppr: true,
+  },
};

module.exports = nextConfig;
```

#### 静的コンテンツと動的コンテンツの配置

静的/動的コンテンツの定義について。**動的**コンテンツとは「`Promise` を throw するようなコンポーネント」のことを指す。というのも **PPR は React18 の `Suspense` API を利用して実現される**のだが、その時 `<Suspense>` に渡す children コンポーネントが動的コンテンツであり、そのコンポーネントは外部システムから API 経由でデータを fetch してきたりデータベース接続したりと、何らかの非同期処理を行われるようなものであるからである。
静的コンテンツはそれ以外のもの、ということになる。

今回動的コンテンツ部分に使用した Promise を throw するコンポーネントとして、PokeAPI を利用した。(ちなみに、私はポケモンを全くプレイしたことがない:pray:)

https://pokeapi.co/

以下は、`npx create-next-app` によって自動生成されたコード以外に手をつけた部分を掲載する。

```app/page.tsx
import { Suspense } from "react";
import { PokemonList } from "@/components/PokemonList";
import data from "@/app/data.json";
import Image from "next/image";
import styles from "./page.module.css";

const limits = [1, 10, 20, 30, 50, 100];

export default function Home() {
  return (
    <main>
      <h1>Dashboard</h1>
      <div style={{ width: "1200px", backgroundColor: "gray" }}>
        <div
          style={{
            width: "100%",
            display: "grid",
            gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr 1fr",
            gridGap: "10px",
          }}
        >
          {/* 静的コンテンツ */}
          {[...new Array(6)].fill(0).map((_, i) => (
            <StaticData
              title={`Static data ${i + 1}`}
              key={i}
              data={data.results}
            />
          ))}
          {/* 動的コンテンツ */}
          {[...new Array(6)].fill(0).map((_, i) => (
            <div key={i}>
              <Suspense fallback={<Loading />}> {/* <PokemonList> 内の Promise が resolve されるまでは、fallback に指定したコンポーネントがレンダリングされる */}
                <PokemonList
                  title={`Dynamic data ${i + 1}`}
                  offset={100 * i}
                  limit={limits[i % 6]}
                />
              </Suspense>
            </div>
          ))}
        </div>
      </div>
    </main>
  );
}
const Loading = () => {
  return (
    <div style={{ width: "100%", marginLeft: "20px", display: "table" }}>
      <p
        style={{
          textAlign: "center",
          lineHeight: "200px",
        }}
      >
        {"Now Loading..."}
      </p>
    </div>
  );
};

type TPoke = {
  name: string;
  url: string;
};

const StaticData = ({ title, data }: { title: string; data: TPoke[] }) => {
  return (
    <div
      style={{
        width: "90%",
        height: "100px",
        marginLeft: "20px",
        backgroundColor: "blue",
        overflow: "auto",
      }}
    >
      <p>{title}</p>
      <ul>
        {data.map((p, i) => (
          <li key={`${i}_${p.name}`} style={{ marginLeft: "20px" }}>
            {p.name}
          </li>
        ))}
      </ul>
    </div>
  );
};
```

動的コンテンツでは、`<PokemonList>` に渡す `limit` に応じて API fetch によるデータ取得量を調節している。

以下の静的コンテンツに表示するポケモンデータリストは、数が多いので途中省略している。

```app/data.json
{
  "count": 1292,
  "next": null,
  "previous": null,
  "results": [
    { "name": "bulbasaur", "url": "https://pokeapi.co/api/v2/pokemon/1/" },
    { "name": "ivysaur", "url": "https://pokeapi.co/api/v2/pokemon/2/" },
    { "name": "venusaur", "url": "https://pokeapi.co/api/v2/pokemon/3/" },
    { "name": "charmander", "url": "https://pokeapi.co/api/v2/pokemon/4/" },
    { "name": "charmeleon", "url": "https://pokeapi.co/api/v2/pokemon/5/" },
    { "name": "charizard", "url": "https://pokeapi.co/api/v2/pokemon/6/" },
    { "name": "squirtle", "url": "https://pokeapi.co/api/v2/pokemon/7/" },
    { "name": "wartortle", "url": "https://pokeapi.co/api/v2/pokemon/8/" },
    { "name": "blastoise", "url": "https://pokeapi.co/api/v2/pokemon/9/" },
    { "name": "caterpie", "url": "https://pokeapi.co/api/v2/pokemon/10/" },
    ...
  ]
}
```

最後に動的コンテンツ(`<PokemonList>`)の中身。

```src/components/PokemonList.tsx
type TPoke = {
  name: string;
  url: string;
};

type TResponse = {
  results: TPoke[];
};

export async function PokemonList({
  title,
  offset,
  limit,
}: {
  title: string;
  offset: number;
  limit: number;
}) {

  // limit 数分のポケモンリストを取得
  const pokemons: TResponse = await fetch(
    `https://pokeapi.co/api/v2/pokemon?offset=${offset}&limit=${limit}`,
    {
      cache: "no-store",
    }
  ).then((res) => res.json());

  // 取得したポケモンリストに対して詳細情報を取得
  // このリクエスト結果は何にも使用していない。
  // 単に↑のリスト取得 API のみだと PokeAPI のレスポンスが速すぎて Promise が瞬時に resolve されてしまい面白くないため、
  // 無意味に負荷をかけて limit 数に応じて resolve に時間を要するようにしている。
  for (const p of pokemons.results) {
    await fetch(p.url, {
      cache: "no-store",
    }).then((res) => {
      res.json();
    });
  }

  return (
    <div
      style={{
        width: "90%",
        height: "200px",
        marginLeft: "20px",
        backgroundColor: "red",
        overflow: "auto",
      }}
    >
      <p>{title}</p>
      <ul>
        {pokemons.results.map((p, i) => (
          <li key={`${i}_${p.name}`} style={{ marginLeft: "20px" }}>
            {p.name}
          </li>
        ))}
      </ul>
    </div>
  );
}
```

以上をもって、実行すると PPR(Partial Prerender)によって HTML が生成されている旨が表示される。

```sh
npm run build
...

Route (app)                              Size     First Load JS
┌ ◐ /                                    137 B          83.5 kB
└ ○ /_not-found                          884 B          84.3 kB
+ First Load JS shared by all            83.4 kB
  ├ chunks/565-418886544c024b0a.js       27.6 kB
  ├ chunks/bf6a786c-7471427646f1da28.js  53.9 kB
  ├ chunks/main-app-c4e277afda00546c.js  221 B
  └ chunks/webpack-4080d5c1cd5d9ae8.js   1.64 kB


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
```

`npm start` で localhost:3000 にアクセスする。

<img width="700px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/34c9ea35-7dd8-0c2b-5fb7-6434a1053ecc.png">

青枠が静的コンテンツで、赤枠が動的コンテンツとなっている。
画像では、動的コンテンツの6個目の Promise がまだ resolve されておらず、`Now Loading...` が表示されている。



以降では、これをベースにしつつ、この赤枠/青枠のコンテンツ(＝コンポーネント)数に着目して画面初期表示速度の計測に入っていきたい。

## 画面初期表示速度測定

### 何を測るか?

web ページの表示速度の指標としては色々あると思うが、今回は以下の3点に注目した。

- `TTFB`: ページにアクセスしてリクエストが投げられてからサーバーから最初の1バイトを受け取るまでの時間
- `FCP(First Contentful Paint)`: ユーザーが最初にページに移動してから、ページのコンテンツのいずれかの部分が画面にレンダリングされるまでの時間
- `DOMContentLoaded`: DOM ツリーの構築が完了する時間

`TTFB` は、短いほどユーザーがコンテンツを目にするまでの時間が短縮される[^3]ので、指標として重要であると判断した。

[^3]: TTFB が短ければ、ユーザーが意味のあるコンテンツを確認できるまでの時間も速いとは必ずしも言えるわけではないが、少なくとも一要因ではある。


https://web.dev/articles/ttfb?hl=ja

>要点:
TTFB は Core Web Vitals の指標ではないため、重要な指標のスコアを上げることが妨げられない限り、サイトが「良好」な TTFB しきい値を満たすことは必ずしも必要というわけではありません。
ウェブサイトによってコンテンツの配信方法はさまざまです。TTFB の短さは、マークアップをできるだけ早くクライアントに配布するうえで不可欠です。ただし、ウェブサイトが最初のマークアップを迅速に提供し、そのマークアップで意味のあるコンテンツを入力する JavaScript が必要な場合は（単一ページ アプリケーション（SPA）の場合と同様）、クライアントによるマークアップのレンダリングがより早く行われるように、TTFB を可能な限り最小限に抑えることが特に重要です。
逆に、サーバー側でレンダリングされクライアント側での作業を必要としない場合は、TTFB は高くなりますが、完全にクライアント側でレンダリングする場合よりも FCP と LCP の値は向上します。このため、TTFB のしきい値は「おおまかなガイド」であり、サイトのコア コンテンツの提供方法と比較して考慮する必要があります。

`FCP` は、TTFB だけでは測定できない、「実際にユーザーは画面に何か描画されているのを見えるか」を確認する上で重要な指標である。

https://web.dev/articles/fcp?hl=ja


`DOMContentLoaded` は、サーバーからリクエストに対するレスポンスを受け、DOM ツリーの構築が完了したタイミングであり、これ以降に画像や CSS/font ファイルのロードが実行される。今回のセッティングでは、PPR の有無による差異が生じないと判断したため、このタイミングを大体画面の初期表示が完了したものとすることにした。


### 測定ツール

TTFB と FCP は `Web Vitals` という Google Chrome の拡張を利用して測定した。

https://chromewebstore.google.com/detail/web-vitals/ahfhijdlegdabablpippeagghigmibma

拡張を有効にして測定したい web ページを開くと、このような見た目で表示される。

<img width="400px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/dccaedec-45be-60bf-aa3d-73eb61bac5fa.png">


DOMContentLoaded は、Chrome のディベロッパーツール > ネットワークタブ を確認した。

<img width="600px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/10624fe6-a1da-5925-acad-5bcf80a51950.png">

### 検証結果

静的/動的コンテンツの量(コンポーネント数)の大小関係で何パターンか測定した。
各ケースにおいて、5回リロード/計測を行い、その平均値を掲載する。

#### ケース1. 基本パターン(静:6個, 動:6個)

|  | PPR あり | PPR なし | PPR なし / PPR あり
|:-:|:-:|:-:|:-:|
| TTFB  | 0.024s  | 0.15s  | 6.1|
| FCP  | 0.25s  |  0.38s | 1.6 |
| DOMContentLoaded  | 1.8s  | 3.6s  | 2.0 |


#### ケース2. 静的コンテンツ > 動的コンテンツ(静:60個, 動:6個)

|  | PPR あり | PPR なし | PPR なし / PPR あり
|:-:|:-:|:-:|:-:|
| TTFB  |  0.030s |  2.0s |67|
| FCP  |  0.67s |  2.3s |3.5|
| DOMContentLoaded  |  2.3s |  4.0s |1.7|


#### ケース3. 静的コンテンツ < 動的コンテンツ(静:6個, 動:60個)

|  | PPR あり | PPR なし | PPR なし / PPR あり
|:-:|:-:|:-:|:-:|
| TTFB  | 0.030s  |  0.20s |9.2|
| FCP  | 0.26s  | 0.45s  |1.7|
| DOMContentLoaded  | 5.3s  | 5.3s  |1.0|


#### ケース4. 静的コンテンツ == 動的コンテンツ(静:60個, 動:60個)

|  | PPR あり | PPR なし | PPR なし / PPR あり
|:-:|:-:|:-:|:-:|
| TTFB  | 0.026s  | 2.0s  |79|
| FCP  | 0.72s  | 2.4s  |3.4|
| DOMContentLoaded  |  6.0s | 7.2s  |1.2|

### 結果からわかったこと

- TTFB に関して
    - PPR の有無により、6.1~79倍の差が生まれた。
        - これは、PPR が無効の時、TTFB が**静的コンテンツの量に依存して長くなっている**である。
    - PPR が有効の時、常に一定である。
- FCP に関して
    - PPR が有効/無効の時ともに、**静的コンテンツの量に依存して長くなっている**。
        - ただし、その上昇幅は PPR が無効の時の方が大きい。
    - PPR の有無により、1.6~3.5倍の差が生まれた。
- DOMContentLoaded に関して
    - PPR が有効/無効の時ともに、**動的/静的コンテンツの量に依存して長くなっている**。
        - その中でも、影響が強いのは動的コンテンツの方である。
    - PPR の有無により、1.0~2.0倍の差が生まれた。

### 考察

まず最初に、**PPR ありの方が表示に関する指標全て良かった**ことが挙げられる。その中でも TTFB の差は大きく、特に静的コンテンツが多い場合にその差はより顕著となる。これは PPR ではページの読み込み時、動的コンテンツのレンダリングを待たずに即座に静的コンテンツ部分の HTML がレンダリングされるため、 それらが多ければ多いほど、PPR の有無で差が開くものと思われる。
また、ページリロードボタンをクリックした時の画面切り替えが、PPR ありの場合はかなり速く、ストレスを感じさせないものであった。下の GIF アニメーションは、左側が PPR あり、右側が PPR なしのもので、ほぼ同じタイミングでリロードされるように見せたものである。**クリックから最初のレンダリング開始までの時間が明らかに PPR ありの時の方が速く**、トータルでも迅速に画面が表示されている。

<img title="PPR あり" width="350px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/c22f9b0d-4890-6b65-86fc-a12cd620f2cc.gif">

<img title="PPR なし" width="350px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/9925d717-c096-6f30-5a6d-d5134ba31376.gif">

以上より、PPR は静的なコンテンツが多く、その中に適度に動的コンテンツが散りばめられたようなページにおいて大きな効力を発揮するのではないかと思う。[こちら](https://vercel.com/blog/partial-prerendering-with-next-js-creating-a-new-default-rendering-model#not-just-for-app-shells)にもそのような記載がある。

## 今後見ていきたいこと

- 今回は localhost での確認であったが、Vercel にデプロイすることで、ISR(Incremental Static Regeneration)や Edge Network の恩恵を受けさらにどうなるのかを見てみたい。