🔥

[Cloudflare Workers] HonoにJSXミドルウェアが追加されました

2022/06/13に公開約8,000字

Cloudflare Workers向けのフレームワーク「Hono」にJSXミドルウェアが追加されたので、その紹介をします。HonoはCloudflare Workers向けのWebフレームワークです。Honoはビルトインミドルウェアが豊富で、今回紹介するものはそのひとつです。

概要

簡単に紹介すると、JSXのシンタックスとテンプレートリテラルでHTMLをかっこよく書けます。そして、非常に高速にSSRされます。以下のコードを見てください。これがCloudflare Workersのエッジで動くのです。

index.tsx
import { Hono } from 'hono'
import { html } from 'hono/html'
import { jsx } from 'hono/jsx'

const app = new Hono()

const Layout = (props: { children?: any }) => html`<!DOCTYPE html>
  <html>
    <body>
      ${props.children}
    </body>
  </html>`

const Content = (props: { name: string }) => (
  <Layout>
    <h1>Hello {props.name}!</h1>
  </Layout>
)

app.get('/hello/:name', (c) => {
  const { name } = c.req.param()
  return c.html(<Content name={name} />)
})

app.fire()

SSRのためのJSX

JSXミドルウェアは、HTMLをレンダリングするためのテンプレートエンジンを探していたところ、その代替として、@usualomaさんが提案してくれました(そしてそのまま彼がほとんど実装してくれました。すげえ!感謝!)。HonoにはMustacheのミドルウェアもありますが、ようはMustacheとかHandlebarとかejsとかそういうテンプレートエンジンの類いとしてJSXを使うという試みです。つまり「Server-Side-RenderingのためにJSXシンタックスを使う」というものです。なので、仮想DOMを作らず文字列を扱うだけです。クライアントサイドのことは一切面倒をみません。これがキモです。

TypeScriptではJSXの構文をもともとサポートしていて、タイプチェックを経て、JavaScriptへコンパイルされます。HonoのJSXミドルウェアはTypeScriptで書かれることのみ想定しています。JSXというと「Reactのもの」と思われがちですが、JSXはReactに限りません。今回のミドルウェアはReactではないのです。

加えると、Cloudflare Workersにはファイルシステムがありません。テンプレート「ファイル」よりも、一度JSにコンパイルされた方が使いやすいので、JSXが適していると言えます。

HonoはCloudflare Workersで動作する高速なフレームワークです。Cloudflare Workersではスクリプトの容量が1MB以内でなくてはいけなかったりと制限が強いので、HTMLをベースとした「Webサイト」を作るのには向きません。現に、Workersの他に「Cloudflare Pages」というプロダクトもあります。どちらかというと機能の少ないWeb APIに向いていると思います。Honoは基本的にそのユースケースにフィットするでしょう。また、Cloudflare Workers対応フレームワークではRemixがありますので、Workersである程度大きさのWebサイトを作りたい方はそちらを使うのがよいでしょう。

それに対し、今回のミドルウェアは「気軽に静的なHTMLページを作る」、というユースケースを想定して作ったものです。JSXで書けたとしても、クライアントサイドのコードは吐かないし、Hydrationもしません。SSRするだけです。その代わり、高速に動作します。また、後述する「htmlミドルウェア」と組み合わせれば、非常に見通しよくHTMLページを配信することができるでしょう。

Getting Started

では、JSXミドルウェアを使ってみましょう。プロジェクトを初期化して、Honoをインストールします。

mkdir jsx-example
cd jsx-example
npx wrangler init -y
npm i hono

次に、tsconfig.jsonで最低限の設定をします。jsxの値はreactモードにします。

tsconfig.json
{
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "jsx",
    "jsxFragmentFactory": "Fragment"
  }
}

その上で、ファイルの拡張子を「.tsx」として、コードを書き始めればOKです。import { jsx } from 'hono/jsxと書くだけで、ミドルウェアがオンになり、JSXを書けるし、それをそのまま文字列として扱えます。c.htmlメソッドの引数に渡すことができます。

src/index.tsx
import { Hono } from 'hono'
import { jsx } from 'hono/jsx'

const app = new Hono()

app.get('/', (c) => {
  return c.html(
    <html>
      <p>Hono!</p>
    </html>
  )
})

app.fire()

使用例

いわゆる「Function Component」を作ることもできますし、そこにpropsも渡せます。childrenが渡ってくるので、下記で言うLayoutみたいなことができます。

src/index.tsx
import { Hono } from 'hono'
import { jsx } from 'hono/jsx'

const app = new Hono()

const Layout = (props: { children?: string }) => {
  return (
    <html>
      <body>{props.children}</body>
    </html>
  )
}

const Top = (props: { messages: string[] }) => {
  return (
    <Layout>
      <h1>Hello Hono!</h1>
      <ul>
        {props.messages.map((message) => {
          return <li>{message}!!</li>
        })}
      </ul>
    </Layout>
  )
}

app.get('/', (c) => {
  const messages = ['Good Morning', 'Good Evening', 'Good Night']
  return c.htm(<Top messages={messages} />)
})

app.fire()

Fragment

Fragmentにも対応します。当然、<></>でもOK。

src/index.tsx
import { jsx, Fragment } from 'hono/jsx'

const List = () => (
  <Fragment>
    <p>first child</p>
    <p>second child</p>
    <p>third child</p>
  </Fragment>
)

memo

React.memoのようなmemoもあります。再計算しないので、対象のコンポーネントに関しては高速に動作します。プロパティが変更されないようなものはmemo化しておくといいかもしれません。

src/index.tsx
import { jsx, memo } from 'hono/jsx'

const Header = memo(() => <header>Welcome to Hono</header>)
const Footer = memo(() => <footer>Powered by Hono</footer>)
const Layout = (
  <div>
    <Header />
    <p>Hono is cool!</p>
    <Footer />
  </div>
)

dangerouslySetInnerHTML

変数をエスケープせずに埋め込めるdangerouslySetInnerHTMLも実装されています。

src/index.tsx
app.get('/foo', (c) => {
  const inner = { __html: 'JSX &middot; SSR' }
  const Div = <div dangerouslySetInnerHTML={inner} />
})

htmlミドルウェアと組み合わせる

ほぼ同時期に「htmlミドルウェア」も追加されました(これも@usualomaさんが作ってくれました!)。これは、HTMLを作るためのhtmlメソッドを提供します。ようは変数をエスケープするテンプレートリテラルが書けます。

src/index.ts
import { Hono } from 'hono'
import { html } from 'hono/html'

const app = new Hono()

app.get('/:username', (c) => {
  const { username } = c.req.param()
  return c.html(
    html`<!DOCTYPE html>
      <h1>Hello! ${username}!</h1>`
  )
})

app.fire()

小粒な機能ですが、これがとても使えます。まず、面白いのは、こちらの拡張を使えば、VS Codeでバッククォーテーションの中もシンタックスハイライトと補完が効くのです。

https://marketplace.visualstudio.com/items?itemName=bierner.lit-html

SS

これをJSXミドルウェアと組み合わせると色んなことができます。

JSXの中にスニペットを挿入する

JSXでは表現できないスニペットをリテラルで書いてJSXの{snippet}に挿入しています。

src/index.tsx
const snippet = html`
  <script async src="https://www.googletagmanager.com/gtag/js?id=MEASUREMENT_ID"></script>
  <script>
    window.dataLayer = window.dataLayer || []
    function gtag() {
      dataLayer.push(arguments)
    }
    gtag('js', new Date())

    gtag('config', 'MEASUREMENT_ID')
  </script>
`

app.get('/', (c) => {
  return c.html(
    <html>
      <head>
        <title>Test Site</title>
        {snippet}
      </head>
      <body>Hello!</body>
    </html>
  )
})

スクリプトを挿入する

JavaScriptのスクリプトをインラインで挿入するのをdangerouslySetInnerHTMLなしでできます。

src/index.ts
app.get('/', (c) => {
  return c.html(
    <html>
      <head>
        <title>Test Site</title>
        {html`
          <script>
            // ここにスクリプトを書く。エスケープされない。
          </script>
        `}
      </head>
      <body>Hello!</body>
    </html>
  )
})

memoなしでも速い

Function Componentライクに書けます。こうすればJSXのmemo相当のことができます。

src/index.tsx
const Footer = () => html`
  <footer>
    <address>My Address...</address>
  </footer>
`

プロパティをもらう

アウターの<!DOCTYPE html>htmlheadbody...タグをhtmlテンプレートリテラルで書いて、レイアウトとして使いつつ、headタグの中のtitle要素の値はプロパティでもらう、なんてこともできますね。これはよく使うパターンになるでしょう。

src/index.tsx
import { Hono } from 'hono'
import { html } from 'hono/html'
import { jsx } from 'hono/jsx'

const app = new Hono()

interface SiteData {
  title: string
  children?: any
}

const Layout = (props: SiteData) => html`<!DOCTYPE html>
  <html>
    <head>
      <title>${props.title}</title>
    </head>
    <body>
      ${props.children}
    </body>
  </html>`

const Content = (props: { siteData: SiteData; name: string }) => (
  <Layout {...props.siteData}>
    <h1>Hello {props.name}</h1>
  </Layout>
)

app.get('/:name', (c) => {
  const { name } = c.req.param()
  const props = {
    name: name,
    siteData: {
      title: 'JSX with html sample',
    },
  }
  return c.html(<Content {...props} />)
})

app.fire()

開発環境

Cloudflare Workersと言えば、Wranglerで開発しますが、JSXミドルウェアを使う場合は、Miniflare+esbuildの組み合わせが好みです(この2つはWranglerの中でも使われています)。Miniflareなのでlive-reloadが効きます。

Example

以前はnano-jsxというライブラリを使ってSSRを試していたExampleがありますが、それを今回のJSXミドルウェアに書き変えたので、見てみてください。コードがだいぶスッキリしました。

https://github.com/honojs/examples/tree/master/jsx-ssr

パフォーマンス

当然ですが、Remixより速いです。以下はRemixがただ一つのルートから文字列を返すだけ。Honoがルートがいくつかある中、動的にHTMLをレンダリングする、という条件でベンチした結果です。

SS

まとめ

以上、HonoのJSXミドルウェアとhtmlミドルウェアを紹介しました。このミドルウェアがある前は、HonoはWebサイトには向かない、と思っていましたが、この2つの使い勝手が良いので、重宝するかもしれません。SSRしかしないので、クライアント側で動きがある時にどうしようかと思っていますが、例えば、Honoの場合かなり「MVC」っぽいので、Railsを真似て、Hotwireを使うってのも面白いです。他になにかいい方法があれば教えて下さい。

JSXミドルウェアは簡単に使い始めることができます。興味のある方は是非、使ってみてください。

https://github.com/honojs/hono

Discussion

ログインするとコメントできます