Vite + React の SSR/SSG の基本的な動きを理解する

React
ReactTypeScriptVite

Next.js を利用すれば SSR/SSG(CSR)/ISR いずれも提供されているので実現することができますが、Vite + React で SSG する方法です。

Vite 公式から辿れる以下のようなライブラリを利用することで React の SSGはできそうです。

https://github.com/vitejs/awesome-vite#ssr

実際試してはないですがパット見た限りだと vite-plugin-ssr が有力なのかなと思いました。がバージョンは 2023/3/4 時点で 0.49.0 だったので留意を。ちなみに vite-ssg は Vue 用ですね。

  • vite-ssr: SSRはできるけど、SSGについては言及されていない
  • vite-plugin-ssr: SSRはもちろん、SSGについてもやり方含めて記載されている
  • ssr: Github の readme が中国語だったのでほぼ見ていません

が、これまで概念レベルでは理解していましたが、コードレベルでは触れたことがなかったため、もう少し基礎を理解すべく vite-plugin-react にある playground/ssr-react を動かしてみました。

公式 にも説明があるので、そちらを見つつこの記事を見ると理解できるかと。

前提

冒頭の通り、playground/ssr-react を動かしています。

バージョン

バージョンは以下の通り

├── @vitejs/plugin-react@3.1.0
├── compression@1.7.4
├── express@4.18.2
├── react-dom@18.2.0
├── react-router-dom@6.8.2
├── react@18.2.0
├── serve-static@1.15.0
└── vite@4.1.4

変更点

SSG 実施後に useState や fetch の状態も確認するため、/about に対応する pages/About.jsx にコードを追加しています。jsonplaceholder.tpicode.com は テスト用に fake データを返してくれます。

import { useEffect, useState } from 'react'
import { addAndMultiply } from '../add'
import { multiplyAndAdd } from '../multiply'

export default  function About() {
  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 (
    <>
      {JSON.stringify(todo)}
      <h1>About2</h1>
      <div>{addAndMultiply(1, 2, 3)}</div>
      <div>{multiplyAndAdd(1, 2, 3)}</div>
      <div>
      {count}
      <button onClick={() => {
        console.log("aa")
        setCount(count+1)}
      }>Cnt++</button>
      </div>
    </>
  )
}

SSR

server.js

Github 上の server.js に色々コメントを追加したものです。色々書きましたが、やっていることは以下です。

  1. テンプレートとなる HTML を選択
  2. ReactDOMServer.renderToString を利用して、指定URLの画面をレンダリングする
  3. 2の結果を1にはめ込み返却する
  4. クライアント側は、ReactDOM.hydrateRoot を行うことで、ReactDOMServer で生成された HTML コンテンツを hydrate する
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'

// import.meta
//   コンテキスト固有のメタデータを JavaScript のモジュールに公開
//   https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/import.meta
//   ここの場合、import.meta の値は以下
//   {"url":"file:///xxxx/yyyyy/vite-plugin-react/playground/ssr-react/server.js"}
//
// fileURLToPath
//   https://nodejs.org/api/url.html#urlfileurltopathurl
//   パーセントでエンコードされた文字を正しくデコードし、クロスプラットフォームで有効な絶対パス文字列を保証
//
// __dirname: /xxxx/yyyyy/vite-plugin-react/playground/ssr-react
const __dirname = path.dirname(fileURLToPath(import.meta.url))

const isTest = process.env.VITEST

process.env.MY_CUSTOM_SECRET = 'API_KEY_qwertyuiop'

export async function createServer(
  // 現在のカレントディレクトリ
  root = process.cwd(),
  isProd = process.env.NODE_ENV === 'production',
  hmrPort,
) {
  // 絶対パスを返す
  // path.resolve(__dirname, 'a', 'b', 'c');
  // => current_path/a/b/c
  const resolve = (p) => path.resolve(__dirname, p)

  // prod の場合は、同期的に dist/client/index.html を読み込む
  // prod じゃなければ空文字
  const indexProd = isProd
    ? fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
    : ''

  const app = express()

  /**
   * @type {import('vite').ViteDevServer}
   */
  let vite
  if (!isProd) {
    vite = await (
      await import('vite')
    ).createServer({
      root,
      logLevel: isTest ? 'error' : 'info',
      server: {
        // ミドルウェアモードで起動
        // https://ja.vitejs.dev/config/server-options.html#server-middlewaremode
        // https://github.com/vitejs/vite/blob/38ce81ceb86d42c27ec39eefa8091f47f6f25967/packages/vite/src/node/server/index.ts#L352
        // あたりでが該当の実装。この場合 listen はできない。
        middlewareMode: true,

        // chokidar に設定するオプション
        // https://github.com/paulmillr/chokidar
        // ポーリングをするか否か
        watch: {
          // During tests we edit the files too fast and sometimes chokidar
          // misses change events, so enforce polling for consistency
          usePolling: true,
          interval: 100,
        },

        // HMR 用の port, host, path & protocol 設定
        hmr: {
          port: hmrPort,
        },
      },
      // appType は spa | mpa だと htmlFallbackMiddleware が設定される
      // https://github.com/vitejs/vite/blob/48150f2ea4d7ff8e3b67f15239ae05f5be317436/packages/vite/src/node/server/index.ts#L597
      // Vite 自身が HTML を提供しないようそれ以外を設定(なので custom という文字じゃなくてもよさそう)
      appType: 'custom',
    })
    // use vite's connect instance as middleware
    app.use(vite.middlewares)
  } else {
    // リソースを圧縮する middleware
    app.use((await import('compression')).default())
    app.use(
      (await import('serve-static')).default(resolve('dist/client'), {
        index: false,
      }),
    )
  }

  // サーバでレンダリングしたHTMLを提供
  app.use('*', async (req, res) => {
    try {
      const url = req.originalUrl

      let template, render
      if (!isProd) {
        // index.html をベースにする
        // 
        // template の出力例: 
        // <!DOCTYPE html>
        // <html lang="en">
        //   <head>
        //     <meta charset="UTF-8" />
        //     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        //     <title>Vite App</title>
        //   </head>
        //   <body>
        //     <div id="app"><!--app-html--></div>
        //     <script type="module" src="/src/entry-client.jsx"></script>
        //   </body>
        // </html>
        template = fs.readFileSync(resolve('index.html'), 'utf-8')

        // 変換後 (/about アクセス例)
        //
        // template の出力例: 
        // <!DOCTYPE html>
        // <html lang="en">
        //   <head>
        //     <script type="module">
        // import RefreshRuntime from "/@react-refresh"
        // RefreshRuntime.injectIntoGlobalHook(window)
        // window.$RefreshReg$ = () => {}
        // window.$RefreshSig$ = () => (type) => type
        // window.__vite_plugin_react_preamble_installed__ = true
        // </script>
        //
        //     <script type="module" src="/@vite/client"></script>
        //
        //     <meta charset="UTF-8" />
        //     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        //     <title>Vite App</title>
        //   </head>
        //   <body>
        //     <div id="app"><!--app-html--></div>
        //     <script type="module" src="/src/entry-client.jsx"></script>
        //   </body>
        // </html>
        template = await vite.transformIndexHtml(url, template)

        // entry-server.jsx の render 関数 
        // ssrLoadModule は自動的にESM を Node.js で使用できるコードに変換するとのこと
        //
        // export function render(url, context) {
        //   return ReactDOMServer.renderToString(
        //     <StaticRouter location={url} context={context}>
        //       <App />
        //     </StaticRouter>,
        //   )
        // }
        render = (await vite.ssrLoadModule('/src/entry-server.jsx')).render
      } else {
        // build:client で出力した dist/client/index.html がテンプレート
        template = indexProd
        // entry-server.js の render 関数
        // @ts-ignore
        render = (await import('./dist/server/entry-server.js')).render
      }

      const context = {}
      // appHtml の出力結果は以下(以下は整形したもの)
      //
      // <nav>
      //   <ul>
      //    <li><a href="/about">About</a></li>
      //    <li><a href="/env">Env</a></li>
      //    <li><a href="/">Home</a></li>
      //   </ul>
      //  </nav>{}
      //  <h1>About2</h1>
      //  <div>9</div>
      //  <div>5</div>
      //  <div>0<button>Cnt++</button></div>
      const appHtml = render(url, context)

      if (context.url) {
        // Somewhere a `<Redirect>` was rendered
        return res.redirect(301, context.url)
      }

      // template にあった 
      //   <div id="app"><!--app-html--></div>
      // の部分に上の appHtml(出力結果)を出力
      const html = template.replace(`<!--app-html-->`, appHtml)

      // レンダリングされた結果を返す
      // html の出力例: 
      // <!DOCTYPE html>
      // <html lang="en">
      //   <head>
      //     <script type="module">
      // import RefreshRuntime from "/@react-refresh"
      // RefreshRuntime.injectIntoGlobalHook(window)
      // window.$RefreshReg$ = () => {}
      // window.$RefreshSig$ = () => (type) => type
      // window.__vite_plugin_react_preamble_installed__ = true
      // </script>
      //
      //     <script type="module" src="/@vite/client"></script>
      //
      //     <meta charset="UTF-8" />
      //     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      //     <title>Vite App</title>
      //   </head>
      //   <body>
      //     <div id="app"><nav><ul><li><a href="/about">About</a></li><li><a href="/env">Env</a></li><li><a href="/">Home</a></li></ul></nav>{}<h1>About2</h1><div>9</div><div>5</div><div>0<button>Cnt++</button></div></div>      //     <script type="module" src="/src/entry-client.jsx"></script>
      //   </body>
      // </html>
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      !isProd && vite.ssrFixStacktrace(e)
      console.log(e.stack)
      res.status(500).end(e.stack)
    }
  })

  return { app, vite }
}

if (!isTest) {
  createServer().then(({ app }) =>
    app.listen(5173, () => {
      console.log('http://localhost:5173')
    }),
  )
}

結果

/about 表示時にレンダリングされた結果が返ってきつつ、クライアント側で fetch が呼ばれレスポンスが画面に反映されました(画面初期状態は {“userId: 1,xxx の部分は {} です) 。また useState を使用している箇所も問題なく動作しており、ちゃんと hydrate されていることが確認できました。

SSG

prerender.js

やっていることは SSR とほぼ同じです。

  1. テンプレートとなる HTML を選択(build:client した成果物の html)
  2. 画面対象のURL(このコードでは pages 配下を抽出)に対して、build:server した成果物を用いて ReactDOMServer.renderToString を行い、指定URLの画面をレンダリングする
  3. 2の結果を1にはめ込む
  4. クライアント側は、ReactDOM.hydrateRoot を行うことで、ReactDOMServer で生成された HTML コンテンツを hydrate する
// Pre-render the app into static HTML.
// run `yarn generate` and then `dist/static` can be served as a static site.

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const toAbsolute = (p) => path.resolve(__dirname, p)

// build:clinet で生成したファイル dist/static/index.html を読み込む
const template = fs.readFileSync(toAbsolute('dist/static/index.html'), 'utf-8')
// server レンダリングの render 関数
const { render } = await import('./dist/server/entry-server.js')

// determine routes to pre-render from src/pages
// src/pages 配下にある jsx を画面として、画面のパスを取得する
// routesToPrerender: ["/about","/env","/"]
const routesToPrerender = fs
  .readdirSync(toAbsolute('src/pages'))
  .map((file) => {
    const name = file.replace(/\.jsx$/, '').toLowerCase()
    return name === 'home' ? `/` : `/${name}`
  })

;(async () => {
  // pre-render each route...
  // entry-server.js の render (ReactDOMServer.renderToString)を行う
  //
  // pre-rendered: dist/static/about.html
  // appHtml の出力例
  //  : <nav><ul><li><a href="/about">About</a></li><li><a href="/env">Env</a></li><li><a href="/">Home</a></li></ul></nav>{}<h1>About2</h1><div>9</div><div>5</div><div>0<button>Cnt++</button></div>
  //
  // pre-rendered: dist/static/env.html
  // appHtml の出力例
  //  : <nav><ul><li><a href="/about">About</a></li><li><a href="/env">Env</a></li><li><a href="/">Home</a></li></ul></nav><h1>default message here</h1>
  //
  // pre-rendered: dist/static/index.html
  // appHtml の出力例
  //  : <nav><ul><li><a href="/about">About</a></li><li><a href="/env">Env</a></li><li><a href="/">Home</a></li></ul></nav><h1>Home22</h1><div>9</div><div>5</div><div class="circ-dep-init">circ-dep-init-a circ-dep-init-b</div>
  for (const url of routesToPrerender) {
    const context = {}
    const appHtml = await render(url, context)

    // server.js 同様、テンプレート内容の置き換え
    const html = template.replace(`<!--app-html-->`, appHtml)

    const filePath = `dist/static${url === '/' ? '/index' : url}.html`
    fs.writeFileSync(toAbsolute(filePath), html)
    console.log('pre-rendered:', filePath)
  }
})()

SSG は npm run generate コマンド。上の通り、entry-client.jsx をエントリとしてビルドした結果と SSR ビルドした結果両方が必要なため以下の様なスクリプトになっているようです。

"generate": "vite build --outDir dist/static && npm run build:server && node prerender",

結果

serve で SSG の成果物(dist/static)を起動すると、SSR と同じ画面を表示することができました。また、useState や fetch も同様、問題なく動作できました。

まとめ

今回は SSR / SSG 実行時には データフェッチはやっていませんが、実際に SSR/SSG やる場合の多くのケースはデータフェッチが必要かと思います。vite-plugin-ssr には onBeforeRender()という仕組みがあり、これをつかうとデータフェッチが簡単にできそうです。