📛

Honoで見直すMPAの開発者体験

2023/12/14に公開

Hono Advent Calendar 2023 14日目の記事です。

https://qiita.com/advent-calendar/2023/hono

前置き

Next.js App Routerのリリース以降、 async function で書けるServer Componentsいいじゃんファイルベースルーティングも使いやすいなと触ってたのですが、まだ安定していないこともあり不可解なエラーメッセージや複雑なキャッシュの仕組み、デプロイ先を選ぶ感じなどこのままNext.jsにベットしてていいのかなと感じていました。
そんな折にHonoでシンプルなフォームを持つWebアプリを書く機会があり、非常に優れた開発者体験に驚きました。5年以上前にMPAを開発した経験もありますがそこから比べてもとても良くなっていて、Honoの良さを感じるとともにMPAというアプローチを見直すきっかけになりました。
そもそも現在Webフロントエンドを構築する際に当たり前のように選択肢に上がるSPAですが、必ずしもSPAが適していない場合でもSPAが選択されることが少なくないのではないでしょうか。それはSPAを中心にJavaScriptのエコシステムが発展してきたために開発効率などの面で妥当な判断であることも多かったのではないかと思います。
最近ではAstroやFreshといったMPAに加えてIsland Architectureといったアイディアを持つフレームワークの登場により、MPAが合っている場合に開発者体験や開発効率で妥協することなくMPAを作れるようになってきています。
HonoはFreshなどに比べるとMPAに特化しているわけではないので本格的でインタラクティブなWebアプリを作るならそちらが向いていると思いますが、シンプルなHTTPサーバーを書く感覚が得られるという意味では一番かなと思います。

サンプルアプリ

フォームがありバリデーションに失敗するとエラーメッセージが表示され、サブミットに成功すると成功画面が表示されるシンプルなWebアプリを例にします。


初期状態


バリデーションエラー


完了画面

https://github.com/adwd/hono-form-example

https://hono-form-example.pages.dev/

このサンプルアプリを例に、Honoで書いてみて良いと思った点やApp Routerを書いている場合との差異を紹介します。

HonoでMPAを書く際に開発者体験がいいところ

HonoでMPAを書いていて開発体験がいいなと感じた点を分類すると以下の3つです。

  • 従来のMPAフレームワークに比べてJSXとTypeScriptがテンプレートエンジンとして使いやすい
  • Next.jsに比べてリクエストを受けてレスポンスを返すだけというシンプルなメンタルモデルでいられる
  • 充実したJavaScriptエコシステムの恩恵を受けられる

JSX、TypeScriptがテンプレートエンジンとして使いやすい

app.get('/', async (c) => {
  const message = await fetchMessage();

  return c.render(
    <main>
      <form method="POST">
        <Header message={message} />
        <Input label="ユーザー名" type="text" name="name" required />
        <Input label="メールアドレス" type="email" name="email" required />

        <div class="divider"></div>
        <button type="submit" class="btn btn-success w-full">
          登録する
        </button>
      </form>
    </main>,
  );
});

Honoは基本的にはExpressライクなAPIです。HTMLをレスポンスとして返したい場合は、上記のコードで c となっている Context を使って c.htmlc.render の中にJSXを書くことができます。TypeScriptを使ってコードを書くことでJSXの中に型チェックを効かせることができ、書き始めてすぐにその点が開発体験が良いと感じました。テンプレートエンジンを使ったMPA開発を数年前にやった際はテンプレートを別ファイルに書き型チェックも効かないなどあまりいい感じはしなかったのですが、JSXとTypeScriptはテンプレートエンジンというくくりではかなりいいものなのではないかと思います。

コンポーネントも使える

import { FC } from 'hono/jsx';

const Input: FC<
  { label: string; errors?: string[] } & Hono.InputHTMLAttributes
> = (props) => {
  const { label, errors, ...inputProps } = props;
  const { name, required } = inputProps;

  return (
    <div class="form-control">
      <Label htmlFor={name} label={label} required={required} />
      <input
        {...inputProps}
        id={name}
        class={`input input-bordered${errors ? ' input-error' : ''}`}
      />
      <ErrorMessage errors={errors} />
    </div>
  );
};

Reactライクなコンポーネントを定義できるので個人的にテンプレートエンジンで不満だった部品の再利用がやりやすいのも良いと感じました。

HTTPサーバーなので非同期処理が当然でき、Requestオブジェクトが使える

App RouterのServer Componentsで async function でコンポーネントを書けてすごく良いなと思ったのですが、HonoだとHTTPサーバーなので当然のように async が使えます。 c.render と組み合わせてHTMLを返すとまるでServer Componentのようです。また c.req.raw でWeb標準の Request オブジェクト、 c.req でHonoがラップした HonoRequest オブジェクトが使えます。HeaderやCookieを読み書きしたり認証の処理を挟んでリダイレクトするみたいなのもごくシンプルに書けます。

app.get('/', async (c) => {
  const message = await fetchMessage();

  c.req; // HonoRequest
  c.req.raw; // Request
  
  return c.render(
    <main>
      ...
    </main>,
  );
});

HTTPのリクエストを受けてレスポンスを返すというシンプルなメンタルモデルでいられる

HonoでMPAを作る場合、書くコードは単純にHTTP Requestを受けてResponseを返す処理でしかなく、Next.js文脈でルートが遷移したときに何が起こるかを気にするのに比べて非常にシンプルなメンタルモデルでいられると感じました。この点がSPAばかりやってた自分にはいい驚きになりました。

zodなどのバリデーションライブラリを使える

HonoというよりJSでサーバーを書く場合のメリットですが、zodやvalibotでバリデーションスキーマを定義してそれをサーバー・クライアントの両方で使えるのは非常に便利です。今回の例ではvalibotを使い、サーバーでバリデーションしています。

const FormSchema = object({
  name: string([
    minLength(1, '名前を入力してください'),
    custom((input) => input.toLowerCase() !== 'john', 'Johnは使えません'),
    custom((input) => input.toLowerCase() !== 'bob', 'Bobは使えません'),
  ]),
  email: string([
    email(),
    custom(
      (input) => !input.includes('example.com'),
      'example.comは使えません',
    ),
  ]),
});

実装は適当すぎですが、バリデーションを小さいパーツのパイプラインとして書ける感じは伝わると思います。こうして書いたバリデータから型を得られることも大きなメリットで、それをJSX(TSX)側で使うと型チェックを有効に使えて非常に便利です。

type FormValue = Input<typeof FormSchema>;
/*
type FormValue = {
    name: string;
    email: string;
}
*/

フォームのPOSTに対してHTMLを返せる

サンプルアプリでは / へのPOSTリクエストに対して、ボディの FormData をパースし前述のvalibotを使ったバリデータを適用しエラーがなければ成功画面を表示し、エラーがあるならエラーメッセージ付きのフォームを表示します。

import { parseForm } from './form-schema';

app.post('/', async (c) => {
  const body = await c.req.parseBody();
  const result = parseForm(body);

  if (result.ok) {
    // 成功画面
    return c.render(
      <main>
        ...
        <h1 class="text-2xl font-bold pb-2">ユーザー登録が完了しました</h1>
      </main>,
    );
  }

  // バリデーションエラーメッセージ付きのフォームを表示する
  const { form, errors } = result;
  return c.render(
    <main>
      <form method="POST">
        <p class="text-error">入力項目にエラーがあります。</p>
        <Input
          label="ユーザー名"
          type="text"
          name="name"
          value={form.name as string}
          required
          errors={errors.name}
        />
        ...
      </form>
    </main>,
  );
});

SPA脳だとフォームのPOSTで叩くのは /api/user みたいなJSONが返ってくるエンドポイントで、 preventDefault してあれこれするのに慣れていたのでこのWeb標準的には普通であるやり方が逆に新鮮でした。このサンプルアプリでは表現できていないのですがPOSTのレスポンスもページであることでできることもあると思います。

おわりに

いくつかの観点からHonoでMPAを構築して気付いた良い点を紹介しました。
クライアントサイドから始まったReactがSSRできるようになり、Next.jsが誕生してサーバーまで広がってきたのと反対のアプローチとして、MPAをベースとしてインタラクションが必要な箇所にIslandを作るといったやり方も選択肢の一つとしてあるかなと感じました。
Next.jsに関してはこの記事ではいい点をあまり書いていませんが非常に高性能なWebフロントエンドを作るための高度な機能がたくさん入ったもので、その用途ではまさに最先端を行くものだと思います。ですがそんなパフォーマンスギリギリまでこだわる必要がないケースが多くあることも事実で、そういった場合によりシンプルに開発する選択肢があってもいい気がします。現状私がNext.jsに持つ不満ってApp Routerが安定していないことに起因している面が多いので1〜2年経って安定しエコシステムもついてきたら案外App Router最高と言っているかもしれません😇
Hono開発者のyusukebeさんがXでReact rendererを作っている様子をポストされていたり、Sonikというフレームワークを試作されてたりと今後よりHonoでできることが増えていきそうです。

https://twitter.com/yusukebe/status/1733642343033835918

https://github.com/sonikjs/sonik

Discussion