🚀

Astroの<slot>をJSXのchildrenのようにイテレーションする方法

2023/04/26に公開

背景

Astroはコーディング環境としてとても便利で最近のマイブームです。
ただ、下記のJSXのように、子要素をラップしてから出力するようなことは直接的にはできないことが気になってしまいます。

function Main() {
  return (
    <MyComponent>
      {items}
    </MyComponent>
  )
}
function MyComponent({children}) {
  return (
    <ul>
      {children.map(child => (
        <li class="my-comp">{child}</li>
      ))}
    </ul>
  )
}

同じような出力得る場合、Astroでは、下記のように呼び出し側でラッパーも実装する必要があります。

---
import MyComponent from './MyComponent.astro'
---
<MyComponent>
  {items.map(item => <li class="my-comp">{item}</li> )}
</MyComponent>
---
// MyComponent
---
<ul>
  <slot>
</ul>

これではコンポーネントの責任範囲が曖昧になってしまいます。
上記の例で、(どういう状況か分かりませんが)仮に<MyComponent>のタグが<ul>から<table>に変わったとします。
そのときに、子要素側も<li>から<tr><td>などに変更される必要がありますが、Astroだと<MyComponent>の呼び出し側から変更してもらう必要があります。1つならまだしも、これが2つもあれば面倒なうえに修正漏れの可能性も増えるでしょう。

というわけで、JSXで慣れているように、<slot>をループで処理してラッパーを被せてみました。

概要

Astro.slots.render() で得られたHTML文字列をnode-html-parserでparseして、mapで処理する。

node-html-parserのインストール

yarn add -D node-html-parser

<slot> をパースするように変更

---
// MyComponent
import { parse } from 'node-html-parser'
const html = await Astro.slots.render('default')
const slots = parse(html.replaceAll(/\n/g, ''))
---
<ul>
  { slots.childNodes.map(slot =>
    <li class="my-comp">
      <Fragment set:html={slot.toString()} />
    </li>
  ) }
</ul>
---
// Main
import MyComponent from './MyComponent.astro'
---
<MyComponent>
  {items.map(item => <div>{item}</div> )}
</MyComponent>

パースしたHTMLはtoString()で取得できます。
しかし、そのまま出力してもエスケープされてしまうため、set:htmlを使ってエスケープされないようにしています。なので、AstroをSSRで使っていて、ユーザーの入力を受け付けるような場合は事前にサニタイズを施すか、このような方法は避けた方がよいでしょう。
今回はchildNodesのみを対象としていますが、要素が一つだけだった場合にうまくいかないかもしれません。(試してません)

おまけ

仮に、<slot>の内容がテキストノードではないことが分かっている(テキストを許容しない)場合は、以下のように判定を付けることができます。

---
import { parse, NodeType } from 'node-html-parser'
const html = await Astro.slots.render('default')
const slots = parse(html.replaceAll(/\n/g, ''))
---
(中略)
  { slots.childNodes.map(slot =>
    slot.nodeType === NodeType.ELEMENT_NODE
      ? <li class="my-comp">
          <Fragment set:html={slot.toString()} />
        </li>
      : ''
  ) }

パースしたノードのタイプがNodeType.ELEMENT_NODEであるかを判定することで、テキストやコメントのノードを弾くことができます。

Discussion