🆕

Hono + htmx + Cloudflareは新しいスタック

2023/10/09に公開

この記事は以前7月に自分で書いた「Hono + htmx + Cloudflare is a new stack」という記事を一部修正し、訳したものです。

https://blog.yusu.ke/hono-htmx-cloudflare/

Hono + htmx + Cloudflareは新しいスタック

以前、バックエンドエンジニアだった身からすれば、Reactは複雑だと感じることがあります。さらに(私はフレームワーク開発者なのですが)フレームワーク開発者にとってはハイドレーションの仕組みを作ることは厄介です。しかし、しばしばReactを使うことになります。

Reactの優位な点の一つは「JSX」です。最初見た時、JSXは奇妙に思えました。「なんでJavaScriptの中にHTMLのタグが入っているんだ!」。しかし、一度慣れると、JSXは柔軟で、書きやすいことに気づきました。

今日はこれから、JSXをサーバーサイドのテンプレートとして使う技術スタックを紹介します。これはつまりReactなしでJSXを使うということです。

HonoのJSX

Hono - エッジのためのJavaScriptのWebフレームワーク - はJSXの機能を有しています。クライアントではなくサーバーサイドのHTMLをJSXで書くことができます。HandlebarsやEJS、mustacheのようなテンプレートエンジンのように振る舞います。

const app = new Hono()

app.get('/', (c) => {
  return c.html(<h1>Hello!</h1>)
})

HonoのアプリケーションはCloudflare WorkersやFastly Compute@Edge、Deno Deployなどのエッジサーバーで動きます。それは実に高速なサーバーサイドレンダリングをもたらします。さらに言えば、JavaScriptの「ハイドレーション」を行わず、SPA遷移なしで、ユーザー体験を損なわないのです。このエッジベースのSSRとハイドレートしない組み合わせはとても速いセットアップを可能にします。

htmx

htmxはJavaScriptを自分で書かずともAjaxを可能にするライブラリです。

<!-- have a button POST a click via AJAX -->
<button hx-post="/clicked" hx-swap="outerHTML">
  Click Me
</button>

Ruby on Railsで使われているHotwireと似ています。htmxはReactをREST APIとともに使うのと違い、サーバーサイドのJSXと簡単に統合でき、インタラクティブな体験をシンプルにつくることができます。

スタックの全容

スタックに含まれる全部のコンポーネントは以下です。

  • Hono + JSX
  • htmx
  • Zod
  • Tailwind CSS
  • Cloudflare Workers
  • Cloudflare D1

Cloudflare D1はSQLiteをCloudflareのエッジで動かすデータベースサービスです。現在は「ベータ」のステータスでプロダクションでの使用は推奨されていまんが、とても速く、PoCのためのプロジェクトには完璧に向いています。

これから紹介する例では、Zodを値の検証に使っています。HonoのZod ValidatorミドルウェアはHonoと統合されいてるためとても使いやすく、検証した値の型を簡単に取得することができます。

SS

100行のTODOアプリ

これはすごいです。エッジ上のD1データベースに実データを入れて削除するTODOリストのアプリケーションをたった100行(程度)で書くことできたのです!それはとても速く(100ms以内)とても小さいです(Gzippedで22KB)!

デモ

SC

バンドルサイズ

SS

コード

いつもサンプルのコードを紹介する際、特徴的な行を選んでペーストします。しかし、このサンプルコードはたった100行程度なので、これから全部のコードをお見せしましょう。

component.tsx
import { html } from 'hono/html'
import { jsxRenderer } from 'hono/jsx-renderer'

export const renderer = jsxRenderer(({ children }) => {
  return html`
    <!DOCTYPE html>
    <html>
      <head>
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <script src="https://unpkg.com/htmx.org@1.9.3"></script>
        <script src="https://unpkg.com/hyperscript.org@0.9.9"></script>
        <script src="https://cdn.tailwindcss.com"></script>
        <title>Hono + htmx</title>
      </head>
      <body>
        <div class="p-4">
          <h1 class="text-4xl font-bold mb-4"><a href="/">Todo</a></h1>
          ${children}
        </div>
      </body>
    </html>
  `
})

export const AddTodo = () => (
  <form hx-post="/todo" hx-target="#todo" hx-swap="beforebegin" _="on htmx:afterRequest reset() me" class="mb-4">
    <div class="mb-2">
      <input name="title" type="text" class="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg p-2.5" />
    </div>
    <button class="text-white bg-blue-700 hover:bg-blue-800 rounded-lg px-5 py-2 text-center" type="submit">
      Submit
    </button>
  </form>
)

export const Item = ({ title, id }: { title: string; id: string }) => (
  <p
    hx-delete={`/todo/${id}`}
    hx-swap="outerHTML"
    class="flex row items-center justify-between py-1 px-4 my-1 rounded-lg text-lg border bg-gray-100 text-gray-600 mb-2"
  >
    {title}
    <button class="font-medium">Delete</button>
  </p>
)
index.tsx
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

import { renderer, AddTodo, Item } from './components'

type Bindings = {
  DB: D1Database
}

type Todo = {
  title: string
  id: string
}

const app = new Hono<{ Bindings: Bindings }>()

app.get('*', renderer)

app.get('/', async (c) => {
  const { results } = await c.env.DB.prepare(`SELECT id, title FROM todo;`).all<Todo>()
  const todos = results
  return c.render(
    <div>
      <AddTodo />
      {todos.map((todo) => {
        return <Item title={todo.title} id={todo.id} />
      })}
      <div id="todo"></div>
    </div>
  )
})

app.post(
  '/todo',
  zValidator(
    'form',
    z.object({
      title: z.string().min(1)
    })
  ),
  async (c) => {
    const { title } = c.req.valid('form')
    const id = crypto.randomUUID()
    await c.env.DB.prepare(`INSERT INTO todo(id, title) VALUES(?, ?);`).bind(id, title).run()
    return c.html(<Item title={title} id={id} />)
  }
)

app.delete('/todo/:id', async (c) => {
  const id = c.req.param('id')
  await c.env.DB.prepare(`DELETE FROM todo WHERE id = ?;`).bind(id).run()
  c.status(200)
  return c.body(null)
})

export default app

エレガントでしょ?

全体のコードは以下にあります。

https://github.com/yusukebe/hono-htmx

PHPの話をしているの?

もしかして、あなたはこう思うかもしれません。

それってPHPの話?

そうすると私はこう答えるでしょう。

いいえ違います。でもとても似ています!

これは非常にPHPっぽいです、もしくはRuby on Rails。でも私はPHPは好きだし、このスタックには以下の良い点があります。

  • エッジで動く
  • JSXを使える
  • コードをうまく構成することでスパゲッティコードを避けれる

冒頭で述べた通り、私はバックエンドエンジニアだったので、このウェブサイトをつくるアプローチは私にとって馴染みがあり、快適です。シンプルでクリーンなのです。

今後

このスタックをより発展させるために必要なことがいくつかあります。ひとつはファイルベースのルーティングです。また、HonoのJSXを使うのがベストのアプローチではないかもしれません。PreactやReactをサーバーサイドで使う選択肢もあります。

どちらにせよ、このスタックはノスタルジックであり新しい心地もします。あ、ひとつ忘れてることがありました。このスタックの名前はどうしましょか!

Discussion