Svelte を使って Web Components を開発することができます。便利ですが、未だ変化の激しいライブラリでもあるため、現時点ではうまく実現できない部分もいくつかあります。
この記事では、現時点での Svelte の Web Components サポートに残っている課題・落とし穴と、存在する場合はその対処法を解説します。
再現方法と対処法のコードは、このリポジトリにあります。
https://github.com/tnzk/svelte-webcomponents-exp
Vercel にもデプロイしてあるので、オンラインで挙動を確認することもできます。
https://svelte-webcomponents-exp.vercel.app/
Svelte コンポーネントで props
として指定した変数は、 Web Components としてビルドした場合には"属性" ( <a href="#">
の href
など) として設定できるようになります。
属性の名前は [a-z]
だけで指定され、例外的に一部単語を -
で繋ぐ、いわゆるケバブケースで書かれることが慣例となっています[^1]。
ところが、 Svelte では props を変数宣言として記述するため、 JavaScript において変数名に使用できない -
を含む props を宣言することができません。
Svelte の開発チームはこの問題を認識していますが、解決していません。コミュニティからは、このような場合には $$props
を通して $$props['kebab-attr']
のようにアクセスしてすることが提案されています [^2]。
[^2]: 参考: https://github.com/sveltejs/svelte/issues/875
しかし、実際にこの方法でケバブケースの名前を持つ属性にもアクセスできるのは、そのカスタム要素を HTML 上に直接書いた場合のみです。ビルドした Web Compnents のユーザはこのユースケースに当てはまるため問題ないのですが、開発中の取り回しが課題となります。 Svelte でマウントした場合は、子コンポーネントが初期化される段階ではどの props も undefined
となるため、子コンポーネントに意図した値が渡りません。
// App.svelte
<script>
import Child from './Kebab.svelte'
let name = value
</script>
<input bind:value>
<swc-kebab your-name={name}></swc-kebab>
// Kebab.svelte
<svelte:options tag="swc-kebab" />
<script>
export let yourName = $$props['your-name']
</script>
Hello, {yourName}
どうしても <swc-kebab your-name={name}></swc-kebab>
と書いてそのまま動くようになってほしい場合は、若干大袈裟ですが Svelte のクラス定義を拡張する方法があります[^3]:
// KebabFixed.js
import Kebab from './Kebab.svelte'
class KebabFixed extends Kebab {
static get observedAttributes() {
return (super.observedAttributes || []).map(attr => attr.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase());
}
attributeChangedCallback(attrName, oldValue, newValue) {
attrName = attrName.replace(/-([a-z])/g, (_, up) => up.toUpperCase());
super.attributeChangedCallback(attrName, oldValue, newValue);
}
}
customElements.define('swc-kebab-fixed', KebabFixed);
// App.svelte
<script>
import './KebabFixed.svelte'
let name = value
</script>
<input bind:value>
<swc-kebab-fixed your-name={name}></swc-kebab-fixed>
[^3]: 参考: https://github.com/sveltejs/svelte/issues/3852
また、同様の話で、 Web Components として呼び出した場合、属性名に大文字を使うことができません。例えば、 yourName
などと書いても、 Svelte に渡ってくる時点では yourname
のように小文字に統一されてしまいます。
これは Svelte の Web Components サポートに問題があるというより、前述の属性名の命名慣習に従って統一するようにブラウザが変換しているようです。
JavaScript ではキャメルケースで変数を命名する規約を採用していることが少なくないため、いつもの感覚で以下のように書くと、 yourName
は undefined
となってしまいます。
この場合、 NoUppercase.svelte
側の二箇所のyourName
を yourname
に変更することで、問題なく動作するようになります。逆に、呼び出し側の属性名は yourName="camelCase"
であっても yourname="non camel case"
であっても同様に動作します。
// App.svelte
<script>
import './NoUppercase.svelte'
let name = value
</script>
<input bind:value>
<swc-no-uppercase yourName={name}></swc-no-uppercase>
// NoUppercase.svelte
<svelte:options tag="swc-no-uppercase" />
<script>
export let yourName // Change this to `yourname`
</script>
Hello, {yourName} <!-- Change this to `yourname` -->
ここまでの例では、Web Components として設置した要素に、 Svelte の記法を使って属性直を設定していました。こうすることで、カスタム要素を本番環境に近いかたちで使用しつつ、 Svelte の便利な機能を一部使用することができます。Svelte での通常の開発と同様に、value
を変更するとそれに依存関係を持つ name
が変更され、 props にも反映されて swc-child
要素の内容も更新されます。
実際に HTML の中に配置する場合、 yourname={name}
のような便利な属性直の設定方法はできません。属性値には yourname="gihyo"
のようにテキストをベタ書きすることになります。
この属性値を動的に変更したい場合は、 DOM API を経由して JavaScript で値を設定する必要があります。
const element = document.querySelector('swc-child')
element.yourName = 'my new name'
属性値が変更されると、Svelte が登録した attributeChangedCallback
によってカスタム要素内のDOMが更新され、 Svelte コンポーネントに近い使い方ができます。
一方、bind:
の仕組みはサポートされていないため、これを使っても子コンポーネント内での props の変更が親コンポーネントに反映されることはありません。
コンポーネントでの変更をコンポーネントユーザ側に反映したい場合、後述するカスタムイベントを送出し、コンポーネントユーザがそれに対してイベントリスナを登録する必要があります。
実装の負担は生じますが、フロントエンドフレームワークを使わない以上、コンポーネントからのイベントを受け取りどう処理するかをコンポーネントユーザの責務であり裁量とすることは妥当といえます。
Svelte のコンポーネントには props を経由して任意のオブジェクトを渡すことができます。〜のように使えて便利です。
しかし、カスタム要素をHTML中に書く場合には基本的に文字列しか渡すことができません。
Svelte コンポーネントを Web Components 化する場合、Svelte では渡せていたオブジェクトが渡せなくなるため、子コンポーネント側でそれに依存した記述が多くあると、移行(または両対応)の負担が大きくなります。
渡したいオブジェクトが十分単純な場合にはJSONにシリアライズして渡すことができますが現実的にはそのような場面はあまり多くなさそうです。
一つのworkaroundとして、グローバルな名前空間にストアのような変数を持ち、そのストア内のキーを文字列としてコンポーネントに渡し、コンポーネントもそのキーからストアを参照しデータを共有することが考えられます。
// App.svelte
<svelte:options tag="swc-root" />
<script>
import PassAnObjectFixed from './PassAnObjectFixed.svelte'
let name = 'default name'
window.__myData = {
'somekey': {}
}
$: window.__myData['somekey'].name = name
const syncToParent = () => {
name = window.__myData['somekey'].name
}
</script>
<input bind:value={name}>
{name}
<p>As WC: <swc-pass-object name={data}></swc-pass-object></p>
<p>As Svelte: <PassAnObject {data} /></p>
<p>As WC: <swc-pass-object-fixed key="somekey"></swc-pass-object-fixed><button on:click={syncToParent}>Sync to input field</button></p>
// PassAnObjectFixed.svelte
<svelte:options tag="swc-pass-object-fixed" />
<script>
export let key
let name
const refresh = () => {
name = window.__myData['somekey'].name
}
refresh()
$: window.__myData['somekey'].name = name
</script>
Hello, {name} <button on:click={refresh}>Refresh</button>
<input bind:value={name}>
この方法によれば、子コンポーネントがストアに加えた変更を親コンポーネントが参照することもでき、前述したbind機構に類似する仕組みを実現することもできます。
しかし、コンポーネントに明示的に指定する値がキーだけとなるため、データの依存関係が不明確になることはあまり好ましいとは言えません。DOM APIでの属性値の変更と、カスタムイベントでのコンポーネント内での変更の通知を明示的に行い、データの依存関係を明確にするほうが、メンテナンス性の良いコードになると考えられます。
前述した通り、Svelte では、ビルトインで定義されている on:click
や on:keydown
, on:focus
などのイベントに加えて、コンポーネント特有のイベントをカスタムイベントとして送出するように設定できます。
しかし、これらのカスタムイベントは Svelte 固有のイベント機構によるものなので、Web Components としてビルドして HTML に組み込んだ場合、 DOM API からイベントリスナを設定しても、これをハンドルする方法がありません。以下の例では、 Svelte コンポーネントとしてマウントすればイベントが送出されて bind 機構と同様の仕組みが実現できるものの、 Web Components としてマウントするとこれができないことが確認できます。
// App.svelte
<svelte:options tag="swc-root" />
<svelte:window on:load={() => handleLoad()} />
import CustomEventExample from './CustomEventExample.svelte'
let name = 'default name'
const handleCustomEvent = (event) => name = event.detail.name
let rootElement
const handleLoad = () => {
const customElement = rootElement.querySelector('swc-custom-events')
customElement.addEventListener('namechanged', handleCustomEvent)
}
$: if (customEventElement) customEventElement.name = name
</script>
<div bind:this={rootElement}>
<h1>Custom Event</h1>
<p>As Svelte: <CustomEventExample {name} on:namechanged={handleCustomEvent} /></p>
<p>As WC: <swc-custom-events name={name}></swc-custom-events></p>
</div>
// CustomEventExample.svelte
<svelte:options tag="swc-custom-events" />
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let name
$: (name) && dispatch('namechanged', { name })
</script>
Hello, {name}
<input bind:value={name}>
幸い、ブラウザのAPIもDOM上のカスタムイベントを送出する仕組みを提供しているので、次のように記述することでカスタムイベントを送出し、ユーザ側の JavaScript でイベントリスナを設定することができます(https://github.com/sveltejs/svelte/issues/3119#issuecomment-706220854)。
<svelte:options tag="swc-custom-events-fixed" />
<script>
import { createEventDispatcher } from 'svelte';
import { get_current_component } from 'svelte/internal';
const component = get_current_component();
const originalDispatch = createEventDispatcher();
const dispatch = (name, detail) => {
originalDispatch(name, detail);
component?.dispatchEvent(new CustomEvent(name, { detail }));
}
export let name
$: (name) && dispatch('namechanged', { name })
</script>
Hello, {name}
<input bind:value={name}>
(独自に oncustomevent
のような props を宣言しておくことで、通常のHTMLの onclick
や onfocus
の感覚でカスタム要素を使用できるようになり便利かなーと思ったのですが、受け取った側でJSを評価してもハンドラとなる関数を参照できないため、コンポーネント側でのサポートはできないようでした)
<svelte:options tag="tag-name" />
を設定したコンポーネントも、 Svelte コンポーネントとして使うこともできます。Svelte コンポーネントとして使う場合は、ここまでこの記事で述べたような制約が生じません。
開発中は手早く色々なことを試行錯誤をしたいため、 Svelte の機能を使ってコードの記述量が減らせるのは便利です。
一方、その分 Web Components としてリリースしたバンドルとの挙動の差異が生じやすく、 Web Components として使ってはじめて不具合に気づく場合が多々生じます。
他の開発者に使ってもらう目的で Web Components を公開する場合、ビルドしたカスタム要素を実際のHTMLに設置して動作確認することが必須です。
前述した Svelte コンポーネントとし呼び出すか Web Components と呼び出すかによる差異のひとつに、コンポーネント単位のスタイル定義の適用範囲があります。
<svelte:options tag="tag-name" />
を設定したコンポーネントを Svelte コンポーネントとしてマウントすると、そのコンポーネントは Shadow root を持ちます。
一方、そのコンポーネントの中で呼び出されたコンポーネント(子コンポーネント)はこれとは異なる挙動となり、 Shadow root を持ちません。通常の Svelte コンポーネントと同様に振る舞います。と同時に、子コンポーネントに設定されていた <style>
によるスタイル定義は、 Shadow root に設定されるようコンパイラに変更されています。結果として、子コンポーネントには意図したスタイルが反映されないことになります。
// App.svelte
<svelte:options tag="swc-root" />
<script>
import StylesEncupsulated from './StylesEncupsulated.svelte'
let name = 'default name'
</script>
<h1>Styles</h1>
<p>As Svelte: <StylesEncupsulated {name} /></p>
<p>As WC: <swc-styles-encapsulated name={name}></swc-styles-encapsulated></p>
// StylesEncupsulated.svelte
<svelte:options tag="swc-styles-encapsulated" />
<script>
export let name
</script>
<span>Hello, {name}</span>
<style>
span { color: blue }
</style>
簡単な回避策として、スタイルをインラインで指定してしまう、という方法があります。インラインのスタイルは Svelte コンパイラによって処理されないので、コンポーネントの要素の中にそのまま残り、適用されます。
// StylesEncupsulated.svelte
<svelte:options tag="swc-styles-encapsulated" />
<script>
export let name
</script>
<span style="color: blue;">Hello, {name}</span>
しかし、この方法では同じスタイルを繰り返し書く必要があったり、そもそもテンプレートの記述が煩雑化するなど、メンテナンス性に悪い影響があります。
これに対して、Svelte コンポーネントとしてではなく、Web Components として呼び出すようにすると、子コンポーネント内のスタイルの適用を受ける Shadow root が生成されるので、問題なくスタイルが適用されます。
前述した通り、 Svelte コンポーネントとしてマウントするのと Web Components としてマウントするのとでは挙動の違いが様々にあるので、普段から Web Components としてマウントして開発するのが良いと考えられます。
Svelte での Web Components 開発は、「 Svelte で実装したコンポーネントインスタンスを、 createElements.define
API でカスタム要素として登録する」というかたちで実現されます。コンパイラオプションで customElemen
を設定することで実現するため、コンポーネントごとに Web Components としてビルドするかどうかを切り替えることは、素朴にはできません。
そのため、プロジェクト内のいずれかのコンポーネントに <svelte:options tag=... />
の設定を忘れてしまうと、 Uncaught (in promise) TypeError: Illegal constructor at new SvelteElement
のエラーが発生してしまうことがあります[^4]。
対処法はシンプルで、すべてのコンポーネントに <svelte:options tag=... />
を設定するしかありません。幸い、先述した通り、この設定をしたコンポーネントであっても Svelte コンポーネントとして使うこともでき、その場合は Web Components 特有の制限を受けることはありません。