📦

SSG + Partial Hydration (部分的なReact App) - minista v2.4

2022/05/17に公開

ReactのJSXで書けるスタティックサイトジェネレーター(以下SSG)ministaのv2.4に、部分的なReact App化を施すPartial Hydration機能を実装しました!今回は、この機能を導入した理由と実装方法のポイントを書いてみます。

Partial Hydrationとは

まず、水分補給を表すHydrationとは「サーバーが返したHTMLにJavaScriptの機能を戻す」ことを指しています。例えば、Next.jsのSGやGatsbyは、HTMLパース後にJavaScriptが実行されSPAの振る舞いが完成します。この乾麺にお湯を注ぐような仕組みがHydrationです。

<!-- 静的なHTML(このままでは動かない) -->
<div id="root">
  <div class="block-counter" data-reactroot="">
    <button type="button">increment</button>
    <p>count: 0</p>
  </div>
</div>
<script defer src="block-counter.js"></script>
// React 17のビルド前コード (block-counter.tsx)
import ReactDOM from "react-dom"
import { useState, useCallback } from "react"

const App = () => {
  const [count, setCount] = useState(0)
  const increment = useCallback(() => setCount((c) => c + 1), [])
  return (
    <div className="block-counter">
      <button onClick={increment} type="button">increment</button>
      <p>count: {count}</p>
    </div>
  )
}
const target = document.getElementById("root")
ReactDOM.hydrate(App, target) // HTMLにJavaScriptの機能を戻す

空のindex.htmlにJavaScriptですべてをレンダリングする方が簡単ですが、先にサーバーから静的なHTMLを返すことで初期表示を高速化できます。SEO対策も容易に。HydrationはHTMLとの差異がなければ再レンダリングを行わないためパフォーマンス的にも良いです。

ただ、これをサイト全体分作るとJavaScriptが肥大化します。HTMLと同量のテンプレートリテラルを書き込むので当然ですね。そこで、Hydrationしたいコンポーネントだけ抽出してJavaScriptを最小化する「Partial Hydration」が出てきました。Astroの設計に採用されています。

https://docs.astro.build/en/core-concepts/partial-hydration/

Partial Hydration導入の理由

とはいえ、ministaにPartial Hydrationを導入する予定はありませんでした。素晴らしいけど何をどうすれば実装できるのか想像もついていませんでしたし、Reactの凝ったサイトを作るのであればNext.jsか、それこそAstroを使えば良いと考えていたからです。

時は流れて1年後、Gatsby製の古い自前サイトをministaでリプレイスして回っていました。多くはReactの機能を使っておらず、JSXテンプレートやCMS連携が楽という理由でGatsbyを選んでいたため、前述のHydration用JavaScriptが不要でした。

ministaで静的サイトを書き出せば基本的にJavaScriptはゼロなので大きく軽量化できます。JSX記述もCMS連携も可能で体験も悪くなりません。ビルドも早い。ほぼ解決でしたが、一部のサイトだけリプレイスできずにいました。例えばフォントシミュレーターの付いたYakuHanJP

YakuHanJPのフォントシミュレーター

多数あるパターンをReactでState管理。これをVanilla.jsで書き直すのは面倒です。要件的にはAstroが向いていそうですが、ちょっとしたインタラクティブな要素が発生するたびにフレームワークを持ち替えるのは先々辛くなりそうな予感がします。

Astroを使う前にministaでPartial Hydrationできないか試してみようと。ダメもとで試行錯誤した結果の最終系が次項です。結果的になんとかなりました。

ministaのPartial Hydration実装

まずは使い方から。Partial Hydrationしたいコンポーネントのimportパスの末尾に ?ph を付与します。これだけでOK。コンポーネントで useState などが使えるようになります!

import BlockCounter from "../../components/block-counter?ph"

export default () => {
  return <BlockCounter />
}

次に実装面ですが、ministaはdevモードがReactのSPAなので拡張子をReact Componentとして判定するだけでした。問題は本番ビルド。全体を renderToStaticMarkup() で静的サイト化しているため、コンポーネントのJavaScript生成やHydrationができません。

そこで、本番ビルドの一時ファイルを生成するesbuildにプラグインを作って処理を挟みます。

import type { Plugin } from "esbuild"
import fs from "fs-extra"
import path from "path"
import { v4 as uuidv4 } from "uuid"
import { systemConfig } from "./system.js"

export function partialHydrationPlugin(): Plugin {
  return {
    name: "esbuild-partial-hydration",
    setup(build) {
      build.onResolve({ filter: /\?ph$/ }, (args) => {
        return {
          path: (path.isAbsolute(args.path)
            ? args.path
            : path.join(args.resolveDir, args.path)
          ).replaceAll("\\", "/"),
          namespace: "partial-hydration-loader",
        }
      })
      build.onLoad(
        { filter: /\?ph$/, namespace: "partial-hydration-loader" },
        async (args) => {
          const jsPath = args.path.replace(/\?ph$/, "")
          const uniqueId = uuidv4()
          const underUniqueId = uniqueId.replace(/-/g, "_")
          const outDir = systemConfig.temp.partialHydration.outDir
          const outFile = `${outDir}/modules/${underUniqueId}.txt`
          const template = `${jsPath}`

          await fs.outputFile(outFile, template).catch((err) => {
            console.error(err)
          })
          const dummy = `export default () => <div data-partial-hydration="${underUniqueId}"></div>`
          return {
            contents: dummy,
            loader: "tsx",
          }
        }
      )
    },
  }
}

これにより、importの末尾が ?ph のコンポーネントは underUniqueId を持ったダミー要素に差し替えられました。esbuildは args.path にimportしたコンポーネントのフルパスを渡してくれるので underUniqueId と紐づけて一時保存します。

この処理が成功したことでPartial Hydrationが可能になったと言っても過言ではありません!どのコンポーネントをどのDOMにレンダリングするのか明確になっているからです。あとは泥臭いビルド処理を書けばなんとかなります。

長くなるのでポイントだけ書きますと、まずesbuildが一時保存したファイルの情報を集めて以下のような一時ファイルを出力します。

import { renderToString } from "react-dom/server.js"
import PH_1 from "/github/minista/user/src/components/block-counter"
import PH_2 from "/github/minista/user/src/components/block-toggle"

// PH_1
const html_1 = renderToString(React.createElement(PH_1))
// PH_2
const html_2 = renderToString(React.createElement(PH_2))

export { html_1, html_2 }

esbuildでバンドルして呼び出せばHydration用のHTMLが得られるので、ページのHTMLを生成するタイミングでダミー要素と差し替えます。

<div data-partial-hydration="00000000_0000_0000_0000_000000000000">

↓ replace

<div data-partial-hydration="ph-1" style="display:contents;">
  <div class="block-counter" data-reactroot="">
    <button type="button">increment</button>
    <p >count: 0</p>
  </div>
</div>

それとは別にReact Appを作る一時ファイルも生成します。以下をViteでビルドすればHydration用のJavaScriptが得られるので、ページのHTMLで読み込ませるという訳です。

import React from "react"
import ReactDOM from "react-dom"
import PH_1 from "/github/minista/user/src/components/block-counter"

// PH_1
const targets_1 = document.querySelectorAll('[data-partial-hydration="ph-1"]')
if (targets_1) {
  targets_1.forEach(target => {
    const App = React.createElement(PH_1)
    const options = {
      root: null,
      rootMargin: "0px",
      thresholds: 1,
    }
    const observer = new IntersectionObserver(hydrate, options)
    observer.observe(target)

    function hydrate() {
      ReactDOM.hydrate(App, target)
      observer.unobserve(target)
    }
  })
}

余談など

とりあえず、Partial Hydrationができるようになりました!インタラクティブな要素が多少あってもministaで作ってしまえそうです。あとは、Preactを使った容量削減やオプションなど、説明しきれない部分がありましたので、ドキュメントサイトも参照してみてください。

https://minista.qranoko.jp/docs/assets#partial-hydration

Discussion