🐡

エディタに優しいHonoのコードを考える

2024/12/21に公開

本稿はHono Advent Calendar 2024の21日目の記事です。

はじめに

Hono RPCを使っていると、アプリケーションが大きくなるにつれクライアント側のコードを書くときにエディタの処理が重くなることが知られています。具体的には、補完候補が表示されるまで秒単位で待たされたりします。Xで「hono rpc 重い」などで検索してみるといくつかひっかかります。現時点での最も効果的なこれの解決策は、事前に型をコンパイルして使うことです[1]

しかし、クライアント側に関してはこれで解決するものの、バックエンド側も普通に重たくなります。アプリケーションが肥大化してくると、エントリーポイントとなるHonoインスタンスに新しいルートを追加するときにエディタがもたつきます。npm workspaceなどでバックエンドのコードをこまめにパッケージ分割してコンパイルすれば解決するはずですが、それもやや面倒です。

コードをいじる頻度から言えばクライアント側ほどは問題にならないとは思いますが、それでも遅いより速い方がいいので対処法を少し考えてみました。

なお、本稿はHonoとHono RPCの基本的な知識があることを前提としています。

結論

結論から言うと、「実装と型を分けて使う」とよさそうでした。

// sub-app.ts
import { Hono } from 'hono'

export const app = new Hono()
  .get('/sub', c => c.json({ ok: true }))

// index.ts
import { Hono } from 'hono'
import { app as subApp } from './sub-app'

const app = new HonoApp()
  .route('', subApp)

export default app

大きなアプリケーションを作る場合、ハンドラーの定義を複数のHonoインスタンスに分割して、大元のHonoインスタンスのrouteメソッドで読み込むという方法を取ることが多いと思います。Honoのgetメソッドの戻り値の型は、ハンドラーの型情報を追加したHono型になっています。こうすることで、Hono RPCが型情報を使えるようになります。

このgetメソッドは戻り値を持つので忘れてしまいがちですが、破壊的なメソッドです。つまり、次のように書いてもハンドラーはきちんと登録されます。

// sub-app.ts
export const app = new Hono()
app.get('/sub', c => c.json({ ok: true }))

であれば、次のようにすれば実装のためのHonoインスタンスと型のためのHonoインスタンスを分けることができます。

// sub-app.ts
import { Hono } from 'hono'

export const app = new Hono()
export const typedApp = app
  .get('/sub', c => c.json({ ok: true }))

// index.ts (変更なし)
import { Hono } from 'hono'
import { app as subApp } from './sub-app'

const app = new HonoApp()
  .route('', subApp)

export default app

このとき、index.tsのappsubApp側の型情報を持っていません。subAppにハンドラーが増えても型の複雑さは変わりませんし、別のHonoインスタンスを追加する場合も同じやり方をすれば型をスリムに保つことができます。

実際にやってみた

実際に擬似的な巨大Honoアプリケーションを作って試してみました。200個のエンドポイントを持つsub appを5つ作り、それをエントリーポイントのHonoインスタンスに登録します。sub appはこんな感じです(hcWithTypeについては後ほど説明します)。

import { Hono } from 'hono'
import { hc } from 'hono/client'
export const app = new Hono().basePath('app-1')
const typedApp = app
  .get('/route-1/:id', (c) => {
    return c.json({
      ok: true
    })
  })
  .get('/route-2/:id', (c) => {
    return c.json({
      ok: true
    })
  })
  // 中略。200個ある
  .get('/route-200/:id', (c) => {
  return c.json({
  ok: true
  })
})
export const hcWithType = (...args: Parameters<typeof hc>) =>
  hc<typeof typedApp>(...args)

appは型情報のない変数、typedAppは型情報を持つ変数です。

まず、型ありのエントリーポイントを用意します。

import { Hono } from 'hono'
import { typedApp as app1 } from './apps/app-1'
import { typedApp as app2 } from './apps/app-2'
import { typedApp as app3 } from './apps/app-3'
import { typedApp as app4 } from './apps/app-4'
import { typedApp as app5 } from './apps/app-5'

const app = new Hono()
  .route('', app1)
  .route('', app2)
  .route('', app3)
  .route('', app4)
  .route('', app5)

export default app

1000個のエンドポイント分の型情報を持っているので、このappをIDEで操作しようとすると結構重いです。

補完が現れるまで5秒ほどかかっています。では型なし版ではどうでしょうか?

一瞬で補完が表示されます!

そして、型ありでも型なしでもランタイム上では全く同じ動きをします。はっぴー

RPCは?

バックエンドは高速になりましたが、クライアントはどのように書けばいいのでしょうか?型なし版のHonoインスタンスからではHono RPCは使えません。そこで、先ほど説明を先延ばしにしたhcWithTypeの出番です。

export const hcWithType = (...args: Parameters<typeof hc>) =>
  hc<typeof typedApp>(...args)

sub app毎にこれを用意しておきます。つまり、sub app毎にクライアントが生えます。

// client.ts
export { hcWithType as app1Hc } from './apps/app-1'
export { hcWithType as app2Hc } from './apps/app-2'
export { hcWithType as app3Hc } from './apps/app-3'
export { hcWithType as app4Hc } from './apps/app-4'
export { hcWithType as app5Hc } from './apps/app-5'

// 使う時はこんな感じ
const res = await app1Hc('localhost:3000')['app-1']['route-1'][':id'].$get({ param: { id: '1' }})

もちろん、クライアントを1つにするために型つきのHonoインスタンスを作ってもいいですし、全てのsub appの型をまとめる型を書くことも可能だと思います。ただ、今回のようにクライアントを分割する方法も結構ありなんじゃないかと以前から思っていました。メリットは各クライアントの型の複雑度が下がるのでコンパイル無しでも使用に耐えうる可能性があがること、デメリットはsub appの分だけクライアントが増えることでしょうか。

おわりに

最初に書いたように実際そんなに困っていない部分だと思いますが、アイデアが天から降りてしまったので書いてみました。もし採用された方がおられましたら感想を聞かせていただけると幸いです。

記事中で使ったコード

脚注
  1. 手前味噌ですが、Honoの公式ドキュメントに書きました ↩︎

Discussion