はじめに
この記事はSvelte Advent Calendar 2021 9日目の記事です。
React に疲れてきて Svelte に移りたい今日この頃です。
Svelte いいですね。ローカル変数をいじくり回しているだけで状態変更やリアクティブな動作が発動するのは書いていて気持ちがいいです。
とはいえ、他と比べると後発ということもありライブラリ等はあまり充実おらず、ちょくちょく自作の必要があるのは大変だったりします。
そんな中、Svelte を触っていて動的に HTML タグを切り替えたいと思い、公式ドキュメントを見ていたものの記述がありませんでした。
コンポーネント自体を切り替える機能(dynamic components)はあるものの、HTML タグはコンポーネントとして用意されていないので、HTML タグの切り替えはできないことがわかりました。
これに関して、Issue は立てられているものの(Proposal: dynamic elements <svelte:element>)まだ実現していない状況です。
とはいえ、HTML タグをコンポーネント化するライブラリ(svelte-elements)自体はありましたが最終更新日がかなり古く、使用するのは不安だったため自作することにしました。
忙しい人のためのコード全文
コード
<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>
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 のシンタックスに対応していないので見づらいですが、動画のコードは以下のような形になっていました。
<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>
とりあえずコードを眺めて処理の流れを追い、
-
as
でタグ名を取ってくる、 -
bind:this
でcontainer
に DOM を保持 - onMount で
container
をoriginalContainer
に退避 - リアクティブ宣言で
originalContainer
が使われているのでswap()
が呼ばれる -
swap()
の中でcontainer
をas
のタグに入れ替える
という処理になっているようでした。
swap()
の中のwhile
の処理がなぜこれで動くのか混乱しましたが、
よく考えるとappendChild
で親 DOM の繋ぎ先を変えるわけで、childNodes
が減っていくのでこのコードになっていると納得しました。
ここから手を加えていくわけですが、当方 JavaScript は受け付けないため TypeScript 用に型を書き加えながらいじっていきます。
<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 行がメインですが、リアクティブ宣言で変更時の処理がこんなシンプルに書けるのはすごい
これで動的に HTML タグを入れ替えることができるようになりました。
さらに以下のようにすると自由にコンポーネントを入れ替えられるようになります。
(component
の型がany
なのは 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 にデフォルト値を渡せるようにすればいいと考えました。
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 しつつ、新しいコンポーネントとして返します。
下記の用に使えば任意のタグのコンポーネントを作ることができます。
<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 の実績が少ないようなのでもっと増えるといいと思います