Astro IconでのぞいたIslands Architectureの深淵
はじめに
Astroには沢山の便利なインテグレーションが存在しますが、その中で比較的使われる事が多いと思われる astro-icon
と @astrojs/react
でハマった点があったので備忘録です。
補足
この検証を行っている時点での各ライブラリのバージョンは以下になります。
- astro: 4.16.5
- astro-icon: 1.1.1
- @astrojs/react: 3.6.2
astro-icon
astro-iconは、例えば新規ウィンドウで開くアイコンやアローといったページ内で使い回されるsvgの使用とコード出力を効率化してくれるインテグレーションです。
事前に /src/icons
にsvgを格納し、ライブラリが用意してくれている Icon
コンポーネントのname属性にファイル名を渡してあげることでsvgがレンダリングされます。
その際、 /src/icons
に格納されているオリジナルのsvgが以下のようなコードになっていた場合、
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
<path fill="#333" d="M16 10 5.5 16.062V3.938L16 10Z"/>
</svg>
Icon
コンポーネントは初回のレンダリング時のみ
<svg width="1em" height="1em" viewBox="0 0 20 20" data-astro-cid-j7pv25f6="" data-icon="triangle">
<symbol id="ai:local:triangle">
<path fill="#333" d="M16 10 5.5 16.062V3.938z"></path>
</symbol>
<use xlink:href="#ai:local:triangle"></use>
</svg>
このように元のコードを symbol
タグでラップした物+表示する用の use
タグで出力し、2回目以降の呼び出しでは
<svg width="1em" height="1em" viewBox="0 0 20 20" data-astro-cid-j7pv25f6="" data-icon="triangle">
<use xlink:href="#ai:local:triangle"></use>
</svg>
1回目の呼び出し時に生成した symbol
タグを参照する use
タグのみ出力してくれるといった具合です。
@astrojs/react
AstroはIslands Architectureを採用しており、ページ内で使用するコンポーネントをReact、Vue、Svelteといった他のUIフレームワークで書くことが可能です。
Astroは基本的にビルド時にすべてのHTMLをレンダリングし、クライアントサイドでの複雑なインタラクションやデータフェッチ等を行う必要があるコンポーネントのみアイランドとしてハイドレーションを行うことで高いパフォーマンスを実現します。
@astrojs/react
は名前の通りプロジェクト内でReactコンポーネントを採用する際に必要な公式インテグレーションです。
何が起こったのか
astro-icon
で表示していた何個かのsvgのうち、一つがページ内ですべて表示されなくなりました。
調べてみるとsvg
タグとその中の use
タグは出力されているのですが、初回呼び出し時に出力されるはずの symbol
タグがページのどこにも出力されていませんでした。参照すべき symbol
が存在しないので表示されないのは当然ですね。
astro-icon
の Icon
コンポーネントのコード見てみると、
Icon
コンポーネントが呼び出された際、呼び出したsvgのファイル名をキャッシュに格納する事を試み、その結果次第で includeSymbol
というフラグ変数をコントロールし、true(初回のレンダリング)であれば symbol
タグも出力する、という比較的シンプルな構造になっていたため尚更不思議でした。
原因は何だったのか
端的に言うと以下の2点が発生条件でした。
- Reactコンポーネントにslotを使用し
astro-icon
のIcon
コンポーネントを渡す - Reactコンポーネント内で、渡された
Icon
コンポーネントを最終的にレンダリングしていなかった
Astroでは、Reactで言うchildrenのようにスロットという方法でコンポーネントに子要素を渡すことができます。
例えば
<div class="sampleComponent">
<slot name="icon"/>
<slot name="text"/>
</div>
上記のようなコンポーネントを以下のように呼び出すことで
<SampleComponent>
<Icon name="triangle" slot="icon"/>
<p slot="text">テキスト</p>
</SampleComponent>
最終的にこのようにレンダリングされます。
<div class="sampleComponent">
<svg width="1em" height="1em" viewBox="0 0 20 20" slot="icon" data-astro-cid-j7pv25f6 data-icon="triangle">
<use xlink:href="#ai:local:triangle"></use>
</svg>
<p data-astro-cid-j7pv25f6>テキスト</p>
</div>
Reactコンポーネントにも同様に、slotでコンポーネントを渡すことができます。
export function ReactComponent({ icon }: { icon?: React.ReactNode }) {
return <div className="reactComponent">{!!icon && <>{icon}</>}</div>;
}
<ReactComponent>
<Icon name="triangle" slot="icon"/>
</ReactComponent>
このReactコンポーネントが何らかの条件でsvgの表示/非表示をコントロールしていて、 Icon
コンポーネントを受け取っているにも関わらずレンダリングしなかった場合、 symbol
が出力されなくなりました。
極端に分かりやすく簡略化すると以下のようなケースです。
export function ReactComponent({ icon }: { icon?: React.ReactNode }) {
const isShowIcon = false;
return (
<div className="reactComponent">{!!icon && isShowIcon && <>{icon}</>}</div>
);
}
考察・検証
astro-icon
は、該当セクションで説明した通り「nameで指定した各svgファイルの、それぞれ初回呼び出し時に symbol
タグを出力する」動作になっており、つまり 「Reactコンポーネント内では、最終的にレンダリングされなかっただけでコンポーネントの実体化は行われ、そこで初回呼び出し時の処理(=symbolタグの出力)が完了した事になった」 という状況に見えます。
関連ドキュメントを確認してみます。
childrenにslot名で個別のコンポーネントを渡す事自体は想定している挙動ですが、
@astrojs/reactの"子要素のパース"セクションに
AstroコンポーネントからReactコンポーネントへ渡される子要素は、Reactノードではなくプレーンな文字列としてパースされます。
とあり、基本的にコンポーネントを渡す事はできるがその時点でHTML文字列化されるようです。astro.configで experimentalReactChildren
フラグを使うとvnodeとして伝わるとのことですが、 experimentalReactChildren: true
にしても結果は変わりませんでした。
experimentalReactChildren
フラグで内部的にどう処理が変わるのかは気になる所ですが、なんとなく 「Reactコンポーネントに渡された時点で Icon
コンポーネントが評価されプレーン文字列になってしまう→この時点で Icon
コンポーネントの内部的には初回レンダリングと判断され、 symbol
タグが出力された文字列を返す→Reactコンポーネント側でそれをレンダリングしなかった」 ことで起きた事であると理解できました。
これらの情報を踏まえて確認・検証をした項目が以下になります。
Reactコンポーネントではなく通常のAstroコンポーネントで同じ事をした場合は?
発生しませんでした。
基本的にAstroコンポーネントであれば、そのコンポーネント内でどんな条件分岐があろうともslotで渡されたコンポーネントがビルド時にレンダリングされるかどうかはAstroフレームワーク側で明確になる=最終的に不必要である事が推察できる場合、渡されたコンポーネントは実体化されない?
Reactコンポーネントを書く位置によって結果が変わるか?
Reactコンポーネントを置く位置より前に、slot等を介さず普通に Icon
コンポーネントを使用すればそこで symbol
タグが出力されると思ったのですが、そうはなりませんでした。Import順も同様に、影響なし。
ページ内で出現する順序に関係なく、アイランドコンポーネント部分は優先的に処理されるのかもしれません。
<Layout>
<Icon name="triangle"/><!-- 先にこちらでsymbolが出力されそうだが、 -->
<ReactComponent>
<!-- 後に書いたこちらにsymbolが出力される -->
<Icon name="triangle" slot="icon"/>
</ReactComponent>
</Layout>
まとめ
原因が分かったため何とか対応できたのですが、結局 ページのどの位置に書かれていようが、Reactコンポーネントにslotで Icon
コンポーネントが渡された場所で symbol
タグが出力されてしまう(ため、 Icon
コンポーネントをレンダリングしなければページ全体でそのsvgは表示されない) 事は回避できておらず、どういったロジックでそうなってしまうかは明確になってないのでモヤりが残る形になってしまいました。
また、ReactコンポーネントではなくVue,Svelteの場合同様の現象が起きるのか、といった検証はできていません。
AstroとIslands Architectureによる複数フレームワークの混在を初めて知った時、 結局最後はhtmlとjsになる と理屈では分かっていてもそれがどう実現されているのか不思議に思ったまま雰囲気でここまで来てしまっているので、折を見てしっかり理解してみようと思います。
Astroを採用している方は基本的に事前レンダリングで済むサイト構成を取っていると思うのでReactコンポーネントを積極的に使う方はそこまで多くないかもしれませんが、同じ問題に直面した方が居ましたら参考にしていただければ幸いです。
参考
Discussion