本記事の詳細なコードは以下のリポジトリから閲覧できます。
https://github.com/ke-kawai/hono-cdk-hydration
TL;DR
- Hono の JSX で Hydration 機能を持つ SSR を実現したい
- Vite で簡単なビルドプロセスを書いてみた話
- ついでに aws-lambda プラグインと組み合わせ、Lambdalith としてデプロイしてみた
モチベーション
Hydration 技術について
Next.js や Remix、SvelteKit などのモダンフレームワークは SSR (Server Side Rendering) をサポートしています。SSR は狭義には HTML をサーバーで生成して返すかどうかを指しますが、これらのモダンフレームワークは Hydration と呼ばれる技術によって、CSR (Client Side Rendering) が提供するインタラクティブ性を損なうことがありません。
Hydration の仕組みは次のようになっています。クライアントがページにアクセスした際、最初にレンダリングされるべき内容をサーバーサイドで生成し、ユーザーに HTML レスポンスとして返します。その後、JavaScript ファイルをリクエストさせ、インタラクティブ性が必要な部分を再生成するという手法です。名前の由来は、乾燥したものに水を与えて戻すように、静的な HTML にインタラクティブ性を付与することから来ています。
SSR のメリットは、CSR の弱点であった初回ページロードの遅さと SEO の弱さを改善できる点です。デメリットとしてはバンドルされた javascript ファイルが大きくなりやすいようです。
なぜ HonoX ではないのか
Hono を使ったメタフレームワークである HonoX も Hydration をサポートしています。しかし、HonoX はまだアルファ版であること、テンプレートで作成される HonoX プロジェクトではデプロイ先が Cloudflare を向いており AWS CDK に変更するのが大変そうだったこと、何より学習という観点から、以下のような要件を満たすものを自前で作成してみました。
- AWS 環境に簡単にデプロイできること
- クライアントコンポーネントを明示的な方法で宣言でき、ページ側ではそれを意識しなくて済むこと
- Hydration の実装が軽量で改善しやすいこと
Hydration の仕組み
今回実装した Hydration は以下の流れで実現されます。
- クライアントのリクエストに対して、サーバー側で Hydration が必要な要素をマーキングした HTML レスポンスを返却
- クライアントがクライアントコンポーネントの入った client.js をリクエスト
- サーバーが client.js を返却
- client.js が実行され、マーキングされた要素をクライアント側で再レンダリング
本章では、クライアントコンポーネントの宣言方法、サーバーサイドレンダリング、client.js のビルド、クライアント側での再レンダリングの順で詳しく見ていきます。
クライアントコンポーネントの宣言方法
クライアントコンポーネントは Hono の JSX を用いて以下のように書けます。これはシンプルなカウンターの一例です。実装したビルドスクリプトの制約で以下の書き方を守る必要があります。
- ファイル名は
*.client.tsxとすること - 関数本体は function 宣言の形で書くこと
-
export default createClientComponent(関数名)でデフォルト関数としてエクスポートすること
import { useState } from "hono/jsx";
export function ClientCounter() {
const [count, setCount] = useState(0);
return (
<div>
<div>
<p>{count}</p>
</div>
<div>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
</div>
);
}
export default createClientComponent(ClientCounter);
呼び出し側では、クライアントコンポーネントであることを意識する必要はなく、サーバーコンポーネントと同様に扱えます。
import ClientCounter from "./ClientCounter.client";
app.get("/", (c) => {
return c.render(
<div>
<h1>カウンターアプリ</h1>
<ClientCounter />
</div>
);
});
サーバー側のレンダリングメカニズム
ユーザーがリクエストを送信した際、はじめにサーバーサイドレンダリングが実行され、生成された HTML がレスポンスとして返却されます。この際、サーバーコンポーネントかクライアントコンポーネントかに関係なく、初期に描画されるべき内容を返す必要があります。
createClientComponent 関数では、要素を div で囲い、Hydration が必要な要素であることをdata-client-component 属性を与えることによって明示します。さらに、コンポーネントが受け取った props を data-props属性 として HTML の中に渡しておきます。後述しますが、クライアント側ではこの data-props を元に再びレンダリングを行います。
export function createClientComponent<P = {}>(componentFunc: FC<P>): FC<P> {
const componentName = componentFunc.name;
return (props?: P) => {
const id = `client-${componentName}-${componentCounter++}`;
const initialContent = componentFunc(props);
// propsをhtmlのdata-props属性に記載する
serializedProps = JSON.stringify(props);
return (
<div
id={id}
data-client-component={componentName}
data-props={serializedProps}
data-hydrated="false"
>
{initialContent}
</div>
);
};
}
例えば上の ClientCounter を利用した場合、レスポンスの HTML は次のようになります。
<div
id='client-ClientCounter-1'
data-client-component="ClientCounter"
data-props='{"initialCount":0,"initialLabel":"Counter"}'
data-hydrated="false"
>
<div>
<div>
<p>0</p>
</div>
<div>
<button>Decrement</button>
<button>Increment</button>
</div>
</div>
</div>
client.js のビルド
client.js は hono/jsx やクライアントコンポーネントの実装が1つにバンドルされたファイルです。client.js が実行されることで、サーバーから返された HTML から Hydration が必要な要素を探し出し、再レンダリングが行われます。
client.js は Vite のビルドシステムを用いて生成します。npm run build が実行されると、client-entry.tsxがエントリーポイントとなり以下のコードが実行されます。
const componentModules = import.meta.glob<ComponentModule>(
"../lambda/**/*.client.tsx",
{ eager: true }
);
const components: ComponentRegistry = {};
for (const [path, module] of Object.entries(componentModules)) {
if (module.default) {
const match = path.match(/\/([^/]+)\.client\.tsx$/);
if (match) {
const componentName = match[1];
components[componentName] = module.default;
}
}
}
hydrateClientComponents(components);
ここでは、プロジェクト内の *.client.tsx ファイルを全て探し出し、Hydration を行う関数に渡しています。こうすることで、Hydration 機能を持ち合わせたクライアントコードが生成されます。
Hydration の実行
では作成された client.js がどのようなことをするのか見ていきましょう。client.js では、HTML にdata-client-component属性を持つ全ての要素に対して Hydration を行います。
この時 Hydration の優先度は以下のようになっています。
- まず画面内 + 上下 200px の要素に関して即時に実行
- スクロールによって画面内 + 上下 200px に入った場合は即座に実行
- 残りの要素に関してはブラウザがアイドル状態の時に適宜実行
初めは全てを順番に Hydration するような実装を書いたのですが、先駆者様の記事をその後発見し、これを参考に改良しました。
export const hydrateClientComponents = (components: ComponentRegistry) => {
...
elements.forEach((element) => {
if (isVisibleWithinMargin(element, highPriorityHydrationMargin)) {
// 画面内のコンポーネントは即時にHydration
hydrateElement(element, components);
} else {
// その他の要素は遅延させる
observer.observe(element);
deferredElements.push(element);
}
});
// 画面外のコンポーネントはスクロールで表示されたタイミングでHydration
// ブラウザがアイドル状態のときに残りのコンポーネントをHydration
scheduleIdleHydration(deferredElements, components, observer);
};
それぞれのコンポーネントはhydrateElementで Hydration されますが、実装はシンプルで、コンポーネント名からクライアントコンポーネントの実装を引き出し、要素を置き換える形で再レンダリングを行います。
const hydrateElement = (
element: Element,
componentRegistry: ComponentRegistry
) => {
const componentName = element.getAttribute("data-client-component");
const Component = componentRegistry[componentName];
const props = getPropsFromElement(element);
render(<Component {...props} />, element);
};
AWS Lambda としてデプロイする
自前実装した Hydration 機能付きの Hono アプリを AWS CDK と組み合わせて、簡単にデプロイできるようにしました。こちらの公式ドキュメントが大変参考になりました。
一点だけドキュメントと異なるのは、CDK のスタックとして Lambda を定義するときに commandHooks を設定していることです。Hono のビルドの完了後に上述した Vite のビルドを実行し、client.js や style.css といった静的ファイルを Lambda 関数に同梱させています。
const fn = new NodejsFunction(this, 'lambda', {
entry: 'lambda/index.tsx',
handler: 'handler',
runtime: lambda.Runtime.NODEJS_22_X,
bundling: {
commandHooks: {
...
afterBundling: (inputDir: string, outputDir: string): string[] => [
`cd ${inputDir} && npm run build`,
`cp -r ${inputDir}/public ${outputDir}/public`,
],
},
},
})
検討課題や疑問
キャッシュの実装
現在はビルドによって作成される JavaScript と CSS ファイルにキャッシュを設けていません。つまり、アクセスされるたびにユーザーから HTML、JavaScript、CSS の3回のリクエストを処理することになっています。改善するためにはキャッシュ化を盛り込んだ方が良いでしょう。ビルドのたびに固有の ID を含んだファイル名にする、CloudFront に配置してキャッシュを適切に管理するなど、様々な選択肢がありそうです。
複雑なアプリ上での動作検証
サンプルのアプリは AI に適当に書かせています。コンポーネントの入れ子やクライアントコンポーネントとサーバーコンポーネントを組み合わせたサンプルアプリを作ってみた感触では問題なさそうですが。実アプリに耐えられるかは色々使ってみて検証する必要がありそうです。
パフォーマンスの測定
今回実装したHydrationの仕組みでは、あるクライアントコンポーネントが存在した時に、その配下の全ての DOM 要素に対して再レンダリング行われるのではないかと思います。クライアントコンポーネントが最上位に来てしまうような設計をするとパフォーマンスが悪くなるのではないでしょうか。他のフレームワークがこの問題にどう対処しているのか、有名どころのフレームワークと比較してみるのが面白そうです。
まとめ
今回は Hono に簡単な Hydration メカニズムを実装して、それを AWS Lambda 上にデプロイしてみました。Hydration は複雑な仕組みかと思いましたが、AI と壁打ちしながら自前で実装することができました。
Hydration の実装を通じて、モダンフレームワークの見識を深められたのと同時に、実装の改善案や新たな疑問が出てきました。
初めて技術記事というものを書いてみましたが、個人的に良いキャッチアップの機会になりました。また車輪の再発明や情報発信を通じて技術力を高めていけたら嬉しいです。
