Vite + React で SSG する(vite-plugin-ssr 利用した最小構成)

React
ReactTypeScriptVite

以前の記事では特に plugin など利用しませんでしたが、今回は vite-plugin-ssr を利用した方法です。実際には Pre−rendering 時に情報を取得してレンダリングしたりと言ったことをすると思います。この場合は選択肢として有力そうな vite-plugin-ssr 試してみました。

前提

以下で作ったベースからのスタートしています。

バージョンは以下の通り。vite-plugin-ssr は 0.4.91 です。

├── @types/react-dom@18.0.9
├── @types/react@18.0.26
├── @typescript-eslint/eslint-plugin@5.46.0
├── @typescript-eslint/parser@5.46.0
├── @vitejs/plugin-react@3.0.0
├── eslint-config-prettier@8.5.0
├── eslint-plugin-react-hooks@4.6.0
├── eslint-plugin-react@7.31.11
├── eslint@8.29.0
├── node-fetch@3.3.0
├── prettier-plugin-organize-imports@3.2.1
├── prettier@2.8.1
├── react-dom@18.2.0
├── react@18.2.0
├── typescript@4.9.4
├── vite-plugin-ssr@0.4.91
├── vite-tsconfig-paths@4.0.2
└── vite@4.0.0

インストール

yarn add -D vite-plugin-ssr

SSG を行うための変更

vite.config.ts

公式にあるように ssr({ prerender: true }) を追加します。Pre−rendering でない場合は ssr() のみでよさそうです。

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import ssr from 'vite-plugin-ssr/plugin'
import tsconfigPaths from 'vite-tsconfig-paths'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), tsconfigPaths(), ssr({ prerender: true })],
})

_default.page.client.tsx / _default.page.server.tsx

公式の react-tour に様々な方法が書かれているので詳細はそちらを見るのがよいですが、基本形としては、

  • ページは .page.tsx とする。Next.js みたいにフォルダ構成をもとにルーティングされる。
  • ブラウザのみで実行するものは .page.client.tsx
  • Node.js のみで実行するものは .page.server.tsx
  • すべてのページにデフォルトで適用するには、以下を用意する。
    • /renderer/_default.page.client.js
    • /renderer/_default.page.server.js
  • エラー発生時は _error.page.tsx

とのこと。以下のような構成になります。今回は /about アクセス時にのみ、事前処理を追加するため about.page.server.ts を用意しています。

./src
├── main.tsx
├── pages
│   ├── about.page.server.ts
│   ├── about.page.tsx
│   └── index.page.tsx
├── renderer
│   ├── _default.page.client.tsx
│   ├── _default.page.server.tsx
│   └── _error.page.tsx
├── types.ts
└── vite-env.d.ts

以下、_default.page.client.tsx です。

import React from 'react'
import ReactDOM from 'react-dom/client'
import '../index.css'
import type { PageContextClient } from '../types'

export { render }

let root: ReactDOM.Root

function render(pageContext: PageContextClient) {
  const { Page, pageProps } = pageContext

  // 画面は Page として取得できる。pageProps には SSG
  // 実施時に事前取得した情報を含む
  // 後述するが、今回は pagePrps: { comments }
  const page = (
    <React.StrictMode>
      <Page {...pageProps} />
    </React.StrictMode>
  )

  const container = document.getElementById('page-view')!
  // アクセス初回時は hydration する
  if (pageContext.isHydration) {
    root = ReactDOM.hydrateRoot(container, page)
  } else {
    // 以下は 公式のサンプルにあったが必要なのかは不明。今回の検証においてはコメントアウトしても問題なし
    // if (!root) {
    //   root = ReactDOM.createRoot(container)
    // }

    // 初期画面表示後、クライアント側でのルーティング時にはこちらが呼ばれる
    root.render(page)
  }
}

// クライアント側でのルーティングをする際には必要
export const clientRouting = true

render メソッドの PageContextClient ですが、/about アクセス時は以下で、Page は about.page.tsx を指しているのがわかります。

続いてサーバ側。こちらは公式ほぼそのままです。

import ReactDOMServer from 'react-dom/server'
import { dangerouslySkipEscape, escapeInject } from 'vite-plugin-ssr'
import { PageContextServer } from '../types'

export { render }
// client 側で pageProps を使うために必要
export { passToClient }

const passToClient = ['pageProps']

async function render(pageContext: PageContextServer) {
  const { Page, pageProps } = pageContext
  const viewHtml = ReactDOMServer.renderToString(<Page {...pageProps} />)
  const title = 'Vite SSR'

  return escapeInject`<!DOCTYPE html>
    <html>
      <head>
        <title>${title}</title>
      </head>
      <body>
        <div id="page-view">${dangerouslySkipEscape(viewHtml)}</div>
      </body>
    </html>`
}

ちなみに、初回アクセス時のレスポンスを見るとこんな感じで、head 部に必要な処理が挿入されていました。

pages 配下

index.page.tsx

シンプルに Hello! 。あとは画面遷移するリンクだけを配置。vite-plugin-ssr を利用する場合、画面遷移は navigate で行います。

import { navigate } from 'vite-plugin-ssr/client/router'

export { Page }

function Page() {
  return (
    <>
      <div>Hello!</div>

      <div>
        <button onClick={() => navigate('/')}>index</button>
        <button onClick={() => navigate('/about')}>About</button>
      </div>
    </>
  )
}

about.page.tsx

ここも何でもよいのですが、useStatefetch を行う処理を追加しています。
また、Pre−rendering 時に取得される comments もレンダリングさせています。

import { PageProps } from '@/types'
import { useEffect, useState } from 'react'
import { navigate } from 'vite-plugin-ssr/client/router'

export { Page }

function Page({ comments = null }: PageProps) {
  const [count, setCount] = useState(0)
  const [todo, setTodo] = useState({})

  useEffect(() => {
    async function getTodo() {
      const _todo = await (
        await fetch('https://jsonplaceholder.typicode.com/todos/1')
      ).json()
      setTodo(_todo)
    }
    getTodo()
  }, [])

  return (
    <>
      <div>About Page Test</div>
      <span>{count}</span>
      <button onClick={() => setCount(count + 1)}>++</button>
      <div>{JSON.stringify(todo)}</div>
      <div>{comments !== null ? JSON.stringify(comments) : ''}</div>

      <div>
        <button onClick={() => navigate('/')}>index</button>
        <button onClick={() => navigate('/about')}>About</button>
      </div>
    </>
  )
}

about.page.server.ts

about 用の Pre-rendering のための情報取得処理です。onBeforeRender を定義することで情報を渡すことができます。

import { PageContextServer } from '@/types'
import fetch from 'node-fetch'

export { onBeforeRender }

async function onBeforeRender(pageContext: PageContextServer) {
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/comments/1',
  )
  const comments = await response.json()
  return {
    pageContext: {
      pageProps: { comments },
    },
  }
}

その他

型情報については、公式のサンプル をそのまま利用しています。

動作結果

yarn build すると、pre-rendering で index と about ができています。

serve で build 結果を起動

$serve dist/client

/ (locahost:3000/)に遷移

レンダリング結果が返っているのがわかります。
画面表示後、ボタンクリックで /about ページに遷移もできます。

その後 /about に遷移

http://localhost:3000/about/index.pageContext.json という GET メソッドが実行されており、about ページで pre-rendering される情報が返却されています。
その後、初期処理で実行していた `https://jsonplaceholder.typicode.com/todos/1` が呼ばれています。

/about (locahost:3000/anout)に遷移

直接 /about に遷移した場合は以下のように、pre-rendering された結果がちゃんと返ってきています。このあと、useState などもちゃんと動きます。

おわりに

vite-plugin-ssr を利用することで、以下だけで SSG できそうです。すごい。

  • 画面遷移は .page.tsx で定義。画面は Page を export
  • renderer/_default.page.client.tsx / renderer/_default.page.server.tsx でデフォルト動作定義
  • Pre-rendering したい情報は onBeforeRender する