[Svelte] 動的にHTMLタグを切り替えたい（dynamic elements）

## はじめに

この記事は[Svelte Advent Calendar 2021](https://qiita.com/advent-calendar/2021/svelte) 9日目の記事です。

React に疲れてきて Svelte に移りたい今日この頃です。
Svelte いいですね。ローカル変数をいじくり回しているだけで状態変更やリアクティブな動作が発動するのは書いていて気持ちがいいです。
とはいえ、他と比べると後発ということもありライブラリ等はあまり充実おらず、ちょくちょく自作の必要があるのは大変だったりします。

そんな中、Svelte を触っていて動的に HTML タグを切り替えたいと思い、公式ドキュメントを見ていたものの記述がありませんでした。
コンポーネント自体を切り替える機能（[dynamic components](https://svelte.dev/tutorial/svelte-component)）はあるものの、HTML タグはコンポーネントとして用意されていないので、HTML タグの切り替えはできないことがわかりました。
これに関して、Issue は立てられているものの（[Proposal: dynamic elements &lt;svelte:element&gt;](https://github.com/sveltejs/svelte/issues/2324)）まだ実現していない状況です。

とはいえ、HTML タグをコンポーネント化するライブラリ（[svelte-elements](https://github.com/timhall/svelte-elements)）自体はありましたが最終更新日がかなり古く、使用するのは不安だったため自作することにしました。

## 忙しい人のためのコード全文

<details>
  <summary>コード</summary>
  <div>

```svelte:DynamicElement.svelte
<script lang="ts">
import { onMount } from "svelte";

export let as: string;
export let events: {
  [K in keyof GlobalEventHandlersEventMap]?: (
    e: GlobalEventHandlersEventMap[K],
  ) => void;
} = {};

let originalContainer: HTMLElement;
let container: HTMLElement;
let subscriptions: (() => void)[] = [];

onMount(() => {
  originalContainer = container;
  return () => {
    unsubscribe();
    container.parentNode?.replaceChild(originalContainer, container);
  };
});

$: originalContainer && swap(as);
$: originalContainer && $$props && setAttributes(container);
$: originalContainer && events && subscribe(container);

function swap(as: string) {
  if (container.nodeName === as.toUpperCase()) return;

  const target = document.createElement(as);
  while (container.childNodes.length) {
    target.appendChild(container.childNodes[0]);
  }
  container.parentNode?.replaceChild(target, container);
  container = target;
}

function setAttributes(target: HTMLElement) {
  Object.entries($$restProps).forEach(([name, value]) => {
    if (value === undefined && value === null) {
      target.removeAttribute(name);
    } else {
      target.setAttribute(name, value);
    }
  });
}

function subscribe(target: HTMLElement) {
  unsubscribe();

  subscriptions.push(
    ...Object.entries(events).map(([event, listener]) => {
      target.addEventListener(event, listener as (e: Event) => void);
      return () => {
        target.removeEventListener(event, listener as (e: Event) => void);
      };
    }),
  );
}

function unsubscribe() {
  for (const unsubscribe of subscriptions) {
    unsubscribe();
  }
  subscriptions = [];
}
</script>

<div bind:this={container}>
<slot />
</div>
```

```ts:util.ts
export function bindDefaultProps(
  component: any,
  props: Record<string, any>,
): any {
  return class extends component {
    constructor(options: { props?: Record<string, any> }) {
      super({
        ...options,
        props: { ...props, ...options.props },
      });
    }
  };
}
```

  </div>
</details>

## 設計

自作にあたって、この機能の要件を考えました。

- HTML タグ名を props で渡して、その HTML タグに切り替わるコンポーネント
- タグ名の他、任意の属性やイベントを渡せること
- タグ名、属性、イベントに変更があった場合はちゃんと新しいものに更新されること
- 任意の HTML タグをコンポーネント化できること

上記が満たせれば実用的なものができるのではないかと考えました。

## 実装

さてどういう実装にするかと考えながら情報を漁っていた際にとある動画を見つけました。
[How to create dynamic elements](https://www.youtube.com/watch?v=3MRfdoQzrc0)というまさにやりたかったことそのままの動画で、この時点で問題は解決して終わりにしたいところだったのですが、要件の 1 番目しか満たしていなかったため、このコードをベースにして要件を満たすように手を加えることにしました。

Svelte のシンタックスに対応していないので見づらいですが、動画のコードは以下のような形になっていました。

```svelte:Box.svelte
<script>
  import { onMount } from "svelte";

  export let as;

  let container;
  let originalContainer;

  onMount(() => {
    originalContainer = container;
    return () => {
      container.parentNode.replaceChild(originalContainer, container);
    };
  });

  $: originalContainer && swap(as);

  function swap(as) {
    if (container.nodeName === as.toUpperCase()) return;

    const target = document.createElement(as);
    while (container.childNodes.length) {
      target.appendChild(container.childNodes[0]);
    }
    container.parentNode?.replaceChild(target, container);
    container = target;
  }
</script>

<div bind:this={container}><slot /></div>
```

とりあえずコードを眺めて処理の流れを追い、

1. `as`でタグ名を取ってくる、
1. `bind:this`で`container`に DOM を保持
1. onMount で`container`を`originalContainer`に退避
1. リアクティブ宣言で`originalContainer`が使われているので`swap()`が呼ばれる
1. `swap()`の中で`container`を`as`のタグに入れ替える

という処理になっているようでした。

`swap()`の中の`while`の処理がなぜこれで動くのか混乱しましたが、
よく考えると`appendChild`で親 DOM の繋ぎ先を変えるわけで、`childNodes`が減っていくのでこのコードになっていると納得しました。

ここから手を加えていくわけですが、当方 JavaScript は受け付けないため TypeScript 用に型を書き加えながらいじっていきます。

```svelte:DynamicElement.svelte
<script lang="ts">
  import { onMount } from "svelte";

  export let as: string;
  export let events: {
    [K in keyof GlobalEventHandlersEventMap]?: (
      e: GlobalEventHandlersEventMap[K],
    ) => void;
  } = {};

  let originalContainer: HTMLElement;
  let container: HTMLElement;
  let subscriptions: (() => void)[] = [];

  onMount(() => {
    originalContainer = container;
    return () => {
      unsubscribe();
      container.parentNode?.replaceChild(originalContainer, container);
    };
  });

  $: originalContainer && swap(as);
  $: originalContainer && $$props && setAttributes(container);
  $: originalContainer && events && subscribe(container);

  function swap(as: string) {
    if (container.nodeName === as.toUpperCase()) return;

    const target = document.createElement(as);
    while (container.childNodes.length) {
      target.appendChild(container.childNodes[0]);
    }
    container.parentNode?.replaceChild(target, container);
    container = target;
  }

  function setAttributes(target: HTMLElement) {
    Object.entries($$restProps).forEach(([name, value]) => {
      if (value === undefined && value === null) {
        target.removeAttribute(name);
      } else {
        target.setAttribute(name, value);
      }
    });
  }

  function subscribe(target: HTMLElement) {
    unsubscribe();

    subscriptions.push(
      ...Object.entries(events).map(([event, listener]) => {
        target.addEventListener(event, listener as (e: Event) => void);
        return () => {
          target.removeEventListener(event, listener as (e: Event) => void);
        };
      }),
    );
  }

  function unsubscribe() {
    for (const unsubscribe of subscriptions) {
      unsubscribe();
    }
    subscriptions = [];
  }
</script>

<div bind:this={container}>
  <slot />
</div>
```

というわけでできました。
`Box.svelte`だと用途がわかりにくいため`DynamicElement.svelte`にリネームしつつ、属性とイベントを更新する処理を加えました。

`as`の型は`keyof HTMLElementTagNameMap`で HTML タグ名のみ受け入れるようにできますが、使い勝手が悪いためあえて`string`にしています。

実質 2 行がメインですが、リアクティブ宣言で変更時の処理がこんなシンプルに書けるのはすごい:clap:

これで動的に HTML タグを入れ替えることができるようになりました。

さらに以下のようにすると自由にコンポーネントを入れ替えられるようになります。
（`component`の型が`any`なのは Svelte のコンポーネントを受け入れる型が見つからなかったためです。）

```svelte:Dynamic.svelte
<script lang="ts">
  import DynamicElement from "./DynamicElement.svelte";
  export let component: any;
</script>

{#if typeof component === "string"}
  <DynamicElement as={component}><slot /></DynamicElement>
{:else}
  <svelte:component this={component}><slot /></svelte:component>
{/if}
```

実はまだ要件が残っているためもう一手間必要です。
ライブラリ等でコンポーネントを props で渡せる状況で p タグを渡したいとします。
しかし、この`DynamicElement`を渡しても`as`の props を指定できないため、p タグとして渡せません。
その解決策の１つとして、props にデフォルト値を渡せるようにすればいいと考えました。

```ts:util.ts
export function bindDefaultProps(
  component: any,
  props: Record<string, any>,
): any {
  return class extends component {
    constructor(options: { props?: Record<string, any> }) {
      super({
        ...options,
        props: { ...props, ...options.props },
      });
    }
  };
}
```

引数で渡した props をコンストラクタで bind しつつ、新しいコンポーネントとして返します。

下記の用に使えば任意のタグのコンポーネントを作ることができます。

```svelte:App.svelte
<script lang="ts">
  import DynamicElement from "./DynamicElement.svelte";
  import { bindDefaultProps } from "./util";

  // pタグのコンポーネントを作る
  const P = bindDefaultProps(DynamicElement, { as: "p" });
</script>

<Component component={P}>
  <div>child</div>
</Component>
```

これで全ての要件を満たしました。

## おわりに

2 ファイル作るだけでも割と苦労しましたが、なんとか動くものが出来上がって大変満足です。
とはいえ、必要になる場面はあまりないといえばないので、使わないならそれに越したことはないと思います。
（と思って公式も実装してないのだろうか。。。）

日本ではまだまだ Svelte の実績が少ないようなのでもっと増えるといいと思います:pray: