zfb
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

Islands

クライアントでインタラクティブなコンポーネントを "use client" でマークすると、zfb がブラウザでハイドレートします。

zfb のページはデフォルトで静的 HTML にレンダリングされます。Islands(島) はその抜け道です。ブラウザに JavaScript を配信してクライアントでハイドレートする小さなコンポーネントで、ページの残りの部分はプレーンな HTML のままに保たれます。

メンタルモデルは単純です。ページの大部分は静的なドキュメントです。いくつかのインタラクティブな要素(カウンター、検索ボックス、テーマ切り替え)は、そのドキュメントに埋め込まれた島であり、単一の共有バンドルからブラウザでハイドレートされます。

島とは何か

.tsx ファイルの先頭に "use client" ディレクティブを追加します。

"use client";

import { useState } from "preact/hooks";

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

この 1 つのディレクティブが opt-in のすべてです。これを持たないファイルは純粋なサーバーコンポーネントです。ビルド時に一度レンダリングされるだけで、ブラウザには決して届きません。

同梱の basic-blog テンプレートにある components/theme-toggle.tsx は、典型的な実例であり、このテンプレート唯一の島でもあります。localStoragematchMedia を読み取り、自身の状態を管理し、アクティブなテーマを document.documentElement.dataset.theme にミラーします。鍵となるパターンは、初回ペイントでは決定論的で SSR セーフなデフォルトをレンダリングし、その後 useEffect 内でユーザーの設定に同期する点です。

"use client";

import { useEffect, useState } from "preact/hooks";

type Theme = "light" | "dark";

export default function ThemeToggle() {
  // 決定論的で SSR セーフなデフォルト。実際の設定は useEffect で適用される。
  const [theme, setTheme] = useState<Theme>("light");

  useEffect(() => {
    const saved = window.localStorage.getItem("theme");
    if (saved === "light" || saved === "dark") setTheme(saved);
  }, []);

  const next: Theme = theme === "dark" ? "light" : "dark";
  return (
    <button
      type="button"
      aria-pressed={theme === "dark"}
      onClick={() => setTheme(next)}
    >
      {theme === "dark" ? "Light mode" : "Dark mode"}
    </button>
  );
}

N 個の島に対して esbuild が生成するもの

3 つの島(CounterThemeToggleSearchBox)を持つプロジェクトの場合、islands のビルドステップは 1 つの共有バンドル を出力します。

dist/assets/islands.js

すべての島は単一の esbuild エントリポイントへ静的にインポートされるため、共有バンドルにはすべての島コンポーネントが含まれます。その後 ProductionAssetPipeline が、最終出力を書き出す前にコンテンツハッシュ付きのファイル名へとリネームします。

dist/assets/islands-<hash>.js

いずれかの島が内部で動的な import() を使っている場合、esbuild はそれと並べてコード分割チャンクも出力することがあります。

dist/assets/islands-chunk-<hash>.js

バンドルは各島を静的なマーカー名で登録し、モジュールの末尾で mountIslands() を呼び出して、ページ上のすべての [data-zfb-island] 要素をハイドレートします。

ProductionAssetPipeline がハッシュ化の単一の真実の源です — バンドラはまず安定した islands.js を書き出し、パイプラインがハッシュ付きへのリネームを行い、出力された HTML 内の注入されたスクリプト URL を書き換えます。下流の処理がファイル名を推測する必要はありません。

島がどうロードされるか

レンダリング後、各島のサーバーレンダリングされた HTML は、メタデータを持つ <div> でラップされます。

<div data-zfb-island="ThemeToggle"
     data-props="{}">
  <!-- server-rendered island HTML -->
  <button type="button" aria-pressed="false">Dark mode</button>
</div>

data-when 属性はハイドレーションのタイミングを制御し、デフォルト以外のタイミングを要求したときにのみ出力されます。"load" タイミング(即座にハイドレート)がデフォルトで、data-when 属性は生成されません。デフォルト以外のタイミングを指定する JSX API は <Island> ラッパー です。メディアクエリによるハイドレーションでは、when="media"media クエリの両方を渡します。

import { Island } from "@takazudo/zfb";
import MobileMenu from "./MobileMenu";

export default function Header() {
  return (
    <Island when="media" media="(max-width: 768px)">
      <MobileMenu />
    </Island>
  );
}

"visible""idle""media" 戦略はそれぞれ data-when 属性を出力します。"media" はさらに CSS クエリ文字列を持つ data-media 属性も出力します。

<!-- visible timing -->
<div data-zfb-island="SearchBox" data-props="{}" data-when="visible">…</div>

<!-- idle timing -->
<div data-zfb-island="Counter" data-props="{}" data-when="idle">…</div>

<!-- media timing — (max-width: 768px) が最初にマッチしたときにハイドレート -->
<div data-zfb-island="MobileMenu" data-props="{}" data-when="media" data-media="(max-width: 768px)">…</div>

少なくとも 1 つの島を含むビルドには、1 つの <script> タグ がプロジェクト全体で <head> に注入されます。

<script type="module" src="/assets/islands-<hash>.js"></script>

共有 islands バンドル(islands-<hash>.js)はすべての島コンポーネントを含みます。スキャナが発見した静的なマーカー名(data-zfb-island 属性の値)で各島を登録し、その後 mountIslands() を呼び出します。これはページ上のすべての [data-zfb-island] 要素を走査し、シリアライズされた data-props を読み取り、既存のサーバーレンダリング済み DOM に対して hydrate() を呼び出します。

共有バンドルモデルの帰結

すべての島が単一のファイルにまとめてバンドルされるため、次のことが言えます。

  • いずれかの島を持つすべてのページが、全島のコードを読み込みます。 単一の islands-<hash>.js バンドルには、プロジェクト内のすべての島コンポーネントが含まれます。ThemeToggle しか使わないページでも、CounterSearchBox のコードをダウンロードします。これは、ページ単位の読み込み粒度を、よりシンプルなビルドパイプラインとページをまたいだ優れたキャッシュ効率と引き換えにするものです。

  • 島のないページには islands スクリプトが入りません。 ページに島マーカーが含まれない場合、ビルドパイプラインはそのページへの共有 islands <script> 注入をスキップします。使われている場合、他の client-script 仕組みは引き続き JavaScript を追加できます。

  • いずれかの島の変更が、単一のバンドルを再ハッシュします。 いずれかの島コンポーネントを追加または変更すると新しいコンテンツハッシュが生成され、それがすべてのページについてブラウザのキャッシュされたバンドルを無効化します。トレードオフは、1 つのキャッシュエントリがすべての島をカバーすることです。バンドルが一度キャッシュされれば、プロジェクト内のすべてのページが同じキャッシュヒットの恩恵を受けます。

フレームワークの選択

zfb は島について 2 つのフレームワークをサポートしています。

Config valueRuntime
"preact"(デフォルト)Preact + preact/jsx-runtime
"react"React 18 + react-dom/client

zfb.config.ts で一度だけ設定します。

export default {
  framework: "preact", // または "react"
};

これは プロジェクト全体の設定 です。プロジェクトごとに 1 つのフレームワークです。バンドラ(crates/zfb-islandsFrameworkKind enum)が、その選択を JSX 変換オプション(--jsx-import-source)と共有バンドルに埋め込まれるフレームワーク固有のハイドレーション接着コードに通します。同じプロジェクト内で Preact の島と React の島を混在させることはできません。

zfb は Vue・Svelte・Solid をサポートしていません。FrameworkKind enum は意図的に 2 バリアントの enum であり、プラグインポイントではありません。別のフレームワークが必要な場合は、以下の抜け道が一般的なケースをカバーします。

2 つのアダプタがどう動作するかの詳細は Framework adapters を参照してください。

ブラウザバンドルのモードと設定

island のコードは、クライアントスクリプト、module worker、ページ / SSR バンドルと同じコンパイル時モード値を使います。

コマンドimport.meta.env.DEVimport.meta.env.PRODprocess.env.NODE_ENV
zfb devtruefalse"development"
zfb buildfalsetrue"production"

モードは minify の有無から独立しています。追加の bundle.loadersbundle.define の設定も、islands とそこから起動する worker で共有されます。define の値は公開コードの生の置換式なので、secret を含めてはいけません。

TypeScript はこれらのバンドルコントラクトを推論しません。island のコードでモード値、カスタム loader の import、raw import、define 名を zfb checktsc に通すには、対応する ambient 宣言を追加してください。

?raw でテキストをインポートする

island、またはその import グラフにある first-party モジュールは、厳密に 1 つの形式で任意のプロジェクトローカルファイルをテキストとして読み込めます。

"use client";

import shaderSource from "./shaders/noise.frag?raw";

ターゲットの拡張子は問いません。bundle.loaders が結果を変えることもありません。ターゲットは妥当な UTF-8 として読み込まれ、文字列として default export され、終端の依存として扱われます。つまり .js?raw ファイルはコードではなくテキストです。サポートされるのは、リテラルの相対 specifier と厳密な ?raw サフィックスを持つ静的な default import だけです。named import、namespace import、side-effect import、type-only import、dynamic import、re-export、その他のクエリ、追加パラメータ、リテラルでないパス、プロジェクトルート外のターゲットは、サポートされる形式を示すメッセージとともに失敗します。

zfb dev 中、island とクライアントスクリプトのグラフにある raw ターゲットは、それぞれのライブ監視セットへ追加されます。そのため import 元が変わっていなくても、テキストファイルの編集・削除・再作成によってブラウザバンドルが invalidation されます。同じテキスト変換はページ / SSR パスでも動作し、raw edge はルートの依存追跡にも加わりますが、それによって新しいファイルシステム監視ルートが追加されるわけではありません。デフォルトの監視ルート外(たとえば lib/ 配下)にある SSR 専用の raw ターゲットは、絶対パスの extraWatchPathsでカバーする必要があります。そうしなければ、編集しても dev のファイルシステムイベントは発生しません。

Module worker

esbuild を実行する前に zfb が探索できる、リテラルな module-worker constructor を使います。

"use client";

const searchWorker = new Worker(
  new URL("./workers/search.worker.ts", import.meta.url),
  { type: "module" },
);

最初の URL 引数は、クエリや fragment のない、厳密なプロジェクトローカル相対パスの JS / TS ファイルを指す文字列リテラルでなければならず、worker options は type: "module" を選ぶ必要があります。zfb は worker ソースをサーバーグラフへ入れず、各 worker を自己完結したブラウザエントリとしてバンドルし、URL を次の形へ書き換えます。

./worker-<encoded-project-relative-path>.js?v=<graph-hash>

island が所有する worker は /assets/worker-*.js 配下へフラットに出力されます(base を設定していればそのプレフィックス付き)。安定ファイル名は、完全なプロジェクト相対ソースパスを可逆にエンコードしたものです。区切りは -s-、ドットは -d-、リテラルのハイフンは -h-、その他のバイトは -xHH- になります。たとえば src/search/index.worker.tsworker-src-s-search-s-index-d-worker-d-ts.js になります。?v= クエリは厳密に 8 文字の小文字 16 進数で、推移的な import、ネストした worker、raw ファイル、関連する TypeScript 設定を含む first-party グラフ、または出力に影響するバンドル / resolver 入力が変わると更新されます。dev はその closure を監視し、worker edge がなくなると古い companion を削除します。

予測可能なプレフィックスは、worker ごとの response policy のアンカーにできます。たとえば Cloudflare 形式の public/_headers ファイルは次の設定から始められます。

/assets/worker-*.js
  Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'

ディレクティブは worker が実際にアクセスする対象に合わせて調整してください。パス形式の base_headers のルートにも付けます。たとえば base: "/docs/" なら /docs/assets/worker-*.js が必要です。一方、https://cdn.example.com/static/ のような絶対 URL の base では、そのアセットオリジン側に同等の /static/assets/worker-*.js ヘッダールールが必要です。サイト側の _headers ファイルから別オリジンを設定することはできません。クライアントスクリプトが所有する worker は、別の /assets/client/worker-*.js ディレクトリに置かれるため、同じ base / オリジンの方針に従った対応ルールが必要です。

前処理パスはインストール済みの node_modules をたどりません。third-party の推移的な worker constructor はパッケージ側のツールに委ねられます。同じリテラルな new URL(..., import.meta.url) 形式を使う SharedWorker は、名前付きのハードエラーになります。それ以外のリテラルでない、または module でない constructor 形式はこのコントラクトの対象外で、書き換えられることを期待すべきではありません。

import.meta.glob のサポート

zfb は Vite の import.meta.glob(...) マクロをサポートしています。パターンにマッチするすべてのファイルを 1 回の呼び出しでインポートするもので、サブコンポーネントやデータファイルのディレクトリを自動登録するのに便利です。サポート範囲は意図的に狭く保たれています。島をバンドルする esbuild はこの Vite 専用マクロをネイティブには理解しないため、zfb はビルド時に、esbuild がファイルを目にする前に Rust 側で展開します。展開が理解できる唯一の形式だけが受け付けられ、それ以外はサイレントなミスコンパイルではなくビルドエラーになります。

サポートされる形式

"use client";

// Eager + string-literal pattern, anchored at this file's own directory.
const pages = import.meta.glob("./items/*.tsx", { eager: true });

これはビルド時に、マッチした各相対パスをそのモジュールの名前空間オブジェクトへマッピングする素のオブジェクトリテラルへと展開されます。Vite が生成するものと同じ形です。

{
  "./items/a.tsx": /* module namespace */,
  "./items/b.tsx": /* module namespace */,
}

サポートされる形式の要件は次のとおりです。

  • 第 2 引数は 厳密に { eager: true } でなければなりません。Vite のデフォルト(第 2 引数なし)は lazy な形式であり、ここではサポートされません。

  • パターンは 文字列リテラル でなければなりません。変数、テンプレートリテラル、パターンの配列はサポートされません。

  • パターンは インポート元ファイル自身のディレクトリ配下 に解決されなければなりません。../ でディレクトリを抜け出すパターンは即座に拒否されます。

  • importqueryas オプションは使えません。 認識されるのは素の { eager: true } オブジェクトだけで、それ以外のキーがあるとビルドが失敗します。

どこで動作するか

場所サポート
サーバー専用のページ・レイアウト・コンポーネント("use client" 島から到達不能なもの)あり — 汎用の SSR シャドウコピーバンドラ経由で、以前から常にサポート
"use client" 島自身のファイルあり(issue #1404 以降)
"use client" 島から推移的にインポートされるモジュールあり(#1404 以降、上記と同じ制約)。島の import グラフに含まれ、展開済みの実体あるシャドウコピーファイルとして書き出される場合
別の島到達可能な glob にマッチしたことでだけ到達するファイル(raw-mirrored な glob ターゲット、またはサブツリー内の companion ファイル)なし — 後述の「サポートされない形式」を参照
*.client.{ts,tsx,js,jsx} クライアントスクリプト(およびそれがインポートするもの)なし — 後述の「クライアントスクリプト」を参照

issue #1404 以前は、島から到達可能な あらゆる import.meta.glob(サポートされる eager + 文字列リテラル形式であっても)が、ハードなビルドエラーでした(issue #1387 での応急処置)。現在の islands ビルドは、サポートされる各 glob 呼び出しをあらかじめ展開した状態で、島から到達可能なソースツリーの一時的なシャドウコピーを実体化します。これは SSR バンドラが issue #665/#670 以降、サーバー専用モジュールに対して使ってきたのと同じ手口です。

重要な境界が 1 つあります。シャドウは、島から到達可能な各 glob モジュールのディレクトリ配下も raw-mirror し、glob にマッチしたターゲットファイルを esbuild が読めるようにします。しかしそれらの raw-mirrored なターゲット/サブツリー内ファイルは再帰的には展開されません。その中にさらに import.meta.glob(...) がある場合、zfb は未展開の Vite 専用マクロをブラウザへ配信する代わりに拒否します。同じファイルが通常の import によって島のグラフにも含まれているなら、そのファイルは展開済みの実体あるシャドウコピーになり、サポートされる glob は引き続き動作します。

サポートされない形式はビルドを失敗させる(dev では警告してスキップ)

以下のいずれかは、問題のファイル名を示すメッセージとともに zfb build を失敗させます。zfb dev 中は同じメッセージが警告としてログ出力され、そのリバンドルのティックはスキップされるため、ファイルを直して再保存する間も dev サーバーは動き続けます。

  • デフォルトの lazy 形式 — オプションなしの import.meta.glob("./items/*.tsx")

  • { eager: false }

  • リテラルでないパターン(変数、テンプレートリテラル、計算された式)

  • importqueryas オプション

  • ファイル自身のディレクトリを抜け出すパターン(../

  • プロジェクトルート外や node_modules 配下に存在しながら、なお島から到達可能な glob モジュール(シャドウコピーへミラーする対象が存在しない)

  • raw-mirrored な glob ターゲット、またはサブツリー内の companion ファイルとしてだけ到達する JS 風のファイル内にある import.meta.glob(...) 呼び出し

raw-mirrored ファイルのチェックは意図的に保守的です。glob されたサブツリー内の未使用の JS 風 sibling(.js.jsx.ts.tsx.mjs.cjs.mts.cts)であっても、実際の glob 呼び出しを含んでいれば検出されます。シャドウ側では、その raw ファイルを esbuild があとで読むかどうかを証明できないためです。glob を島から到達可能なモジュールへ hoist するか、glob されたサブツリーの外へ移動するか、明示的な静的インポートへ置き換えてください。

クライアントスクリプト: サポートされず、ビルド時にも検出されない

import.meta.globクライアントスクリプト*.client.{ts,tsx,js,jsx}、およびそれらがインポートする任意のモジュール)では サポートされません?raw、module worker、プラグインの前処理が必要なグラフは一時的なミラーを使う場合がありますが、その段階は意図的に glob を展開しません。そのため呼び出しは意味上未展開のままブラウザへ配信されます。上記の島のケースとは異なり、これはビルド時には検出されませんzfb build は成功し、スクリプトが実際に実行されるときにブラウザが例外を投げます。import.meta.glob は Vite の外では undefined だからです。クライアントスクリプト内で glob のようなファイル一覧が必要な場合は、以下の代替手段のいずれかを使ってください。

代替手段

  • プラグインの仮想モジュール — プラグインの setup フック内で(node:fs や glob ライブラリなどを使って)Node 側で自分でファイル一覧を計算し、addVirtualModule 経由で公開します。フックの完全な契約と実際に動作する仮想モジュールの例は Plugins を参照してください。

  • 明示的な静的インポートimport 文を手で書き出すか、あるいは小さなプリビルドスクリプトで実際の .ts/.tsx ファイルを生成し、それを zfb が通常どおりバンドルします。

島ではないクライアント JS のための抜け道

島はステートフルな UI コンポーネントをカバーします。それ以外のクライアントサイド JavaScript のニーズには、標準的な HTML の仕組みを直接使ってください。

インラインスクリプト — ページの TSX やレイアウトに直接 <script> タグを書きます。

export default function Layout({ children }) {
  return (
    <html>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `document.documentElement.dataset.theme = localStorage.getItem('theme') ?? 'light';`,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

これは、スタイルシートのパース前に実行されなければならない同期的なハイドレーション前の処理(FOUC 防止、テーマ初期化、アナリティクスのセットアップ)に適したツールです。

外部スクリプトpublic/ または CDN の任意の .js ファイルを参照します。

<script src="/scripts/analytics.js" defer />
<script src="https://cdn.example.com/lib.js" defer />

クライアントスクリプトまたはカスタムビルドステップ — zfb は 2 つ目のブラウザエントリパイプラインとして *.client.* エントリを自動的にバンドルします。それ以外の TypeScript モジュールには、別の esbuild または Rollup ステップを追加し、その出力を <script src> から参照してください。"use client" グラフまたは .client.* エントリから到達できないモジュールは、サーバー専用のままです。

できないこと: 通常の("use client" でない).ts.tsx モジュールをページからインポートして、そのブラウザ側のコードがクライアントに届くことを期待すること。ディレクティブを持たないモジュールはサーバー専用です。SWC がビルド時にコンパイルして評価し、そのバイトは出力に一切含まれません。

島を使わないほうがよいとき

島は JavaScript を配信します。それにはコストがあります。追加する前に、それなしで問題を解決できないか自問してください。

DOM のクラス入れ替えトグル(アコーディオン、ディスクロージャーメニュー、表示・非表示パネル)は、数行のプレーンな CSS や小さなインラインの <script> で解決できることがよくあります。ネイティブの <details> / <summary> 要素は JavaScript なしでアコーディオンの挙動を扱います。

<details>
  <summary>Frequently asked question</summary>
  <p>The answer goes here.</p>
</details>

CSS のみのアプローチ(:target:checked + <label>@starting-style)は、かつて JavaScript を必要とした多くのインタラクティブなパターンを扱えます。

島が適したツールとなるのは次の場合です。

  • コンポーネントが、単一のインタラクションを超えて存続しなければならない状態を持つ場合(例: カート、ユーザーセッション、複数ステップのフォーム)。

  • コンポーネントがビルド時には利用できないブラウザ API に依存する場合(canvasWebGLgetUserMedia・リアルタイムデータ)。

  • そうでなければコンポーネントを 2 回(サーバーレンダリング用に 1 回、クライアント用に 1 回)書いて手動で同期し続けることになる場合。

正直な答えが「クリックでクラスを 1 つ切り替えたいだけ」なら、まず CSS か小さなインラインスクリプトに手を伸ばしてください。「2 回書くことになるステートフルな UI」に島を、というのが正しい判断基準です。

JSX ラッパーとハイドレーションオプションについてさらに詳しくは <Island> を参照してください。パイプラインの形についてさらに詳しくは Build pipelineBuild engine を参照してください。

Revision History

作成更新