Next.jsを少しずつ理解していく11【ISR/Incremental Static Regeneration】

今回は、SSGの問題点を紹介しつつその解決策としてISR(Incremental Static Regeneration)の仕組みと使い方を解説します。

SSG/静的サイトジェネレーターの問題点

SSG(Static Site Generation/静的サイトジェネレーション)とは、アプリケーションのビルド時にすべてのHTMLページが生成されるレンダリング方式です。生成されたファイルはCDN※にキャッシュされクライアントからのリクエストに応じて即座に提供されます。

CDNとは

CDN(Content Delivery Network)とは、世界中に張り巡らされた配信ネットワークを利用して、Webサイトにアクセスしようとするユーザーに効率的かつ高速にWebコンテンツを配信する仕組みです。
これにより、Webサイトの表示を高速化させたり、たくさんの人が同時にアクセスしても遅くならないようにすることができます。

SSGのメリット

SSGは読み込みから表示までの速度が速く、各ページが検索エンジンにインデックスされるためSEOにも強いという特徴があります。このようなことから、SSGというレンダリング方式は様々なアプリケーションを構築するための優れたアプローチといえます。

SSGのデメリット/問題点

SSGはビルド時にすべてのページを生成するため、ビルドに必要な時間は生成するページ数に比例して大きくなります。例えば1ページ当たりの生成に100msかかるとして、商品ページが100ページ程度のECサイトであれば10秒ほどでビルドできますが、100,000ページ程に増えると約2時間半以上かかることになります。新たな製品が追加されるたびにコストはどんどん増していきます。

 

あるいは、SSGで一度生成したページは更新されず、次にビルドするまで古いデータが残り続けるという問題もあります。例えばECサイトの場合はページを更新しなければならないシチュエーションが無数にあります。新製品の追加、商品説明の変更、価格変更、口コミの反映などなど。そんなとき、特定の製品に対するたった1つの口コミを反映させるためだけに毎度ビルドすることは、方法としてあまり適切ではありません。

ISR(増分静的リジェネレーター)

つまるところSSGの問題点は「アップデートが必要なページだけをアップデートすることできない」ことにあります。しかし、ISRという方法を利用することでこの問題を解決できます。

Next.jsのISR(Incremental Static Regeneration)を使用すると、一度ビルドした後でも静的に生成されたページをアップデートすることができます。一度生成したページに古いデータが残り続ける問題もISRを使用することで解決できます。

ISRを使う

今回はISRを実際に使用しながら、ビルド後の静的ページにデータ変更を反映させられるのかを確認していきます。

環境を整える

まずはjson-serverというパッケージをインストールします。json-serverはデータを取得するための偽のAPIを構築するために使用します。これまで、データの取得にはJSONPlaceholderを使用してきましたが、JSONPlaceholderではデータの内容をこちら側で変更することができないため、今回はそれが可能なjson-serverパッケージを利用します。

Terminalでnpm install json-serverを実行したらpackage.jsonを開き、dependenciesにjson-serverが追加されているかを確認してください。

ついでに、scriptsに"server-json"スクリプトを追加してください。これで、後の生成するdb.jsonのデータを4000番ポートで表示できるようなります。

Terminal

npm install json-server

pre-rendering/package.json

(以上略)
"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "server-json": "json-server --watch db.json --port 4000"//←これを追加します
  },
  "dependencies": {
    "json-server": "^0.16.3",
    "next": "12.2.5",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
(以下略)

 

続いては偽のAPIを通して取得するデータを作成します。作成したらTerminalを開いて、npm run server-jsonを実行してください。

pre-rendering/db.json

{
    "products": [
        {
            "id": 1,
            "title": "Product 1",
            "price": 1000,
            "description": "Description 1"
        },
        {
            "id": 2,
            "title": "Product 2",
            "price": 2000,
            "description": "Description 2"
        },
        {
            "id": 3,
            "title": "Product 3",
            "price": 2500,
            "description": "Description 3"
        }
    ]
}

Terminal

npm run server-json

https://localhost:4000

https://localhost:4000/products

https://localhost:4000/products/1

次にpagesフォルダの中に新たにproductsフォルダを要し、その中にindex.jsファイルと[productId].jsファイルを作成します。

Terminal

mkdir products
touch products/index.js
touch products/[products].js

/pages/products/index.js

import Link from "next/link"

function ProductList( {products} ) {
    return(
        <>
        <h1>List of Products</h1>
        {
            products.map((product) => {
                return(
                    <div key={product.id}>
                        <Link href={`/products/${product.id}`} passHref>
                            <h2>{product.id} {product.title} {product.price}</h2>
                        </Link>
                        <hr/>
                    </div>
                )
            })
        }
        </>
    )
}

export default ProductList

export async function getStaticProps() {
    const res = await fetch('http://localhost:4000/products')
    const data = await res.json()

    return {
        props:{
            products:data,
        },
    }
}

/pages/products/[productId].js

import { useRouter } from 'next/router'

function Product({ product }) {
    const router = useRouter()

    if(router.isFallback) {
        return <div>Loading...</div>
    }

    return(
        <div>
            <h2>
                {product.id} {product.title} {product.price}
            </h2>
            <p>{product.description}</p>
            <hr/>
        </div>
    )
}

export default Product

export async function getStaticPaths() {

    return {
        paths: [
            {params: {productId: '1'}},
            {params: {productId: '2'}},
            {params: {productId: '3'}},
        ],
        fallback: 'true',
    }
}

export async function getStaticProps(context) {
    const { params } = context
    const res = await fetch(
        `http://localhost:4000/products/${params.productId}`
        )
    const data = await res.json()

    return {
        props: {
            product: data,
        },
    }
}

ISRを使用する

まずは、index.jsファイルでISRを使用してみましょう。ISRを使用するには、getStaticProps関数の中でrevalidateキーを指定しなければなりません。revalidateでは「どれくらいの間隔でデータ更新を反映させるか?」という値を設定します。例えばrevalidate: 10とすれば10秒間隔で新しいデータが反映されます。

また、いつgetStaticProps関数が呼び出されたか(いつ新しいデータが反映されたか)をわかりやすくするためにconsole.logも追加します。ここで記載したconsole.logのメッセージはブラウザではなく、VScodeのターミナル上に表示されます。

/products/index.js

export async function getStaticProps() {
    //console.logも追加
    console.log("Generating / Regenerating ProductList")
    const res = await fetch('http://localhost:4000/products')
    const data = await res.json()

    return {
        props:{
            products:data,
        },
        //revalidateを設定
        revalidate: 10,
    }
}

 

続いては、ビルドを行った後にブラウザでISRの挙動を確かめます。まずは.nextフォルダを削除して、Terminalでnpm run buildnpm run startを実行します。

ビルド後のTerminalの表示を確認すると、postsページとは異なり、productsページには(ISR: 10 Seconds)と表示されていることが分かります。

Terminal

rm -r .next
npm run build
npm run start

ここでlocalhost:3000/productsを確認すると、プロダクト情報の一覧が3つ表示されます。

この時点でTerminalを見ると、console.log()で指定したメッセージ「Generating / Regenerating ProductList」が表示されているため、index.jsのgetStaticProps関数が呼び出されたことを確認できます。この場合は単に/productsパスの最初のリクエストに対してページを生成した際にgetStaticProps関数が使用されただけ(Generating)なので、ISRが働いたわけではありません。

しかし、10秒ほど経過してからもう一度ページをリロードするとTerminalにもう一度メッセージが表示されます。通常であれば一度静的に生成されたページはキャッシュ(記憶)され、以降のアクセスではそのキャッシュからファイルが提供されます。つまり一度ページが生成されるとgetStaticProps関数は二度と使用されないのです。

ですが、今回はindex.jsファイル(/products)にrevaridateを設定しているため、一度生成されたページでも一定間隔(今回は10秒)が経過するごとにgetStaticProps関数を呼び出せるようになっています。(逆に言えば、一度getStaticProps関数を呼び出してから10秒経過するまでは、何度リロードしてもキャッシュされたファイルが提供され続けます)

これが、ISRを使用すると更新したデータを一度ビルドした後の静的ページに反映させられる理由です。

混乱しがちなISRの挙動

では実際にどのようにブラウザにデータの変更が反映されるのかを確認してみましょう。現在product1の表示価格は1000となっているので、これを900に変えてみます。

db.jsonファイルを開いて、"id": 1のpriceを900に変更してください。

db.json

{
    "products": [
        {
            "id": 1,
            "title": "Product 1",
            "price": 900,
            "description": "Description 1"
        },
(以下略)

 

さて、このタイミングで一度ブラウザをリロードするとどうなるでしょうか?多くの方は「変更が反映されるはず」と考えていると思います。

どうでしょうか?変更は反映されていなかったと思います。(がしかし、Terminalを確認するとメッセージが1つ増えている=getStaticPropsが呼び出されている)

ではもう一度リロードしてみてください。

今度は無事に反映されましたね。さてここで疑問に思うのは「一度目のリロードでは、確かにgetStaticProps関数が呼び出されてデータが更新されたはずなのに、なぜブラウザに変更が反映されなかったのか?」です。

 

答えはNext.jsのISRの特殊な挙動にあります。Next.jsでは、ユーザーAがISRページ(revaridateが設定されたページ)にアクセスするとgetStaticProps関数が呼び出されてデータの更新が行われ、それを反映した新たなページファイルが生成されます。しかし、その生成されたファイルはその時のユーザーAには提供されず、キャッシュされた古いページを提供します。そして、それ以降にそのページにアクセスしてきたユーザー(二度目にアクセスしたユーザーAも含む)がいれば、古いページファイル(キャッシュ)を削除したうえで新たなページファイルを提供するのです。

つまり先ほどの実験では、一度目にアクセスした際に新たなデータを反映したページファイルが生成されたものの提供されず、二回目にアクセスした際に古いページファイルが削除され、データ変更を反映した新たなページファイルが提供されたということになります。

ダイナミックルーティングでISRを使う

今度は[productId].jsファイルでもrevalidateを設定して、その挙動を確かめてみましょう。

まずは[productId].jsファイルを開き、getStaticProps関数内にrevalidateとconsole.logを設定します。その後、.nextフォルダを削除したらnpm run buildnpm run startを実行してください。

/products/[productId].js

(以上略)
export async function getStaticProps(context) {
    const { params } = context
    const res = await fetch(
        `http://localhost:4000/products/${params.productId}`
        )
    const data = await res.json()

    console.log(Reeneration product ${params.productId})

    return {
        props: {
            product: data,
        },
        revalidate: 10
    }
}

Terminal

rm -r .next
npm run build
npm run start

 

ブラウザでlocalhost:3000/products/1にアクセスすると、「Description 1」というメッセージが表示されます。Terminalを見ると「Regenerating product 1」と表示されていることからgetStaticProps関数が呼び出されていることも確認できます。

では、db.jsonを開いてデータを好きに変更してみて下さい。変更を保存してページをリロードしてみると、、、

先ほどの実験と同じく変更は表示されません。ではもう一回リロードすると、、、

無事に反映されましたね。ダイナミックルーティングを行うページでもISRの挙動は変わりません。

気が付きましたか?

ISRの最大の特徴は、必要なページだけを再生成(Regenerated)して更新を反映させれられる点にあります。

Terminalを見てみると表示されているメッセージは「Regeneration product 1」だけなので、product 2やproduct 3ページは再生成されていないことが分かります。
このことからNext.jsのISRでは、サイト全体を再生成せずに必要なページだけに変更を反映させていることが確かめられます。

まとめ

次回はSSR(サーバー・サイド・レンダリング)について紹介します。

 

コメントを残す

CAPTCHA