Tags English

Blog - Yusuke Wada

Screenshot

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

#Hono

この記事は英語で書かれた原文をAIが翻訳したものです。

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

元バックエンドエンジニアとして、Reactを複雑だと感じることがあります。さらに、フレームワーク開発者としては、hydrationの仕組みを作るのは骨が折れます。 それでも、結局Reactを使ってしまうことが多いのです。

Reactを使う大きな利点のひとつがJSXです。最初はJSXは奇妙に見えます。「なんでJavaScriptの中にHTMLタグが!!!」 しかし慣れてみると、JSXは柔軟で快適に使えることが分かります。

今日は、JSXを純粋にサーバーサイドのテンプレートエンジンとして使うことをメインに据えた技術スタックを紹介します。 つまり、ReactなしでJSXを使うということです。

Hono JSX Middleware

Hono - エッジ向けのJavaScriptフレームワーク - にはJSXミドルウェアが含まれています。 JSXでHTMLを書けますが、これは厳密にサーバーサイドレンダリング用であり、クライアント向けではありません。 つまり、Handlebars、EJS、mustacheなどと同じような、テンプレートエンジンとして機能します。

const app = new Hono()

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

HonoアプリはCloudflare WorkersFastly Compute@EdgeDeno Deployといったエッジサーバーで動きます。 これにより、信じられないほど高速なサーバーサイドレンダリングが可能になります。 さらに、JavaScriptの「hydration」を行わないので、SPA遷移がなくてもユーザー体験を損ないません。 エッジでのSSRとhydrationなしという組み合わせは、非常に高速な構成になります。

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に相当するものです。 ただ、ReactとREST APIを使う場合と違い、htmxはサーバーサイドJSXと簡単に統合できるので、よりシンプルにインタラクティブな体験を作れます。

The stack

スタック全体は以下のコンポーネントで構成されます。

Cloudflare D1は、CloudflareのエッジでSQLiteを動かすデータベースサービスです。現在は「alpha」ステータスでプロダクション利用は推奨されていませんが、すでに高速で、Proof of Concept (PoC)プロジェクトには十分使えます。

以下の例では、入力値のバリデーションにZodを使っています。HonoのZod Validator MiddlewareはHonoと統合されていて、バリデーション済みの値の型を簡単に取得できるので、とても便利です。

Screenshot

html.js

感謝を伝えなければなりません。 このアイデアは@dctannerさんのツイートがベースになっています。 彼はこれを「html.js」スタックと名付けました。このリポジトリで見ることができます。

https://github.com/dctanner/htmljs-todo-example

100行のTodoアプリ

驚くべきことです。 エッジ上のD1 SQLiteにデータを挿入・削除する本物のTodoアプリのサンプルを、たった100行のコードで作ることができました。 高速(~100ms)で軽量(gzip後のworkerサイズ: 22 KB)です!

デモはこちら。

output

ビルドサイズ。

Screenshot

コード

普段、サンプルコードを見せるときは、 コードの特定の部分を選んで数行だけ貼り付けなければなりません。 しかし今回のサンプルはたった100行なので、コード全体をお見せします。

comonent.tsx:

import { html } from 'hono/html'

export const Layout = (props: { children: any }) => 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>
        ${props.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/quick'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

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

type Bindings = {
  DB: D1Database
}

type Todo = {
  title: string
  id: string
}

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

app.get('/', async (c) => {
  const { results } = await c.env.DB.prepare(`SELECT id, title FROM todo;`).all<Todo>()
  const todos = results as unknown as Todo[] // Currently, should fix a type mismatch.
  return c.html(
    <Layout>
      <AddTodo />
      {todos.map((todo) => {
        return <Item title={todo.title} id={todo.id} />
      })}
      <div id="todo"></div>
    </Layout>
  )
})

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は悪くないと思っています。しかも、このスタックには私にとっていくつかの利点があります。

冒頭で述べたように、私はもともとバックエンドエンジニアだったので、こういうWebサイトの作り方のほうが馴染みがあって快適です。 シンプルでクリーンです。

これから

このスタックを安定させるために取り組むべきことがいくつかあります。ひとつはファイルベースルーティングを可能にすること。 また、HonoのJSXミドルウェアを使うのがベストなのかも分かりません。Preactのほうが良い選択かもしれません。

いずれにせよ、このスタックには懐かしくも新しい感覚があります。ああ、やるべきことをひとつ忘れていました。このスタックに名前を付けなければ!

Thanks

改めて、刺激的なアイデアをくれた@dctannerさんに感謝します。 彼のリポジトリもぜひチェックしてみてください。

https://github.com/dctanner/htmljs-todo-example