🚀

AstroコンポーネントとUIフレームワークのコンポーネント使い分け考察

2024/10/24に公開

Astro、最高

皆さんAstro使ってますか?AstroコンポーネントはほぼJSXでサクサク書けるし、コンテンツ管理はつよつよだし、Astro Islandで超簡単にパーシャルハイドレーションができるしで、現状誰にでもおすすめできるバランスに優れた静的サイトジェネレータと言っても良いと思います。

とはいえ業務で使うとなると実装で迷う箇所や、パフォーマンス向上などで不安点が出てくると思います。そんな方にもAstroを使ってみよう!と思ってもらえるように、実際に業務で1万ページ越えのサイトをAstroで作ってメンテナンスする中で溜まった知見をメモしていこうと思います。

UIフレームワークコンポーネントの自由さと難しさ

AstroではAstroコンポーネントの他に、ReactやSolid、SvelteなどのUIフレームワークのコンポーネントも使うことができます。UIフレームワークのコンポーネントは動的な動作を追加するために使われることが多いですが、UIフレームワークのコンポーネントも、ビルド時にはAstroコンポーネントと同様に静的なHTMLとしてレンダリングされます。

ということは極端にいうと、ファイルベースルーティング用のpageディレクトリ直下の .astro ファイル以外、すべてUIフレームワークのコンポーネントでサイトを構築することも可能なのです。

そうなるとどちらを使うべきか迷いますよね。私の場合、使い慣れたUIフレームワークの構文で静的サイトを作れるならもしかして便利かも?と思い、どちらの方がSSGでは好ましいか検討しました。

結果、極力Astroコンポーネントを使い、UIフレームワークは出来るだけ小さいサイズに納めるという結論に至りました。

※以下の考察ではAstroをSSGとして使い、UIフレームワークはSolidを使う想定で検討しています。他のUIフレームワークには当てはまらない場合もあるかもしれませんのでご了承ください。

Astroコンポーネントを出来るだけ使った方が良い理由

Astroコンポーネントは静的に書き出される

Astroコンポーネントはscriptタグを内包しない限り確実にHTMLとCSSの静的ファイルとしてレンダリングされ、出力ファイルにはそのコンポーネント由来のjavascriptは含まれません。

UIフレームワークのコンポーネントは親要素のAstroコンポーネント側の設定でハイドレーションされるかどうかが決まるため、ヒューマンエラーによって静的出力で良いはずのコンポーネントがハイドレーションされてしまう可能性が存在してしまいます。動的な挙動が必要ない場合、Astroコンポーネントを使うことでそのような可能性を排除し、静的なHTMLが生成される事を担保できます。

Astro Islandの制御を出来るだけ小さくできる

これが最も大きな理由です。UIフレームワークのコンポーネントがネストしている場合、子コンポーネントをハイドレーションするには親コンポーネントもハイドレーションする必要があり、またその挙動も細かく設定ができません。

例: ヘッダーに言語選択用の動的コンポーネントを設置したい場合…

  • index.astro > Header.astro > LanguageSelector.tsxだと、LanguageSelector.tsxがIslandとなりハイドレーションされます。Header.astroはもちろん静的出力です。
index.astro(AstroHeaderバージョン)
---
import AstroHeader from "../components/AstroHeader.astro";
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Astro website</title>
  </head>
  <body>
    <AstroHeader />
    <p>Hello world</p>
  </body>
</html>
AstroHeader.astro
---
import {LanguageSelector} from "./LanguageSelector";
---

<header>
  <h1>Astro Website!</h1>
  <LanguageSelector client:load />
</header>
LanguageSelector.tsx
import {createSignal} from "solid-js";

export const LanguageSelector = () => {
  const [selectedLang, setSelectedLang] = createSignal<string>("ja");

  const handleLangSelect = (e: any) => {
    setSelectedLang(e.target.value);
  };

  return (
    <>
      <select onChange={handleLangSelect}>
        <option selected value="ja">日本語</option>
        <option value="en">English</option>
      </select>
      <p>Current Lang is: {selectedLang()}</p>
    </>
  );
};

  • index.astro > Header.tsx > LanguageSelector.tsxだと、LanguageSelector.tsxをハイドレーションするためにはHeader.tsx にもクライアントディテクティブを設定してハイドレーションする必要があります。結果、上記の例と同じ構造でもクライアント側でダウンロードされるjsファイルのサイズが増加します。
index.astro(SolidHeaderバージョン)
---
import {SolidHeader} from "../components/SolidHeader";
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Astro website</title>
  </head>
  <body>
    <SolidHeader client:load />  // ここからクライアントディレクティブが必要!
    <p>Hello world</p>
  </body>
</html>

SolidHeader.tsx
import {LanguageSelector} from "./LanguageSelector";

export const SolidHeader = () => {
  return (
    <header>
      <h1>Astro Website!</h1>
      <LanguageSelector /> // UIフレームワークからクライアントディレクティブは設定できない!
    </header>
  );
};

LanguageSelector.tsxからハイドレーションした場合

LanguageSelector.tsxからハイドレーションした場合

SolidHeader.tsxからハイドレーションした場合

SolidHeader.tsxからハイドレーションした場合

また、AstroコンポーネントからはIslandの用途に合わせてローディング方法をコントロールできる点も重要です。

公式ドキュメントでも説明されている通り、重たいコンポーネントはクライアントディテクティブを調整することで無駄な読み込みを避け、パフォーマンスの向上を期待することができます。例えば、モーダルウィンドウやタブ表示など、ユーザーが意図的にクリックして表示させる必要がある動的コンポーネントにclient:visibleを設定することで、表示されるまで読み込ませないなど制御する事が可能です。

静的でもUIフレームワークのコンポーネントを使うべき場面

とはいえ、シチュエーションによっては静的なコンポーネントをUIフレームワークのコンポーネントで書かねばなりません。主にAstroコンポーネントからもUIフレームワークのコンポーネントからも使う場合です。例えば、 <a> タグを拡張した <Link> コンポーネントのような、汎用的なコンポーネントが挙げられると思います。これはUIフレームワークからAstroコンポーネントは使えないという制約上、UIフレームワークのコンポーネントで作る必要があります。

UIフレームワークで静的コンポーネントを作る

このようなコンポーネントで動的に動作する必要ない場合、Solidだと<NoHydration>タグで囲うことでハイドレーションさせないように設定できます。しかし<NoHydration>で囲われた箇所以外のjavascript、例えばonMount()などは実行されてしまう点には注意が必要です。あくまで<NoHydration> 以下のハイドレーションを止めるのみです。

Link.tsx
import {splitProps} from "solid-js";
import {NoHydration} from "solid-js/web";

export const Link = (props) => {
  const [, attributes] = splitProps(props, ["text"]);

  return (
    <NoHydration>
      <a {...attributes}>{props.text}</a>
    </NoHydration>
  );
};

またastro:assetsなどのAstroのビルド時専用APIも、ハイドレーションされないようにコントロールすることで使用することが可能です。とはいえ、このようなビルド時専用APIを含むUIフレームワークコンポーネントの挙動は使用側の制御に委ねられるため、できるだけ避けた方がよいです。万一クライアント側で astro:assets のようなビルド時専用のAPIが読み込まれた場合、ビルドは成功してもWEBアプリケーションは動かない事態につながる可能性があります。

場合によってはUIフレームワークのコンポーネントに、childrenとしてAstroコンポーネントを渡すことでAstroのAPI使用の問題を回避できるかもしれません。この場合、Astroコンポーネントから生成された静的HTMLがchildrenとして渡されるため、ビルド時専用のAPIが誤ってクライアント側で呼び出されてしまう危険性を排除できます。ただこの場合、childrenとして渡すAstroコンポーネントと高結合になってしまう可能性が残るため依然取り回しは難しいです。

index.astro
---
import {Image} from "astro:assets";
import myImage from "../assets/myImage.jpg";
import {ImageWrapper} from "../components/ImageWrapper";
---

<ImageWrapper imageName="My Image" client:load>
  <Image src={myImage} alt="my image" />
</ImageWrapper>
ImageWrapper.tsx
type ImageWrapperProps = {
  imageName: string;
  children: any;
};

export const ImageWrapper = (props: ImageWrapperProps) => {
  return (
    <>
      <p>This is {props.imageName}</p>
      {props.children}
    </>
  );
};

おわりに

まだまだAstro初学者のため、もし不備や見落とし、不足点があればぜひコメントでご指摘いただけると嬉しいです。また他のUIフレームワークではこうだよ!という点があればぜひ教えてください。

参考

Discussion