Astroのアイランドアーキテクチャ入門:なぜ配信JSが減るのか
このサイトを Gatsby から Astro に移したとき、判断材料はいくつかありましたが、その中でも大きかったのがアイランドアーキテクチャでした。Gatsby では、記事を読むだけのページでもフレームワークの JavaScript が一式ついてきます。表示するだけなら本来いらないものまで、毎回読み込ませていたわけです。
Astro は発想が逆で、デフォルトでは JavaScript を送りません。ページは静的 HTML として出力され、ブラウザで動かしたい部品だけを明示的に指定する。その指定された部品が「アイランド(島)」です。この記事では、アイランドの考え方と、指定に使う client ディレクティブの使い分けを整理します。移行全体の判断軸については、GatsbyからAstroへ移行した理由:判断軸の整理にまとめています。
デフォルトは「JSなし」が基本
Astro のコンポーネントは、ビルド時に HTML と CSS へ変換されます。これは Astro 製のコンポーネントだけでなく、React や Vue などのフレームワークコンポーネントでも同じです。フレームワークのコンポーネントを置いただけでは、ブラウザ側の JavaScript は自動的に取り除かれ、ただの静的 HTML として出力されます。
「動かしたい」と思ったときに付けるのが client ディレクティブです。これを付けたコンポーネントだけが、個別に JavaScript を読み込み、ブラウザ上でハイドレーション(静的 HTML に動きを後付けすること)されます。
---
import SearchBox from '../components/SearchBox.jsx';
import ArticleBody from '../components/ArticleBody.astro';
---
<!-- 静的HTMLとして出力される。JSは送られない -->
<ArticleBody />
<!-- このコンポーネントだけがアイランドになる -->
<SearchBox client:load />
ポイントは、JS を送るかどうかがページ単位ではなくコンポーネント単位で決まることです。ページの 9 割が静的なら、残り 1 割分だけ JS が増える。逆に言えば、ディレクティブを付けた分だけ確実に JS が増えるので、「とりあえず付ける」が一番やってはいけない運用になります。
アイランドという考え方
アイランドという言葉自体は Astro の発明ではなく、2019 年に Etsy の Katie Sylor-Miller が提唱し、Preact の作者 Jason Miller が広めたものです。「ページ全体を 1 つのアプリとして動かすのではなく、動的な部分だけを島のように切り分ける」という発想で、部分ハイドレーション(selective hydration)とも呼ばれます。
各アイランドは独立して読み込まれ、独立してハイドレーションします。ページ下部の重いカルーセルが、ヘッダーの軽いメニューの邪魔をすることはありません。優先度の低い島は後回しにでき、そもそも画面に現れなければ読み込まれない、という指定もできます。
もう 1 つの特徴は、React・Preact・Svelte・Vue・SolidJS といった複数のフレームワークを、同じページに混在させられることです。この点は後で判断の話として触れます。
補足:サーバーアイランドについて 動的な HTML をサーバー側で後から差し込む仕組みとして、
server:deferを使うサーバーアイランドもあります。おすすめ商品やログイン中ユーザーの情報など、リクエスト時に生成したい内容のためのもので、完全静的なこのブログでは使っていません。この記事で扱うのはクライアントアイランドだけです。
client ディレクティブの使い分け
選ぶときの軸はシンプルで、「読み手がその部品をいつ触るか」です。まず全体像を表で押さえます。
| ディレクティブ | ハイドレーションのタイミング | 優先度 | 向いているもの |
|---|---|---|---|
| (付けない) | しない(静的 HTML のまま) | — | 本文・一覧など、読むだけの部分 |
client:load | ページ読み込み直後 | 高 | 最初から見えていて、すぐ操作できる必要があるもの |
client:idle | 初期読み込みが終わり、ブラウザが暇になったとき | 中 | 少し遅れて効けばいいウィジェット |
client:visible | ビューポートに入ったとき | 低 | ページ下部のカルーセルなど、重くて後回しにできるもの |
client:media | メディアクエリに一致したとき | 低 | モバイル幅でだけ出すメニューなど、画面条件で要るもの |
client:only="react" | ページ読み込み直後(描画自体もブラウザ側) | — | サーバーで描画できない、ブラウザ API 前提のもの |
client:load は「最初から触れるもの」だけ
ページ表示直後にハイドレーションします。優先度は最も高い分、初期表示と競合します。ヘッダーの検索ボックスや、ファーストビューにある操作 UI のように「出てきた瞬間から押せるべきもの」専用です。ここを広げすぎると、結局 SPA と変わらなくなります。
client:idle は「急がないもの」
初期読み込みが終わってブラウザが暇になったタイミングで動きます。内部では requestIdleCallback が使われ、未対応のブラウザでは load イベントで代替されます。「今すぐでなくていいが、気づいたら使えるようになっていてほしい」部品向けです。Astro 4.15.0 からは timeout を指定して、「最悪でもこの時間までには動かす」という上限も付けられます。
<ShareButtons client:idle={{ timeout: 2000 }} />
client:visible は「見えたらでいいもの」
IntersectionObserver で画面内に入ったことを検知してから読み込みます。ページ下部のカルーセルや関連コンテンツのように、そもそもたどり着かない読み手も多い部品に向きます。Astro 4.1.0 からは rootMargin で「画面に入る少し手前から読み込み始める」指定ができ、スクロールした瞬間にまだ動かない、という間を減らせます。重い部品ほど client:load ではなくこちらが定位置です。
client:media は「条件を満たす画面だけ」
メディアクエリに一致したときだけ読み込みます。モバイル幅でだけ存在するハンバーガーメニューなどが典型で、デスクトップではその JS 自体が配信されません。「ある画面サイズでしか出ない UI」に client:load を付けると、不要な人全員に JS を配ることになります。
<MobileMenu client:media="(max-width: 50em)" />
client:only は「サーバーで描けないもの」の逃げ道
他のディレクティブと違い、サーバー側の HTML 生成自体をスキップし、ブラウザだけで描画します。フレームワーク名を文字列で渡す必要があり(client:only="react" のように)、読み込み中の代替表示は slot="fallback" で出せます。
注意:client:only は最後の手段です 初期 HTML に中身が入らないため、JS が読み込まれるまでその部分は空になります。本文や主要コンテンツに使うと、表示の安定性でも検索エンジンへの見え方でも不利です。「ブラウザ API に直接触る都合でサーバーレンダリングできない」といった理由があるときだけ選びます。
判断の手順
「どのディレクティブにするか」で迷ったら、上から順に潰していくと決めやすくなります。
- そもそもブラウザで動く必要があるか? なければ何も付けない
- ページ表示直後に操作されるか? Yes なら
client:load - 画面に入ってからで間に合うか? Yes なら
client:visible(重ければrootMarginを添える) - 画面サイズなど、条件を満たすときだけ必要か? Yes なら
client:media - どれでもないが、後で効けばいいものなら
client:idle - サーバーレンダリング自体が不可能な場合だけ
client:only
このブログでの付け方
このサイトは記事が主体なので、判断はかなり単純です。記事本文、記事一覧、プロフィールといった主要部分はすべて静的 HTML で、フレームワークの JavaScript は送っていません。
動的なのは、検索と目次まわりくらいです。検索は Pagefind を使っていて、ビルド時に生成したインデックスをブラウザ側で読みに行く方式なので、ページ全体をアプリ化する必要がありません。結果として、アイランド設計の恩恵は「凝ったことをしたから速い」ではなく、「動かす場所を数えたら、ほとんどなかった」という形で効いています。
ブログのような読み物サイトでは、これが普通だと思います。アイランドを増やす工夫より先に、「本当にブラウザで動かす必要があるか」を数える方が効きます。
フレームワークの混在は「できる」と「やるべき」が別
Astro では React と Vue のコンポーネントを同じページに置くこともできます。既存の資産を活かしたい場合や、使いたいライブラリが特定のフレームワーク向けしかない場合には助かる仕組みです。
ただ、フレームワークが増えるほど、それぞれのランタイムが配信 JS に乗ります。せっかくアイランドで削った分が、混在で戻ってきかねません。個人ブログの規模なら、フレームワークは 1 つに絞るのが無難、というのが自分の判断です。混在は「困ったときの選択肢」として残しておくくらいがちょうどいい。
やりがちな失敗
- 全部に
client:loadを付ける — SPA 系フレームワークの感覚で「とりあえず動く状態」にすると、ページ全体が JS まみれになってアイランドの意味が消えます。まず何も付けない状態から始め、操作できないものだけを昇格させるのが順序として正解です - ファーストビューの外にある重いものを
client:loadにする — カルーセルや埋め込みウィジェットは初期表示と競合します。client:visibleとrootMarginの組み合わせが定位置です - 理由なく
client:onlyを使う — サーバーレンダリングできない理由がないなら、初期 HTML が空になる損だけが残ります - Astro コンポーネントに
client:を付ける — ディレクティブが効くのはフレームワークコンポーネントだけです。Astro コンポーネントに付けるとエラーになります。ちょっとした動きなら、Astro コンポーネント内の<script>タグで書くのが公式の案内です - props に渡せないものを渡す — アイランドへ渡す props はシリアライズできるものだけです。関数やクラスインスタンスは渡らないので、島の境界をまたぐ設計にすると詰まります
公開前に見るチェック
-
client:loadが付いているのは、表示直後に操作されるものだけか - ページ下部の部品は
client:visibleになっているか -
client:onlyを使っている箇所に、サーバーレンダリングできない理由があるか - フレームワークが増えすぎていないか(混在させる理由があるか)
- ディレクティブなしで済む部品に、付けっぱなしのものがないか
配信 JS は「付けた場所」で決まる
Gatsby から Astro への移行で一番効いたのは、フレームワーク自体の速さというより、「JavaScript を送る場所を自分で数えられる」ことでした。配信される JS の量は、最終的にはどこにディレクティブを付けたかでほぼ決まります。だからこそ、付ける前の判断(「付けない」が第一候補)を丁寧にやる価値があります。