👻

vite-plugin-ssrでWebアプリのトップページだけSSGにする

2023/08/06に公開

最近こういうWebアプリを作っています.
画像の上にお絵かきできるオンラインホワイトボードです.

ページ構成はトップページとホワイトボードページ2種の計3ページです.
ユーザーはトップページからローカルモードかオンラインモードを選び入室し,お絵かきするといった流れです.
ユーザー入力に対して画面上の要素が変動(線を描いたり,画像を差し替えたり)するのは/local/onlineのみです.

今回はトップページだけSSGにしてみます.

環境

  • Typescript
  • Vite
  • Firebase Hosting

${project_root}/srcに各種.tsxファイルが存在するディレクトリ構成.
すでにCSRなWebアプリとしてViteでビルドが通っています.

準備

まずは現状のプロジェクトにvite-plugin-ssrを導入します.
パッケージを引っ張ってきて設定を追加するだけです.

  1. npm install -D vite-plugin-ssr

  2. vite.config.tsにプラグイン使用の設定を追加

    // ...
    import ssr from "vite-plugin-ssr/plugin"
    
    export default defineConfig({
        plugins: [
            react(),
            ssr({ prerender: true }) // 追加
        ]
    });
    

    SSGにするためにprerender: trueとします.

ページの基礎を作成

レンダリングのエントリーポイント(?)になるファイルを作ります.

サーバーサイド

SSGビルドの際にhtmlを生成させる処理を書きます.

  1. ビルドするページのコンポーネントを取得する
  2. Reactにコンポーネントのhtmlを書き出させる
  3. 土台のhtmlに流し込む

といった流れです.

src/renderer/_default.page.server.tsxに書いていきます.
このファイル内でrender()を定義し,exportします.
render()は引数としてビルド対象のページのコンテキストを受け取り,html文字列を返します.
コンテキストにはページURLやそのページで描画するべきコンポーネント(後で指定する)などが含まれています.

export { render };

import { renderToString } from "react-dom/server";
import { escapeInject, dangerouslySkipEscape } from "vite-plugin-ssr/server";
import type { PageContextBuiltIn } from "vite-plugin-ssr/types";

async function render(pageContext: PageContextBuiltIn) {
  const { Page } = pageContext;
  let pageHtml;
  if (Page) {
    pageHtml = renderToString(<Page />);
  } else {
    pageHtml = "";
  }

  return escapeInject`<!DOCTYPE html>
    <html lang="ja">
      <body>
        <div id="root">${dangerouslySkipEscape(pageHtml)}</div>
      </body>
    </html>`;
}

クライアントサイド

次にクライアントサイドでレンダリングする処理を書きます.
SSGの時点で各ページ用のhtmlファイルが生成されます.
このままではWebアプリは動作しません.
トップページで画面遷移に使用しているスクリプト部分が削られているほか,書き込んだ線や背景とする画像のようなユーザーの操作に反応する動的な要素を表示できないためです.

よってそれらを実現する,すなわちクライアント側で動作するスクリプトを流し込みます.

src/renderer/_default.page.client.tsxに書いていきます.
サーバーサイドと同じ様にrender()を定義してexportします.
こちらは引数は変わりませんが,返り値はありません.

export { render };
export const clientRouting = true;
export const hydrationCanBeAborted = true;

import { Root, createRoot, hydrateRoot } from "react-dom/client";
import type { PageContextBuiltInClientWithClientRouting } from "vite-plugin-ssr/types";

let root: Root;
async function render(pageContext: PageContextBuiltInClientWithClientRouting) {
  const { Page } = pageContext;

  const page = (<Page />);

  const container = document.getElementById("root");
  if (container === null) return;
  
  // 初遷移でcsrページである or client side routingである
  if (container?.innerHTML === "" || !pageContext.isHydration) {
    if (!root) {
      root = createRoot(container);
    }
    root.render(page);
  } else {
    root = hydrateRoot(container, page);
  }
}

各ページを作成

先程2つのコードでpageContextから取得していたPageコンポーネントを作成していきます.
これは個別のURLを持つ各ページそれぞれに用意します.

今回はFilesystem Routingを使うので,ディレクトリ構造が直接URL構造となります.
まずはトップページ/のコンポーネントを作ります.
src/pages/index/index.page.tsxで定義します.

とはいえ元々SPAとして作っていたのでこの画面に対応するコンポーネント<Home />がすでにあります.
よってこれを返す関数をexportするだけです.

import { Home } from "./path/to/component/Home";

export { Page };

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

CSRなページ

クライアント側だけで実行するコードはindex.page.client.tsx内に書きます.
/local/onlineはSSGではコンポーネントは書き出さないのでこっちで書きます.

というわけでsrc/pages/online/index.page.client.tsxとかに書いていきます.
URLパラメータに応じて処理を切り替える場合はここで値を取得するなどします.

import { OnlineCanvas } from "path/to/component/OnlineCanvas";

export { Page };

function Page() {
  const params = new URLSearchParams(window.location.search);
  const roomId = params.get("roomId");

  return (
    <>
      <OnlineCanvas roomId={roomId} />
    </>
  );
}

ビルド & デプロイ

SSGで生成されるHTMLファイルやCSRで使うスクリプト類をnpm run buildでビルドします.
ビルドが成功すればdist/に諸々が生成されます.

今回はSSG+CSRなのでdist/clientをデプロイします.
firebase.json

{
    "hosting": {
        "public": "dist/client",
        ...
    }
}

とします.

あとはnpx firebase deployでデプロイすれば完成です.

Discussion