LoginSignup
4
0

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-08

はじめに

この記事はSvelte Advent Calendar 2021 9日目の記事です。

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

そんな中、Svelte を触っていて動的に HTML タグを切り替えたいと思い、公式ドキュメントを見ていたものの記述がありませんでした。
コンポーネント自体を切り替える機能(dynamic components)はあるものの、HTML タグはコンポーネントとして用意されていないので、HTML タグの切り替えはできないことがわかりました。
これに関して、Issue は立てられているものの(Proposal: dynamic elements <svelte:element>)まだ実現していない状況です。

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

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


コード
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>
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 },
      });
    }
  };
}


設計

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

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

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

実装

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

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でタグ名を取ってくる、
  2. bind:thiscontainerに DOM を保持
  3. onMount でcontaineroriginalContainerに退避
  4. リアクティブ宣言でoriginalContainerが使われているのでswap()が呼ばれる
  5. swap()の中でcontainerasのタグに入れ替える

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

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

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

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 のコンポーネントを受け入れる型が見つからなかったためです。)

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 タグとして渡せません。
その解決策の1つとして、props にデフォルト値を渡せるようにすればいいと考えました。

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 しつつ、新しいコンポーネントとして返します。

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

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:

4
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0