HonoXでReactベースのUIライブラリYamadaUIを使用する

2024-5-5

目次

目次

  1. はじめに
  2. HonoXとは
    1. セットアップ
  3. ディレクトリの構造と役割
  4. レンダリングの仕組み
  5. React化する
    1. レンダラーを変更する
    2. ライブラリを使用する
  6. bunでCloudflare Pagesにデプロイする
  7. おまけ - モノリポにおける依存関係との仁義なき戦い
  8. 参考

はじめに

HonoのフルスタックメタフレームワークであるHonoXreact-rendererミドルウェアを適用して、ReactベースのUIフレームワークであるYamadaUIReact Flowを使用してポートフォリオサイトを作成しました。

本記事では、作成したポートフォリオサイトをCloudflare Pagesにデプロイするまでの方法をまとめています。

skr-me.com
skr-me.com favicon https://skr-me.com/

(リポジトリ)

HonoXとは

HonoXは、HonoとViteを組み合わせてできたHonoのメタフレームワークです。ファイルベースルーティングやIslandアーキテクチャによるClient ComponentとServer Componentの棲み分け、SSRを実現し、Next.jsのようなフルスタックWebフレームワークの書き心地を実現したHonoアプリを作成できます。

詳細は、以下のyusukebeさんの記事を参照ください。

zenn.dev
zenn.dev favicon https://zenn.dev/yusukebe/articles/724940fa3f2450

セットアップ

Starter templateに倣って、以下のコマンドを実行し、x-basicを選択します。(※今回はパッケージマネジャにbunを使用しています)

bun create hono@latest

ディレクトリの構造と役割

実行完了すると、以下のようなディレクトリ構成でHonoXアプリが作成されます。

.
├── app
│   ├── global.d.ts // global type definitions
│   ├── routes
│   │   ├── _404.tsx // not found page
│   │   ├── _error.tsx // error page
│   │   ├── _renderer.tsx // renderer definition
│   │   ├── about
│   │   │   └── [name].tsx // matches `/about/:name`
│   │   └── index.tsx // matches `/`
│   └── server.ts // server entry file
├── package.json
├── tsconfig.json
└── vite.config.ts
github.com
github.com favicon https://github.com/honojs/honox?tab=readme-ov-file#project-structure

上記の構成とコメントからわかるように、ファイルベースのルーティングやError Boundaryの設置ができることがわかります。

このディレクトリでbun install->bun run devを実行し、http://localhost:5173 にアクセスすると以下のように初期画面が表示されます。 HonoXアプリの初期画面 HonoXアプリの初期画面

レンダリングの仕組み

レンダーは_renderer.tsxで設定されたレンダラーによって行なわれます。デフォルトの場合だと、hono/jsx-rendererのレンダラーであることがわかります。

./app/routes/_renderer.tsx
import { jsxRenderer } from 'hono/jsx-renderer'
 
export default jsxRenderer(({ children, title }) => {
  return (
    <html lang='en'>
      <head>
        <meta charset='UTF-8' />
        <meta name='viewport' content='width=device-width, initial-scale=1.0' />
        {title ? <title>{title}</title> : <></>}
      </head>
      <body>{children}</body>
    </html>
  )
})

レンダラーの引き数や戻り値はglobal.d.tsで定義されています。

./app/global.d.ts
import type {} from 'hono'
 
type Head = {
  title?: string
}
 
declare module 'hono' {
  interface ContextRenderer {
    (content: string | Promise<string>, head?: Head): Response | Promise<Response>
  }
}

このように、デフォルトの設定ではhono/jsx-rendererのレンダラーが使用されており、アプリケーション全体を通してhono/jsxコンポーネントが使用されています。 例えば、hono/jsxからインポートされたuseStateを使用して、以下のように[route]/app/islands/counter.tsxを実装できます。

./app/islands/counter.tsx
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>
  )
}

しかし、今回はReactベースのUIライブラリを使用するため、hono/jsxをレンダーするhono/jsx-rendererは使用できません。ReactベースのUIライブラリを使用するためには、reactのJSX(ReactNode)をレンダーするためのreact-dom/clientが必要です。

そこで、次のステップでは、ReactNodeのレンダラーを提供するreact-dom/clientにレンダラーを変更し、使用するJSXをreactから提供されているReactNodeにします🏃🏻‍♀️

React化する

レンダラーを変更する

HonoXの面白い特徴として、レンダラーとしてHono純正のhono/jsx-renderer以外の任意のレンダラーを使用できます。

今回は、HonoXでReactのレンダラーを用いることでReactベースのUIライブラリであるYamadaUIやReact Flowを使用可能にしていきます。

まず、HonoXでReactNodeをレンダリングするために必要なモジュールをインストールします。

bun add @hono/react-renderer react react-dom hono --exact
bun add -D @types/react @types/react-dom --exact

./app/client.tsではクライアントでコンポーネントをレンダリングするためのレンダラーの生成を行なっています。

引数を渡さない場合のデフォルトcreateClientだと、レンダラーはhono/jsx-renderer、コンポーネントはhono/jsxとして生成されます。

今回は、レンダラーをreact-dom/client、コンポーネントをreactコンポーネントとして生成するように設定します。

./app/client.ts
import { createClient } from "honox/client";
 
createClient({
  hydrate: async (elem, root) => {
    const { hydrateRoot } = await import("react-dom/client");
    hydrateRoot(root, elem);
  },
  createElement: async (type: any, props: any) => {
    const { createElement } = await import("react");
    return createElement(type, props);
  },
});

このように設定すると以下のような型エラーが出ると思いますが、こちらはKnown Issueとして確認されており、後続のリリースで修正されると思われますので、現時点では黙認しておきます。 Known Type Error in the use of react-renderer Known Type Error in the use of react-renderer

github.com
github.com favicon https://github.com/honojs/honox/issues/87

次に、@hono/react-rendererのレンダラーが受け取るpropsを定義します。今回はページごとにtitledescriptionをheadに設定したかったので以下のように定義しました。

./app/global.d.ts
import "@hono/react-renderer";
 
type Head = {
  title?: string;
  description?: string;
};
 
declare module "@hono/react-renderer" {
  interface Props {
    head?: Head;
  }
}

最後に、Reactレンダラーを適用して完成です。

./app/routes/_renderer.tsx
import { reactRenderer } from '@hono/react-renderer'
 
export default reactRenderer(({ children, head }) => {
  return (
    <html lang='en'>
      <head>
        <meta charSet='UTF-8' />
        <meta name='viewport' content='width=device-width, initial-scale=1.0' />
        {import.meta.env.PROD ? (
          <script type='module' src='/static/client.js'></script>
        ) : (
          <script type='module' src='/app/client.ts'></script>
        )}
        {head.title ? <title>{head.title}</title> : ''}
        {head.title ? <meta name="description" content={`${head.description}`} /> : ''}
      </head>
      <body>{children}</body>
    </html>
  )
})

各rootではglobal.d.tsで定義したpropsを渡して、以下のようにレンダリングを構成できます。

./app/routes/index.tsx
import { createRoute } from "honox/factory";
import FlowArea from "@/islands/portal/flowarea";
 
export default createRoute((c) => {
  return c.render(<FlowArea />, {
    head: { // 該当ページのheadをpropsとして渡している
      title: "saku's Portfolio - Home", 
      description: "saku's Portfolio",
    },
  });
});

ライブラリを使用する

viteはデフォルトではすべての依存関係を外部化、つまり、開発中にバンドルは行なわずプログラムの配布前にのみ行なうということをします。これにより、ビルドの高速化しています。

しかし、ViteのSSRではリンクされた依存関係はHMRをするために外部化しない、つまりSSRではリンクされたパッケージをバンドルに含めてビルドします。(Vite - 外部 SSR)

しかし、外部化したい、つまりリンクされたパッケージでもバンドルに含めずビルドしたい場合にはssr.externalsに入れる必要があります。

具体的にssr.externalsに含めるパッケージとしては以下のようなものが考えられるでしょう。

1. ブラウザ環境でのみ使用されるパッケージ

クライアントサイドレンダリング用のライブラリは、サーバー側レンダリング時には不要だからです。 例えば、YamadaUIの場合は以下のようなエラーが出ます。

...
11:17:53 AM [vite] Error when evaluating SSR module /@fs/Users/s002996/Develop/saku-apps/node_modules/@yamada-ui/core/dist/index.mjs: failed to import "/@fs/Users/s002996/Develop/saku-apps/node_modules/react-fast-compare/index.js"
|- ReferenceError: module is not defined
    at eval (/Users/s002996/Develop/saku-apps/node_modules/react-fast-compare/index.js:125:1)
    at instantiateModule (file:///Users/s002996/Develop/saku-apps/node_modules/vite/dist/node/chunks/dep-DkOS1hkm.js:55036:15)
 
...

2. Node.js依存のパッケージ

Node.js依存のコードには、Node.js固有のAPI、グローバル変数、モジュールシステムなどが使われており、これらはブラウザ環境では存在しません。そのため、ViteがNode.js依存コードをそのままトランスパイルしようとするとエラーになります。

そのため、Node.js依存のパッケージは ssr.external に含める必要があるでしょう。 これにより、Viteはそのパッケージをバンドル化せずに、Node.js実行環境から直接読み込むようになります。

ssr.external に含めないと、逆にViteはそのパッケージをバンドル化しようとしてエラーとなってしまいます。

ja.vitejs.dev
ja.vitejs.dev favicon https://ja.vitejs.dev/config/ssr-options#ssr-オプション

従って、vite.config.tsssr.externalを以下のように追加して、YamadaUIとReact Flowをクライアントサイドで使用できるようにします。

export default defineConfig(({ mode }) => {
    ...
      return {
      ssr: {
        external: [
          "react",
          "react-dom",
          "@yamada-ui/react",
          "@yamada-ui/core",
          "reactflow",
        ],
      },
      ...
    };
});

これで、HonoXでReactやReactに依存しているUIコンポーネントを使用できるようになりました🎉

YamadaUIやReact Flowは、各ライブラリの公式ドキュメントに従うことで使用できます。

bunでCloudflare Pagesにデプロイする

Cloudflare Pagesのプロジェクトを作成し、GitHubと統合します。これにより、mainブランチに変更があった際、Cloudflare Pagesへ自動デプロイされるようになります。

しかし、初期設定のまま、パッケージマネジャとしてbunを使用してCloudflare Pagesで依存関係をインストールすると以下の状態から進行しませんでした。

10:44:26.853	Installing project dependencies: bun install --frozen-lockfile
10:44:27.101	bun install v1.0.1 (32abb4eb)

パッケージマネジャにbunを用いる場合は、現状以下の環境変数の設定が必要なようです。 Cloudflare Pagesでbunを使用する環境変数設定 Cloudflare Pagesでbunを使用する環境変数設定

gist.github.com
gist.github.com favicon https://gist.github.com/Hebilicious/88e5a444f42b8dc09fb86dfa865c6ed3

上記の事情により、SKIP_INSTALL_DEPENDENCY=trueとしているのでビルドコマンドにbun installを含めます。

あとは、上記を含めたビルドコマンドを構成し、ビルド出力ディレクトリなどを設定したらCloudflare Pagesにデプロイされるはずです🚀


おまけ - モノリポにおける依存関係との仁義なき戦い

本当は2日前くらいにデプロイ&この記事を書いていて、「よーし、ツイートして終わり〜〜」と思っていたのですが、投稿時にog画像がいつものようにうまく表示されなくなっていました。

ブログアプリはVercelにデプロイしているので、Vercelのログを確認してみたところ、og画像生成のためのファイルにうまくアクセスできていないようでした。 OG画像にアクセスした時のServerless Functionsでのエラー OG画像にアクセスした時のServerless Functionsでのエラー

⨯ Error: ENOENT: no such file or directory, open '/var/task/articles/_dev/blog-tech-stack.md'
    at Object.readFileSync (node:fs:457:20)
    at c (/var/task/apps/blog/.next/server/app/dev/articles/[slug]/twitter-image/route.js:1:4090)
    at w (/var/task/apps/blog/.next/server/app/dev/articles/[slug]/twitter-image/route.js:1:1018)
    at F (/var/task/apps/blog/.next/server/app/dev/articles/[slug]/twitter-image/route.js:1:2527)
    at /var/task/node_modules/next/dist/compiled/next-server/app-route.runtime.prod.js:6:34672
    at /var/task/node_modules/next/dist/server/lib/trace/tracer.js:140:36
    at NoopContextManager.with (/var/task/node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:7062)
    at ContextAPI.with (/var/task/node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:518)
    at NoopTracer.startActiveSpan (/var/task/node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:18093)
    at ProxyTracer.startActiveSpan (/var/task/node_modules/next/dist/compiled/@opentelemetry/api/index.js:1:18854) {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: '/var/task/articles/_dev/blog-tech-stack.md'
}

原因を調査したところ、./apps/blog./apps/me間での依存関係に整合性が取れてなかったことが問題だとわかりました。

具体的には、./apps/meを付け足しで作った際にインストールした@hono/react-rendererの内部依存パッケージAが、別マイクロサービスである./apps/blog^x.y.zとしてインストールしていたパッケージAとバッティングして、元々./apps/blogで動いていたパッケージAのバージョンが上書きされてしまったことが原因でした。

解決方法としては、npm list --depth=0 --prodで実際に使用されているパッケージのバージョンを全て吐き出し、^を外して、そのバージョンをexactインストールすることで事なきを得ました......

特にモノリポ開発では、範囲を持ったままパッケージをインストールすることはキケンということを再認識させられるいい機会でした🙇🏻


参考

honojs/honox: HonoX - Hono based meta framework

フルスタック Web フレームワーク HonoX を使ってみる

Viteでの開発中のSSR対応の仕組み | 東京工業大学デジタル創作同好会traP

middleware/packages/react-renderer at main · honojs/middleware

yusukebe/honox-react-nested-islands at island-in-island

createRoot – React

HonoX+Cloudflare Pagesで静的ファイルを読み込む

Copyright © 2024 saku 🌸 All rights reserved.