👻

HonoでAPI付き雑React SPA最小

2024/02/28に公開3

laisoさんが書いてたのほぼなんだけど

https://zenn.dev/laiso/articles/c7eba95ce43feb

Honoは文字列でもStreamでもなんでも返せるからサーバーサイドもReactで書けるし、tsconfig.jsonで適切に設定すればJSXなんでもいけるし、Viteのdev-serverがあるから、サーバーもクライントも同時に開発、ビルドできて、もちろんAPIを生やすのが得意で、雑React SPA環境(API付き!)作るのに向いてるよ。

作り方解説します。めんどい人はここにプロジェクト作ってるからclone、ダウンロードしてください。

https://github.com/yusukebe/hono-spa-react

まず、create-honoして、cloudflare-pagesのテンプレートを選ぶ。bunをパッケージマネージャーに使ってる。

bun create hono my-app

React関連の依存を入れる。

bun add react react-dom
bun add -D @types/react @types/react-dom

適当にReact用にファイルを書き換える。まずはTSの設定。ポイントはjsxImportSourcehono/jsxだったのをreactにしてる件。これでプロジェクトで書いたJSXはReactになりまーす。

tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "lib": [
      "ESNext",
      "DOM",
      "DOM.Iterable"
    ],
    "types": [
      "@cloudflare/workers-types",
      "vite/client"
    ],
    "jsx": "react-jsx",
    "jsxImportSource": "react"
  },
}

次にvite.config.ts。おまじないだと思って、ssr.externalでReactのライブラリを指定しましょう。

vite.config.ts
import pages from '@hono/vite-cloudflare-pages'
import devServer from '@hono/vite-dev-server'
import { defineConfig } from 'vite'

export default defineConfig(({ mode }) => {
  if (mode === 'client') {
    return {
      build: {
        rollupOptions: {
          input: './src/client.tsx',
          output: {
            entryFileNames: 'static/client.js'
          }
        }
      }
    }
  } else {
    return {
      ssr: {
        external: ['react', 'react-dom']
      },
      plugins: [
        pages(),
        devServer({
          entry: 'src/index.tsx'
        })
      ]
    }
  }
})

これで設定は終わり。laisoさんが書いてたコードがそのまま動きます。ちょっとコピペさせてもらうと(問題あったら教えて下さい)...

クライントはそのまま。

src/client.tsx
import { createRoot } from 'react-dom/client'
import { useState } from 'react'

function App() {
  return (
    <>
      <h1>Hello, Hono with React!</h1>
      <h2>Example of useState()</h2>
      <Counter />
      <h2>Example of API fetch()</h2>
      <ClockButton />
    </>
  )
}

function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>You clicked me {count} times</button>
}

const ClockButton = () => {
  const [response, setResponse] = useState<string | null>(null)

  const handleClick = async () => {
    const response = await fetch('/api/clock')
    const data = await response.json()
    const headers = Array.from(response.headers.entries()).reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
    const fullResponse = {
      url: response.url,
      status: response.status,
      headers,
      body: data
    }
    setResponse(JSON.stringify(fullResponse, null, 2))
  }

  return (
    <div>
      <button onClick={handleClick}>Get Server Time</button>
      {response && <pre>{response}</pre>}
    </div>
  )
}

const domNode = document.getElementById('root')!
const root = createRoot(domNode)
root.render(<App />)

ふつーのReact。サーバー側は、renderToString()を使うのがポイント。

src/index.tsx
import { Hono } from 'hono'
import { renderToString } from 'react-dom/server'

const app = new Hono()

app.get('/api/clock', (c) => {
  return c.json({
    time: new Date().toLocaleTimeString()
  })
})

app.get('*', (c) => {
  return c.html(
    renderToString(
      <html>
        <head>
          <meta charSet="utf-8" />
          <meta content="width=device-width, initial-scale=1" name="viewport" />
          <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css" />
          {import.meta.env.PROD ? (
              <script type="module" src="/static/client.js"></script>
            </>
          ) : (
              <script type="module" src="/src/client.tsx"></script>
            </>
          )}
        </head>
        <body>
          <div id="root"></div>
        </body>
      </html>
    )
  )
})

export default app

あとはbun run devつまり、

vite dev

で開発できます。

ビルドしたい時はクライアントを先にしないと、dist/_worker.jsが消えるので、ちょっとコツがいるんですが、これでいいです。

vite build --mode client && vite build

あとはCloudflare PagesへのDeployも実際のコマンドはこれでいける。

wrangler pages deploy dist

KOREDAKE!

ファイル構造もめっちゃシンプル。

.
├── bun.lockb
├── package.json
├── public
│   └── static
│       └── style.css
├── src
│   ├── client.tsx
│   └── index.tsx
├── tsconfig.json
└── vite.config.ts

クライアントのルーターのことは何も書いてないんだけどそれはよしなに。

以上、API付きの雑React SPA環境でした。

Discussion

tmtk75tmtk75

Cloudflareについて勉強中なのですが、deploy後の/api/clockが出力したログを見ることはできるでしょうか?Workerであればコンソールやwrangler tailで見れることは分かったのですが、この記事の形式だとどうなるのかと思い。

tmtk75tmtk75

とりあえず下記で見たいものは見られました。

wrangler pages deployment tail
nittanitta

index.tsxのフラグメントが閉じていない箇所があるみたいです。

{import.meta.env.PROD ? (
+   <>
        <script type="module" src="/static/client.js"></script>
    </>
) : (
+   <>
        <script type="module" src="/src/client.tsx"></script>
    </>
)}