HonoXについて
2月9日、予告していた通りHono v4をリリースしました。
そのHono v4のリリースと同時に、Honoを使ったメタフレームワーク「HonoX」を公開しました。
今回はHonoXのいくつかの特徴について書いてみたいと思います。これは使い方というより作者目線の思想みたいなものです。
メタフレームワーク
HonoXとは一言で言うと「HonoとViteを組み合わせたメタフレームワーク」です。HonoX自体が機能を提供しないのが肝です。
もう少しだけ具体的に言います。HonoXで扱うのは「Honoのインスタンス」そのものです。つまりあなたがHonoXでアプリを作るということは「Honoのアプリを作る」ことになります。その証拠にエントリーポイントになるapp/server.ts
内で出てくるapp
はHonoのインスタンスなので、hono/dev
にあるヘルパー関数showRoutes()
がそのまま使えます。
import { showRoutes } from 'hono/dev'
import { createApp } from 'honox/server'
const app = createApp()
showRoutes(app)
export default app
Viteについて
これを透過的に実現するのがViteです。
「Viteとは?」となりますが、ここでは簡単に以下の3つを提供してくれる便利なツールだと考えてください。
- モジュールのロード
- 開発サーバー
- ビルド
HonoXではhonox/vite
からViteのプラグインを提供しています。それをvite.config.ts
でロードすることになります。最低限は以下です。
import honox from 'honox/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: honox(),
})
例えばAstroはViteを使っていますが、Astroの設定ファイルはastro.config.ts
でvite.config.ts
ではありません。まぁラップした感じですね。HonoXではvite.config.ts
なので、それと比べるとHonoXはViteをより「素」で使っていることになります。実装をより少なくしたかったのと、よりViteのエコシステムをわかりやすく使えるようにしたかったという意図があります。
ファイルベースルーティング
ファイルベースルーティングはファイルを置いた場所によってWebアプリのルーティングが決定する仕組みです。HonoXの場合、app/routes
以下に.ts
もしくは.tsx
ファイルを置きます。
-
app/routes/index.tsx
=>/
-
app/routes/about.tsx
=>/about
-
app/routes/posts/index.tsx
=>/posts/
-
app/routes/posts/[:id].tsx
=>/posts/:id
という具合です。
Honoベースのルート
ファイルベースルーティングといっても、中のルートファイルではHonoベースのシンタックス、オブジェクトが扱えるので、覚えることは少なくて済みます。
各ルートファイルは3種類のものを返すことができます。
1. (Handler|Middleware)[]
これが一番バランスの取れたAPIです。Honoのハンドラもしくはミドルウェアの配列をdefault export
で返せばそのレスポンスがそのままユーザーに返却されます。honox/factory
から提供されているヘルパーを使う方法を推奨しています。以下のように書けます。
import { createRoute } from 'honox/factory'
export default createRoute((c) => {
return c.render(<h1>Hello!</h1>)
})
READMEにも書いてある一般的な使い方です。ただ申した通り、ハンドラの配列でいいので、こうも書けてしまいます。
export default [
() => {
return new Response('Hello!')
},
]
これがHonoXの面白いところです。
まず、返却するのはResponse
オブジェクトを返せばそれがそのままレンダリングさせるということです。上記だとtext/plain
のHello
が表示されます。つまり、return c.render(<h1>Hello!</h1>)
としているのはただ、Response
オブジェクトを返しているだけなんです。ですので、c.render()
に限らずResponse
オブジェクトさせ返せばいいので、自分でカスタムのヘルパーを作れるかもしれないし、そもそもc.render()
が後述する通りカスタム可能です。このAPIがHonoXをシンプルでかつ柔軟にしています。
次に注目すべきは「配列」であることです。例えば、以下のようなコードを書けます。
export default [middleware1, middleware2, handler]
ヘルパーを使うとこうです。
export default createRoute(middleware1, middleware2, handler)
で、これはが内部的にはこう解釈されます。
app.get(middleware1, middleware2, handler)
つまりHonoでやってこと同じことです。ですので、例えばZod Validatorを挟もうと思えば、いつもどおりハンドラの前に書けばいいのです。
export default createRoute(
zValidator(
'query',
z.object({
q: z.string()
})
),
(c) => {
const { q } = c.req.valid('query')
return c.render(<p>Your query is {q}</p>)
}
)
このように、書き心地を限りなくHonoに近づけています。つまり、Honoを知っていればHonoXのアプリを書けるし、Hono自体も覚えることが少ない。三段論法でHonoXは使いやすいと思います。
app
2. 上記「1」のAPIがHonoと「似ている」と書きましたが、Honoの書き方と「全く同じ」書き方ができます。
import { Hono } from 'hono'
const app = new Hono()
app.get(() => {
return c.render(<h1>Hello!</h1>)
})
export default app
これをapp/routes/index.ts
やapp/routes/about.ts
に置けば動くのです。面白いですね。
「1」やこれから紹介する「3」に比べると冗長になってしまうので、HTMLを返したいだけならそちらを使うのですが、APIを作るのに便利です。URL的に/api
以下をAPIにして、同じアプリのフロントから叩く、でもいいし、HonoXを使って「APIだけ」のアプリを使ってもいいということです。
また、工夫次第でRPCモードも使えます。以下のように書けばAppType
をIslandsのクライントと共有させることができます。
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const app = new Hono()
const schema = z.object({
name: z.string()
})
const routes = app.get('/echo', zValidator('query', schema), (c) => {
const { name } = c.req.valid('query')
return c.json({ message: `Hello, ${name}! from API` })
})
export type AppType = typeof routes
export default app
「Hono」と全く同じシンタックスが使えるということは既存アプリからの乗り換えが簡単だし、その逆もまた然りとなります。
3. Function Component
これは他のフレームワークを触っていたら馴染みがあるでしょう。
export default function About() {
return <h1>I'm Hono!</h1>
}
JSXを返すだけです。内部的にはそれがそのままc.render()
の引数になります。
これも興味深いのは引数にContext
オブジェクトを受け取れることです。つまり、こう書けます。
import type { Context } from 'hono'
export default function About(c: Context) {
const name = c.req.query('name') ?? 'nanashi'
return <h1>I'm {name}!</h1>
}
HonoのContextオブジェクトはその名の通りハンドラに必要な全ての情報を持っているので、これさえできれば、だいたいできます。
また、変数をexportしておくと、あとでRendererで使えます。
export const title = 'about' // <=== これ
export default function About() {
return <h1>I'm Hono!</h1>
}
と書くと、
import { jsxRenderer } from 'hono/jsx-renderer'
export default jsxRenderer(({ children, title }) => {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{title}</title>
</head>
<body>{children}</body>
</html>
)
})
このtitle
にabout
という値が渡ります。例えば、ページのメタデータをexportするようにしたら便利でしょう。
Bring Your Own Renderer
先ほどからc.render()
というメソッドが出てきてます。HonoXではこのメソッドをよく使うことになるでしょう。それもそのはず、このc.render()
、実はHonoXのために僕がしれっと追加したものです。
とはいえ、c.renderの実装はほとんど何もないです。Honoの内部を見るとよくわかります。setRenderer()
で関数を登録でき、render()
で引数をそれに渡しているだけです。
render: Renderer = (...args) => this.renderer(...args)
// ...
setRenderer = (renderer: Renderer) => {
this.renderer = renderer
}
型定義だけで機能を明示していて、デフォルトはこのようになっています。
interface DefaultRenderer {
(content: string | Promise<string>): Response | Promise<Response>
}
HonoXではhono/jsx
をデフォルトで使うことを推奨しているので、この型定義がフィットします。
しかし、これはつまり、型さえ変えれば、hono/jsx
ではなくとも、例えば他のReactなどのUIライブラリも使えます。実際、@hono/react-renderer
という実装もあって、その中の型定義はこうなっています。
interface ContextRenderer {
(children: React.ReactElement, props?: Props): Response | Promise<Response>
}
この自分の好きなレンダラーを使えることを「Bring Your Own Renderer = BYOR」と呼んでいます。HonoXの実装がシンプルになって、かつ柔軟にしています。
Islandsアーキテクチャ
部分的にインタラクションを足すために、Islandsアーキテクチャを採用しています。これは同じくIslandsアーキテクチャを用いているDeno用のフレームワークFreshを参考にしました。
わかりやすい仕組みです。/app/islands
以下に置いたコンポーネントは、それをimportすることで、クライアントにも配信され、インタラクティブになります。hono/jsx
はClinet Componentsに対応したので、デフォルトでこのようにカウンターが書けます。
import { useState } from 'hono/jsx'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
それを、ルートファイル内でimportします。
import { createRoute } from 'honox/factory'
import Counter from '../islands/counter'
export default createRoute((c) => {
return c.render(
<div>
<h1>Hello, {name}!</h1>
<Counter />
</div>
)
})
すると、サーバーサイドはインタラクションなしのUIをレンダリングします。クライントが読み込まれるとインタラクションが追加されるという仕組みです。
Next.jsのように言えば、通常はServer Componentとして動作し、app/islands
以下に置いてルートファイルでimportされるとClient Componentsとみなされるということです。
サーバー側からIslandsコンポーネントへ値やコンポーネントの受け渡しもできます。非同期コンポーネントとSuspense
に対応するので、例えば、サーバーで作った「2秒待ってDone!と表示する」コンポーネントをIslandのカウンターコンポーネントに渡すこともできます。
import { Suspense } from 'hono/jsx'
import { createRoute } from 'honox/factory'
import Counter from '../islands/counter'
const AsyncComponent = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000))
return <p>Done!</p>
}
export default createRoute((c) => {
return c.render(
<Counter>
<Suspense fallback={<p>Loading...</p>}>
<AsyncComponent />
</Suspense>
</Counter>
)
})
Suspense
が動きつつ、カウンターも動作します。
このIslandsアーキテクチャの機能はユーザーがオプトインするものになっていて、デフォルトでは発動しません。つまり、JavaScriptを全く必要としない。また、もし、Islandsのコンポーネントを追加したとしても、その部分だけのJavaScriptが配信されるので非常に効率的です。
Viteのエコシステム
特にラップもせずにViteを素で使っているので、Viteのエコシステムを扱えます。
@hono/vite-*
GitHubのhonojs orgにはhonojs/vite-plugins
というオリジナルのViteのPluginのためのリポジトリがあります。
実はこのリポジトリもHonoXを想定して作ったものです。
ここにはHonoの開発に使えるViteのPluginがいくつかあります。
@hono/vite-dev-server
@hono/vite-cloudflare-pages
@hono/vite-ssg
@hono/vite-dev-server
はhonox
パッケージに同封され、なくてはならない開発サーバです。そして他の2つも使うことができます。
@hono/vite-cloudflare-pages
@hono/vite-cloudflare-pages
はCloudflare Pagesへデプロイする時に使います。いわゆるSSRをするためのものです。
import honox from 'honox/vite'
import pages from '@hono/vite-cloudflare-pages'
import { defineConfig } from 'vite'
const entry = './app/server.ts'
export default defineConfig(() => {
return {
plugins: [honox(), pages({ entry })]
}
})
こんな感じで設定を書いて
vite build
をするとdist/_worker.js
が作られます。Cloudflare Pagesだと_workers.js
がエントリポイントになるので、これをめがけて
wrangler pages deploy dist
とやればCloudflare Pagesへデプロイができます。
@hono/vite-ssg
v4で導入されたSSGヘルパーを使ったVite Pluginです。これを使うと上記プラグインと同じようにvite build
コマンドでビルド、この場合は静的ファイルの書き出しができます。
import honox from 'honox/vite'
import ssg from '@hono/vite-ssg'
import { defineConfig } from 'vite'
const entry = './app/server.ts'
export default defineConfig(() => {
return {
plugins: [honox(), ssg({ entry })]
}
})
これもdist
以下にファイルを生成するので、上記と同じコマンドでCloudflare Pagesへデプロイできます。
wrangler pages deploy dist
MDX
HonoXでは読み込むファイルの対象に.mdx
拡張子のついたファイルも含めています。適切にViteの設定をしてあげることで、Markdown拡張であるMDXを動かすことができます。
import mdx from '@mdx-js/rollup'
import honox from 'honox/vite'
import remarkFrontmatter from 'remark-frontmatter'
import remarkMdxFrontmatter from 'remark-mdx-frontmatter'
import { defineConfig } from 'vite'
const entry = './app/server.ts'
export default defineConfig(() => {
return {
plugins: [
honox(),
mdx({
jsxImportSource: 'hono/jsx',
remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
}),
],
}
})
これがなかなか強力で、フロントマターにも簡単に対応します。
---
title: Hello World
---
## Hello World
Today is a good day!
と書けば、frontmatter
という名前のレコードがRendererに渡って使うことができます。
import { jsxRenderer } from 'hono/jsx-renderer'
export default jsxRenderer(({ children, frontmatter }) => {
return (
<html lang="en">
<head>{<title>{frontmatter.title}</title>}</head>
<body>{children}</body>
</html>
)
})
他にも、設定次第でTailwind CSSを導入できたりします。
まとめ
HonoXについて書いてみました。結果、具体的な使い方ではなく「HonoXの考え方」みたいになりました。といっても難しいことはなくなかなかよく考えられたAPIになっていて使いやすいと思います。
まだ未熟な点あると思うので、しばらくは暖かく見守ってもらえると嬉しいです!
Discussion