client:*を書かないReactは217KiBを連れてこない
このサイトのツール一覧とカテゴリページは、カードの格子に同じReactコンポーネントを使っている。
ファイルは1本、JSXも1本。それでもビルド結果は別物になる。
一覧のほうはastro-islandに包まれて237KBのJSを引き連れてくるのに、カテゴリのほうはastro-islandという文字列すらHTMLに出てこない。
違いはclient:loadと書いてあるかどうかだけ。distを数えると、その1語がどこに効いているのかが全部出てくる。
同じ部品を2か所から使っている
カードの格子はsrc/components/react/ToolCardGrid.tsxにある。propsを受けてリンクの並びを返すだけで、hooksは1つも使っていない。
export function ToolCardGrid({ tools }: { tools: ToolCardData[] }) {
return (
<div className="grid gap-4 sm:grid-cols-2">
{tools.map((tool) => (
<a key={tool.slug} href={`/tools/${tool.slug}`} className="...">
<h2 className="...">{tool.name}</h2>
{/* タグと説明 */}
</a>
))}
</div>
)
}
呼び出し側は2か所ある。
ツール一覧(src/pages/tools/index.astro)は、検索欄とカテゴリボタンを持つToolsExplorerを島として置く。格子はその中から呼ばれる。
<ToolsExplorer client:load tools={cards} categories={categories} />
カテゴリページ(src/pages/tools/category/[category].astro)は、絞り込みの操作を持たないので格子だけを直接置く。
{/* client ディレクティブを付けない = 静的な HTML として描かれ、JS は載らない */}
<ToolCardGrid tools={cards} />
Astroのコンポーネントは既定でビルド時に一度だけ実行され、その結果のHTMLだけが出力される。client:*はそこに「このHTMLをブラウザでもう一度Reactに握らせる」という指示を足すものなので、書かなければ足されない。
出力を数える
npm run buildのあとでdistを数えるとこうなる。
| ページ | HTMLのバイト数 | astro-islandの出現数 |
|---|---|---|
/tools/ | 54,390 | 7 |
/tools/category/convert/ | 10,661 | 0 |
/tools/category/datetime/ | 13,401 | 0 |
/tools/category/dev/ | 17,424 | 0 |
/tools/category/file/ | 12,314 | 0 |
/tools/category/life/ | 9,904 | 0 |
/tools/category/random/ | 11,830 | 0 |
/tools/category/text/ | 10,574 | 0 |
一覧の7は、開始タグ・終了タグと、島を起こすインラインスクリプトの中に出てくる分。島そのものは1つ。
カテゴリページのほうは0。要素が空で出るのではなく、文字列として存在しない。
$ grep -c 'astro-island' dist/tools/category/dev/index.html
0
外部スクリプトも増えない。カテゴリページの<script src>は、Google系の2本とサイト共通のヘッダの1本だけで、これは/tools/にも同じものが載っている。格子に由来するJSは1バイトも出ない。
HTMLのほうは1バイトも違わない
面白いのは、描かれたカードが完全に同じことだ。
/tools/と/tools/category/dev/から、JWTデコードのカードのマークアップを切り出して比べた。
island page card bytes: 1658
static page card bytes: 1658
identical: true
同じJSXが同じHTMLを吐いている。ホバーの色もフォーカスリングもTailwindのクラスとして焼き込まれているので、JSが無くても見た目は変わらない。カードはただのリンクなので、クリックもブラウザがやってくれる。
島にしたときに増えるのは、HTMLの中身ではなくHTMLの外側だ。
島が背負っているもの
/tools/のastro-island要素を開くと、属性はこうなっている。
component-url = /_astro/ToolsExplorer.<hash>.js
component-export = ToolsExplorer
renderer-url = /_astro/client.<hash>.js
props = {"tools":[1,[[0,{"slug":[0,"encoding-converter"]...
client = load
propsが7,529バイトある。エスケープを戻したJSONで6,445バイト。
中身は型タグ付きの入れ子で、[0, 値]が素の値、[1, 配列]が配列を表している。
[0,{"slug":[0,"encoding-converter"],"name":[0,"エンコーディング変換"],
"tags":[1,[[0,"テキスト"],[0,"変換"]]]}]
ブラウザ側のReactが同じ木を作り直すには元のpropsが要るので、ツール15本分のslug・名前・説明・タグ・検索用のキーワードが、描画済みのHTMLとは別にJSONとしてもう一度ページに載る。同じ文字列が2回来ているわけだ。
インラインスクリプトも増える。/tools/には5本、カテゴリページには3本あって、差の2本はclient:loadの受け口(130バイト)とastro-islandカスタム要素の定義(4,380バイト)だった。合わせて4,510バイト。
そして外部JS。component-urlとrenderer-urlからimportを辿って集めると、11ファイル・237,154バイト(231.6KiB)になる。ほとんどはreact-domの178,828バイトで、ToolsExplorer自身は2,469バイトしかない。
同じページで直接比べる
一覧とカテゴリでは載っているコンポーネントも件数も違うから、上の差はそのままでは比較にならない。
そこでカテゴリページのToolCardGridにclient:loadだけを足して、もう一度ビルドしてみた。データもマークアップも同じで、ディレクティブの有無だけが違う状態になる。
| カテゴリ | 静的 | client:loadあり | 差 |
|---|---|---|---|
| convert | 10,661 | 16,319 | +5,658 |
| datetime | 13,401 | 19,993 | +6,592 |
| dev | 17,424 | 25,195 | +7,771 |
| file | 12,314 | 18,347 | +6,033 |
| life | 9,904 | 15,358 | +5,454 |
| random | 11,830 | 18,125 | +6,295 |
| text | 10,574 | 16,295 | +5,721 |
HTMLは5〜8KB増える。内訳は島の開始タグ(devで2,528バイト、うちpropsが2,251バイト)とインラインスクリプトの4,510バイトで、だいたい説明がつく。
JSのほうは、同じ辿り方で9ファイル・222,879バイト(217.7KiB、gzipで69.9KiB)。うちToolCardGrid本体は871バイトだった。
カード内のマークアップは、この状態でもさっきと同じ1,658バイトのまま一致した。
つまりclient:loadを足して得られるものは何も無い。状態を持たないコンポーネントなので、水和が終わってもDOMは1文字も変わらない。217KiBを払って、リンクの格子をReactに握り直させるだけになる。
計測が済んだのでこの変更は戻した。
付け忘れても、ビルドは通る
逆向きも試した。ToolsExplorerからclient:loadを外してビルドする。
エラーも警告も出ない。ビルドは普通に成功して、/tools/は54,390バイトから38,188バイトに縮み、astro-islandは0になる。
ページを開くと、検索欄もカテゴリボタンも見た目どおりに出ている。押しても何も起きないだけだ。
HTMLにはビルド時の初期状態が焼き付いている。
aria-pressed values: true,false,false,false,false,false,false,false
ctx: "...<p role=\"status\" class=\"sr-only\">15<!-- -->本を表示している。</p>"
useState(null)の初期値で「すべて」が押された状態になり、shown.lengthが15のまま固定される。あとは何を押してもaria-pressedは動かない。
これがclient:*の一番いやなところだと思う。書き忘れは型でもlintでも捕まらないし、静的に描けてしまう分、見た目は正しいままになる。動かないことに気づけるのは実際に触ったときだけ。
このサイトでは、逆に静的なままにしておきたいToolCardGridが唯一のclient:*無しのReactコンポーネントなので、src/components/react/の中で例外がどれかは覚えていられる。ここが増えてきたら、テストかビルド後のgrepで縛ることになると思う。
同じJSXが同じHTMLを吐いていて、違うのはその外側だけだった。client:loadを1語書くと、propsのJSONとカスタム要素の定義とreact-domが付いてきて、状態を持たない格子でも217KiBになる。逆に書き忘れると、ビルドは通り、HTMLは出て、初期状態のまま固まる。
増えるほうも忘れるほうも静かなので、島を置いたつもりの場所と、置いていないつもりの場所は、ビルド後に一度数えておくといいと思う。