@tuqulore-inc/eleventy-plugin-preact-island
本ページは、Partial Hydration を担う @tuqulore-inc/eleventy-plugin-preact-island の内部設計と応用ケースを扱います。API リファレンスとインストール手順はパッケージの README を参照してください。
Partial Hydration を採用した理由
本ボイラープレートは、初期表示を静的な HTML で返し、動的な UI が必要な箇所だけをクライアント側で hydrate する方針を取ります。全ページを Preact で再描画する必要はほとんどないためです。
コンテンツ主体のサイトでは、大半のページが静的な文章と画像で完結します。この構造で SPA 相当のランタイムを常時読み込ませると、初期 JS のダウンロードとパースだけで LCP や TTI が悪化します。docs や記事サイトでは避けたい状態です。
一方で、検索フォーム、ナビゲーションのトグル、埋め込みのカウンターなど、一部の要素はどうしてもクライアント側の JS を必要とします。これらを部分的に取り出し、その部分だけを hydrate するのが Partial Hydration です。
なぜ is-land を使うのか
Partial Hydration を素朴に自作すると、初期化のトリガー (可視・アイドル・操作) や、bundle の import 順を毎回書き直すことになります。@11ty/is-land はこの部分を Web Components (<is-land>) の形で提供しており、Eleventy との相性も良好です。
本プラグインは is-land をそのまま採用し、その上に Preact 固有の hydrate 手順と、<Island> という MDX / JSX から使いやすい JSX ラッパを重ねています。is-land 自体を隠さないので、パラメータ付きトリガー (例: on:media("(min-width: ...)")) が必要になったときは、素の <is-land> を直接書けます。
.client.* の規約
クライアント側で動かす JavaScript は、src/**/*.client.{js,jsx,ts,tsx} の副拡張子で置きます。拡張子の付いたファイルは:
- esbuild で bundle され、
dist/<入力相対パス>.client.jsに出力されます。 - Eleventy のテンプレート処理から除外されます (ページとしてはレンダーされません)。
- SSR 側で
<Island component={...}>に渡した際、bundle 済みの URL が自動的に解決されます。
「どのファイルがクライアント側なのか」を追加設定なしで宣言できる規約です。glob 一覧を config に書き足す必要がありません。
<Island> の役割
<Island> は、SSR 側の Preact 描画と、<is-land> 要素の生成を一つの JSX で表せるラッパです。
import { Island } from "@tuqulore-inc/eleventy-preset/island";
import Counter from "./counter.client.jsx";
<Island component={Counter} on="interaction" initial={5} />;
同じ props が SSR 側の描画とクライアント側の hydrate の両方に渡ります。SSR で描いたマークアップと hydrate した後の DOM が一致することが保証されます。
props のシリアライズには devalue を使っており、Date、Map、Set のような JSON では失われる型もそのまま JavaScript の値として渡せます。
型定義の上では、<Island> に渡す props は、渡したコンポーネントの props の型と照合されます。
型検査を有効にしたプロジェクト(default テンプレートなど)では、props 名の打ち間違いや必須の props の渡し忘れが型エラーになります。
Eleventy の pathPrefix に乗る
サブディレクトリ配下にデプロイする場合 (例: GitHub Pages で /repo/ 以下)、<is-land import="..."> の URL もそのプレフィックスに合わせる必要があります。本プラグインは Eleventy の pathPrefix をそのまま読み取ります。個別のオプションで URL プレフィックスを渡す口は用意していません。
「Eleventy が持っている概念に乗る」ことで、開発者は同じ設定を 1 か所にだけ書けば済みます。
bundle: false で外部バンドラに渡す
esbuild による bundle は bundle: true (デフォルト) で動きます。別のバンドラを使いたい場合は bundle: false を渡すと、プラグインは URL 解決と is-land.js のコピー、未バンドルの setup モジュールの出力と読み込みだけを担うようになります。開発者は、期待される URL (<pathPrefix><入力相対パス>.client.js) に bundle を配置し、setup モジュールも同じバンドラで処理する責任を負います。具体的な条件は @tuqulore-inc/eleventy-plugin-preact-island の README を参照します。
「zero-config で動くが、必要なら差し替えられる」構成です。バンドル戦略が変わってもプラグイン全体を捨てずに済みます。
Island を使うかどうかの判断
Island は「クライアント側の JavaScript が必要な要素だけを部分的に hydrate する」仕組みです。JavaScript が要らない要素には使いません。次のいずれかで済む場合、Island を使うより単純です。
<details>/<dialog>などの HTML 要素で完結する UI (アコーディオン、モーダル)- CSS の
:targetや:has()セレクタで表現できる状態遷移 - リンクとページ遷移で済むフロー
「JS がないと本当に困るか」を先に検討します。docs サイトのように読者が触るのは検索とナビゲーションのごく一部、というケースでは、Island の対象は自然と絞られます。
何を Island 化するか
Island 化の判断基準は以下です。
- 状態を持つ: カウンター、フォーム、フィルタ UI など。
- 外部データを読む: 検索、フェッチ、遅延読み込み。
- ユーザー操作で描画が変わる: メニュートグル、タブ切替。
このいずれにも当てはまらない要素 (ロゴ、静的なテキスト、リンク集など) は Island 化する必要がありません。SSR された HTML で完結します。
粒度を細かくするか、粗くするか
Island の単位を細かくすると、hydrate 対象の JS が小さくなります。ヘッダのメニューだけ、フッタのニュースレター登録フォームだけ、というふうにページの一部だけを Island 化するのが原則です。
一方で、隣接した複数の UI が同じ状態を共有する場合 (フィルタとリストが連動するなど) は、その組を 1 つの Island としてまとめます。Island を跨いだ状態共有は、素の Preact の props や Signals では表現できないためです。
粒度の判断で迷ったら、「同じ状態を触る要素は同じ Island に入れる」を目安にします。
トリガーを選ぶ
<Island> の on 属性で、hydrate のタイミングを切り替えられます。
| 値 | 使い所 |
|---|---|
interaction | クリック・フォーカスがあるまで待ちます。多くのボタンやフォームに向きます。デフォルトの値です。 |
visible | ビューポートに入ったら hydrate します。ページ後半に置いたインタラクティブなブロックに向きます。 |
idle | ブラウザがアイドルになったら hydrate します。初期表示直後に必ず動かしたい軽量な UI に向きます。 |
判断基準は「利用者がその Island に触るまでにどれくらい猶予があるか」です。触るまで時間がある要素ほど遅らせ、初期表示直後に必要な要素ほど早めに hydrate します。
on の型は、表の 3 つの値をエディタの補完候補に出しつつ、それ以外の文字列も受け付けます。
is-land が持つ load や save-data のようなトリガーも、そのまま渡せます。
ただし実行時には、/^[A-Za-z0-9_-]+$/ に一致しない値は例外になります。
パラメータ付きトリガー (on:media("(min-width: 40rem)") など) が必要な場合は、<Island> を使わずに素の <is-land> を直接書きます。詳細は @tuqulore-inc/eleventy-plugin-preact-island の README を参照してください。
SSR と一致させる
Island の中で new Date() や Math.random() のような不確定な値を初回描画に使わないでください。SSR とクライアントで異なる値が生成され、hydrate 時に mismatch が起きます。初回描画は決定的な値だけを使い、動的な値は useEffect の中で反映するようにします。
SSR しない Island
前節の方法(初回描画を決定的にし、動的な値を useEffect で反映する方法)では、SSR の HTML に意味のある初期表示を出せないコンポーネントがあります。
現在時刻のように SSR とクライアントで必ず値が食い違うものや、localStorage のようにブラウザにしかない状態を初期表示に使うもの、描画にブラウザ専用 API を必要とするものです。
このようなコンポーネントは、clientComponent() の代わりに clientOnlyComponent() で宣言します。
import { clientOnlyComponent } from "@tuqulore-inc/eleventy-preset/island";
import { useEffect, useState } from "preact/hooks";
function Clock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
return <time>{now.toLocaleTimeString()}</time>;
}
export default clientOnlyComponent(Clock, import.meta.url);
<Island> は、client-only のコンポーネントを SSR で描画せず、fallback に渡した内容だけを <is-land> の中に出力します。
クライアント側では、トリガーが発火した時点で fallback を取り除き、hydrate ではなく新規に描画します。
SSR の HTML と突き合わせないので、mismatch は起きません。
on を省略した client-only の Island は、ページの読み込みが終わった時点で描画します。
通常の Island の既定値である interaction は Island の要素へのクリックを待ちますが、client-only の Island の中身は fallback だけで、空のこともあります。
その場合はクリックできる領域がなく、interaction ではいつまでも描画されません。
on を指定すればそのトリガーで描画するので、on="visible" と組み合わせれば、SSR せず表示領域に入った時点で初めて描画する Island になります。
<Island component={Clock} on="visible" fallback={<span>--:--:--</span>} />
fallback は、描画後のコンポーネントと同じ大きさに揃えます。 大きさが異なると、差し替えた時点でレイアウトがずれます。
client-only にしても、コンポーネントのモジュール自体は SSR 時に評価されます。
<Island> に渡すために、SSR 側のページがそのモジュールを import するためです。
一方で、関数コンポーネントの本体は呼ばれたときにしか実行されません。
ブラウザ専用 API はコンポーネントの本体か useEffect の中に置き、モジュールのトップレベルには置かないでください。
依存パッケージがモジュールのトップレベルでブラウザ専用 API に触れる場合は、そのパッケージを import した時点で SSR が失敗します。
この場合は、そのパッケージを useEffect の中で動的 import する薄いラッパを、client-only のコンポーネントとして書きます。
useEffect はクライアントでしか実行されないので、SSR はそのパッケージを評価しません。
import { clientOnlyComponent } from "@tuqulore-inc/eleventy-preset/island";
import { useEffect, useState } from "preact/hooks";
function Chart(props) {
const [Impl, setImpl] = useState(null);
useEffect(() => {
import("browser-only-chart").then((mod) => setImpl(() => mod.default));
}, []);
return Impl ? <Impl {...props} /> : null;
}
export default clientOnlyComponent(Chart, import.meta.url);