🍋

Next.jsのISRを独自に実装する ~ レンダリングミドルウェアによるCSR/SPAサイトの高速化編 ~

2022/06/28に公開

この記事は何?

これは「Next.jsのISRを独自に実装する」という記事の続編になります。

https://zenn.dev/chimame/articles/7570c71d1e6c38

この記事では、上で紹介しきれなかった、CSRなページ・SPAなコンテンツをスタティックなページに変換して配信するということについて紹介していきます。
前提となる情報は上の記事で述べられているため、まずはそちらをご覧ください。

全体像

上の記事にでも説明されていた概要図をそのまま引用しています。
本記事で説明するのは、Cloudflare Workers以外の部分です。
ここではオリジンをNext.jsにしていますが、Create React Appで作ったようなプレーンなSPAサイトでも構いません。


Next.jsのISRを独自に構築する ~ Cloudflare Workers編(Cache APIの注意点) ~

Workerとオリジンとの間にレンダリングミドルウェアを挟むことで、事前に代理レンダリングをおこない、その結果のHTMLをWorkerにキャッシュさせるといった構成になります。

言葉で説明するのは簡単ですが、これを構築するにあたって、解消すべき課題がいくつかありますのでその方法について触れていきます。

  • データフェッチが二重に起きないようにしないといけない
    • データフェッチによるサスペンドを抑制する
  • ユーザの手元ではインタラクティブに動くページになっていなければならない
    • 単純にHydrateするとHydrate Mismatchが起きてしまう

レンダリングミドルウェアに関して

今回はRendertronを採用しました。

https://developers.google.com/search/blog/2019/01/dynamic-rendering-with-rendertron?hl=ja

RendertronはGoogleのweb.devやlighthouseを開発しているコミュニティが作成した、ダイナミックレンダリングのためのミドルウェアです。

ダイナミックレンダリング
Rendertron によるダイナミック レンダリング ~ ダイナミック レンダリングの仕組み

Headless Chromiumを使用して仮想ブラウザ上でCSR・SPAなサイトをレンダリングし、静的なHTMLのみを返却することができます。
Googleはユーザエージェントによって、静的なHTMLを返すのか、JSを返却してクライアントでレンダリングさせるのかを切り替えることをダイナミックレンダリングと呼んでいます。

数年前までは、クローラーはJSによるレンダリング結果を解釈できませんでした。またJamstackなサイトも今ほどは浸透はしていなかったため、 このような方法で、クローラーフレンドリーなサイトを構築する手法が提案されていました。

Rendertronの修正

Rendertronをレンダリングミドルウェアとして採用するに当たり、少しソースの修正が必要になります。

Rendertronは最終的にscriptタグを排除したり、baseタグを挿入する実装になっていますが、今回は最終的にユーザのクライアントでインタラクティブに動かす必要があるため、このような処理は不要です。

Rendertronのリポジトリをクローンするか、あるいはpatch-package等でコードを変更しましょう。

https://github.com/GoogleChrome/rendertron/blob/main/src/renderer.ts#L48-L94

上記コードのstripPageinjectBaseHrefが不要ですのでそれを消します。

また、レンダリングミドルウェアを通過したHTMLであることを示すエレメントを挿入しておきます。このdivは後ほどの対応の中で使用します。

// https://github.com/GoogleChrome/rendertron/blob/main/src/rendertron.ts
function preRenderedMark() {
  if (window.extractApolloCache) {
    const e = document.createElement('div');
    e.id = 'x-prerender';
    e.setAttribute('data-x-prerender', '');
    document.body.prepend(e);
  }
}

await page.evaluate(preRenderedMark);

あとは公式のREADMEに従ってホスティングすれば、レンダリングミドルウェアの準備は完了です。

補足: Prerenderに関して

Rendertronと同じようなものに、Prerenderというものがあります。

https://github.com/prerender/prerender

こちらのライブラリもRendertronと同様にHeadless Chromiumを使用して事前レンダリングを行うミドルウェアです。
Rendertronと違ってスクリプトタグを排除する処理はなく、また、プラグインを書いて任意の処理を挿入できます。
そのため、実は今回のアーキテクチャにはPrerenderを採用する方が最適です。

しかし、サーバレスでのホスティングが想定されておらず、Chromiumのプロセスの管理がサーバレスと相性が良くないために、安定稼働させることができませんでした。
今回はレンダリングミドルウェアをCloud Runでホスティングしたかったため、Rendertronを選択しました。

サーバレスな構成をとらないのであれば、Rendertronのほうがスムーズに構築ができると思いますので、自身の環境にあったものを選択してください。


レンダリングミドルウェアの準備ができたところで、オリジン側に少し対応を行っていきます。

二重データフェッチの抑制

レンダリングミドルウェアでは、ユーザのクライアント同様にデータフェッチも行われますが、何も手を打たずに、事前レンダリングした結果を返却すると、もう一度同じデータフェッチがクライアントでも発生してしまいます。
そこでキャッシュからデータをリストアできるようにして、ミドルウェア上で行われたデータフェッチをクライアント側ではスキップさせます。

これはApollo Clientの例です。

https://www.apollographql.com/docs/react/v2/caching/cache-interaction/#server-side-rendering

// apollo-client.ts
const cache = new InMemoryCache()
if (typeof window !== 'undefined') {
  // キャッシュをリストアする
  if (window.__APOLLO_STATE__)
    cache.restore(window.__APOLLO_STATE__)
    
  // キャッシュをhtmlに書き込むための関数
  window.extractApolloCache = () => {
    return JSON.stringify(cache.extract()).replace(/</g, '\\u003c')
  }
}

Rendertronの処理を変更し、レンダリング完了後に window.extractApolloCache() を実行してキャッシュをbodyに書き込みます。

// https://github.com/GoogleChrome/rendertron/blob/main/src/rendertron.ts
function apolloCacheInsert() {
  if (window.extractApolloCache) {
    const e = document.createElement('script');
    e.innerHTML = `window.__APOLLO_STATE__=${window.extractApolloCache()}`;
    document.body.prepend(e);
  }
}

await page.evaluate(apolloCacheInsert);

ユーザクライアント側では、cache.restore(window.__APOLLO_STATE__)の実行を通じて、Apollo Clientのデータストアにキャッシュデータがリストアされます。

さらに、キャッシュポリシーを下記のようにしておくことで、最初のフェッチ(ランディング時)はキャッシュを優先し、ナビゲートなど2回目以降は最新のデータをフェッチしにいくようにコントロールできます。

return new ApolloClient({
  ...,
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-first',
      nextFetchPolicy: 'cache-and-network',
    },
  },
})

こうすることで、Rendertronでのデータフェッチの結果を利用して、ユーザクライアントでのデータフェッチよるサスペンドの発生を解消できます。

Hydrate Mismatchの解決

オリジンにNext.jsを採用している場合、クライアントサイドでの(一度目の)レンダリングはHydrateで解決されます。

https://github.com/vercel/next.js/blob/canary/packages/next/client/index.tsx#L541-L574

これによって仮想DOMをフルでマウントするのをスキップして、イベントハンドラのアタッチのみでページをインタラクティブな状態にできます。

しかし、これはサーバサイドのレンダリング結果と、クライアントサイドでのマウント前のDOMの構造が完全に一致していなければなりません。
もし不一致を起こしていると、パフォーマンスの低下につながったり、DOM構造自体が壊れてしまう可能性があます。

今回のアーキテクチャではレンダリングミドルウェアがサーバサイドの役目を果たすため、Rendertron上で生成されたHTMLと、ユーザクライアントでの仮想DOMの不一致がHydrate Mismatchになります。
たとえば、Dynamic Importなどを利用しているケースでは、通常のサーバサイドレンダリングでは、ターゲットとなるコンポネントは代替コンポネントで解決されますが、Rendertron上ではソースをロードしてコンプリートするため、そこが確実にミスマッチになってしまいます。

この問題を解決するために、少々力技ですが、比較の対象物がずれないようにします。
簡単に説明すると、レンダリングミドルウェアで生成したHTMLを、マウント前にdangerouslySetInnerHTMLで、そのまま自身の仮想DOMとして扱うことで差異が発生しないようにします。さらにマウント後に本来のコンポネントをレンダリングします。

マウント直後に、再レンダリングが発生してしまいますので、正しいHydrateと同様のパフォーマンスが得られるわけではありませんが、先の方法でデータフェッチはスキップしていますので、単純なCSRと比べれば十分に早くなっています。

// DeceiveHydrateAfterPrerender.tsx
export const DeceiveHydrateAfterPrerender: FC<{ children: ReactNode }> = ({
  children,
}) => {
  const preRendered = usePrerender()
  const [mounted, mount] = useReducer(() => true, false)
  useEffect(() => {
    mount()
  }, [])

  // マウント前にレンダリングミドルウェアで事前生成したHTMLがあればそれを見せる
  if (!mounted && typeof document !== 'undefined' &&
          !!document.getElementById('x-prerender'))
    return (
      <div
        id="deceive-hydrate-target"
        dangerouslySetInnerHTML={{
          __html:
            document.getElementById('deceive-hydrate-target')?.innerHTML ?? '',
        }}
      />
    )

  return <div id="deceive-hydrate-target">{children}</div>
}
// _app.tsx
function MyApp({ Component, pageProps }: AppProps) {
  return (
    <DeceiveHydrateAfterPrerender>
      <Head />
      <Component {...pageProps} />
    </DeceiveHydrateAfterPrerender>
  )
}

Workerとの接続

あとは、Cloudflare Workers上でリクエストをプロキシして、レンダリングミドルウェアにリクエストが向かうようにします。

const url = new URL(originalRequest.url)
url.host = 'rendertronのホスト名'
url.pathname = `/render/${originalRequest.url}`
const proxiedRequest = new Request(url.toString(), originalRequest)
return fetch(proxiedRequest, originalRequest)

しかし、リアルタムにレンダリングミドルウェアを挟むと、ミドルウェア上でレンダリングを行う分、レスポンスは遅延することに注意してください。
下の記事の処理と組み合わせて、非同期にレンダリングさせることで、レイテンシを発生させることなく、独自にISRを実現できるようになります。

https://zenn.dev/chimame/articles/7570c71d1e6c38

パフォーマンスの変化

このレンダリングミドルウェアを挟む前後のCSRなページのパフォーマンスをlighthouseで計測してみました。

レンダリングミドルウェア無し レンダリングミドルウェア有り

データフェッチによるサスペンドがなくなり、またランディングした瞬間にすでにレンダリングされた結果が存在しているため、LCPが大きく改善してしていることがわかります。
また、Time to Interactive は向上しているものの、劇的な改善につながっていないのは、Hydrate後に再レンダリングが発生しているためであり、このアーキテクチャの課題とも言えます。

まとめ

CSR/SPAで構築されたページの配信に、レンダリングミドルウェア挟んでスタティック化する方法を述べました。
getStaticPropsを使ったページのCWVのスコアに勝りはしませんが、単純なCSR・SPAと比較すると大きくCWVを改善することができますし、事前レンダリングされているのでクローラーフレンドリーでもあります。

GitHubで編集を提案

Discussion