😵

Hono JSX やめ方

に公開

https://hono.dev/docs/helpers/html

HonoでシンプルにSSRをしたい場合、JSXを使わずにHTMLをそのままHTMLタグ付きテンプレートリテラルで書きたくなる時があります。
DenoだとHTMLタグ付きテンプレートリテラルの中もフォーマットしてくれますし、.tsファイル1つだけを公開してHTTPからスクリプト実行するケースも多いですからね。

そんな時は以下のように書くとよさそうです。

index.ts
import { Hono } from "jsr:@hono/hono@4.9.12";
import { html } from "jsr:@hono/hono@4.9.12/html";
import type { HtmlEscapedString } from "jsr:@hono/hono@4.9.12/utils/html";

async function userTable(): Promise<HtmlEscapedString> {
  const users = [
    { id: 1, name: "Alice", age: 28 },
    { id: 2, name: "Bob", age: 34 },
    { id: 3, name: "Charlie", age: 22 },
  ];

  return html`
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Name</th>
          <th>Age</th>
        </tr>
      </thead>
      <tbody>
        ${users.map((user) =>
          html`
            <tr>
              <td>${user.id}</td>
              <td>${user.name}</td>
              <td>${user.age}</td>
            </tr>
          `
        )}
      </tbody>
    </table>
  `;
}

async function noticeList(url: string): Promise<HtmlEscapedString> {
  const notices = [
    "Site maintenance scheduled for tomorrow.",
    "New features have been added to your account.",
    "Don't forget to check out the latest blog post!",
  ];

  return html`
    <p>URL: ${url}</p>
    <ul>
      ${notices.map((notice) =>
        html`
          <li>${notice}</li>
        `
      )}
    </ul>
  `;
}

async function layout(...contents: Promise<HtmlEscapedString>[]): Promise<HtmlEscapedString> {
  return html`
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Hono HTML</title>
        <meta name="description" content="Hono HTML | Deno">
        <meta name="color-scheme" content="light dark">
        <link href="https://cdnjs.cloudflare.com/ajax/libs/picocss/2.1.1/pico.min.css" rel="stylesheet">
      </head>
      <body>
        <main class="container">
          <h1>Hello, World!</h1>
          ${contents}
        </main>
      </body>
    </html>
  `;
}

const app = new Hono();

app.get("/", (c) => {
  const randomValue = Math.round(Math.random());
  let body: Promise<HtmlEscapedString>;

  if (randomValue === 0) {
    body = layout(userTable(), noticeList(c.req.url));
  } else {
    body = layout(noticeList(c.req.url), userTable());
  }

  return c.html(body);
});

Deno.serve(app.fetch);

後はWebサーバーを起動してlocalhostにアクセスするだけです。

$ deno -N index.ts
http://localhost:8000/

Webブラウザをリロードするたびに異なるレイアウトで描画されていることが分かります。

  1. layout(...contents: Promise<HtmlEscapedString>[])という関数を作っておくと、無限に引数へコンポーネントを追加できるボイラープレートになります。
  2. このような場合、${contents}をHTMLタグ付きテンプレートリテラルに追加するだけでコンポーネントを引数の順番で描画してくれるみたいです。
  3. Honoのルーティング内でlet body: Promise<HtmlEscapedString>;を宣言しておくと、条件分岐で異なるコンポーネントを使用でき、最後にreturn c.html(body);する見通しの良いコードになります。
  4. コンポーネントの引数やコンポーネント内の処理は自由に書くことができます。
  5. ただHTMLタグ付きテンプレートリテラルを入れ子にする場合はif文やfor文が使えないので、map()filter()のようなArrayやObjectで使えるメソッド、三項演算子のような式を使う必要があります。
  6. またDenoなどで型チェックを行う場合は、基本的に全て非同期のPromise<HtmlEscapedString>を型にする必要がありますので、関数にasyncをつけます。

HTML HelperのドキュメントでもJSXメインの紹介だったので、最近作ったSoraDBでうまくいった設計を記事にしてみました。

https://zenn.dev/tkithrta/articles/1eeb08c37b85c3

Discussion