本文へ移動
myowchick

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,3907
/tools/category/convert/10,6610
/tools/category/datetime/13,4010
/tools/category/dev/17,4240
/tools/category/file/12,3140
/tools/category/life/9,9040
/tools/category/random/11,8300
/tools/category/text/10,5740

一覧の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 = {&quot;tools&quot;:[1,[[0,{&quot;slug&quot;:[0,&quot;encoding-converter&quot;]...
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あり差
convert10,66116,319+5,658
datetime13,40119,993+6,592
dev17,42425,195+7,771
file12,31418,347+6,033
life9,90415,358+5,454
random11,83018,125+6,295
text10,57416,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は出て、初期状態のまま固まる。

増えるほうも忘れるほうも静かなので、島を置いたつもりの場所と、置いていないつもりの場所は、ビルド後に一度数えておくといいと思う。

← 一覧へ戻る