🐫

Svelte の Web Components サポートを使ってビデオ通話ライブラリのSDKを開発した話

2021/12/25に公開

はじめに

Svelte Advent Calender をご覧の皆さんこんにちは。濱口と申します。25日を担当させて頂きます。

普段はドイツのスタートアップでフルスタックエンジニアをやっています。国内ではビデオ通話SDKの Kommu(クローズドベータ中)の開発や、実践型プログラミングスクール Code Village の監修をしています。

ソフトウェアエンジニアとしてはWebフロントエンドから仮想デバイスドライバの開発まで幅広く経験してきましたが、最近は特に kommu の利用者(kommuをライブラリとして使って開発する人)向けのSDKをより使いやすくするために、 Svelte や Web Components に特に興味を持っています。

先日、ひょんなきっかけから Svelte Summit で発表する機会を頂き、Svelte での Web Components 開発についてお話しさせて頂きました。

https://www.youtube.com/watch?v=GuJg3IHA1to&t=4643s

発表は英語で、近々日本語字幕付きの動画がアップされるとのことなのですが、もう少し時間がかかるそうなので、この記事ではその内容を日本語版で書きたいと思います。

なぜ Svelte と Web Components を使うことにしたか

Kommuは、WebRTCベースのビデオチャット機能を提供するライブラリです。当初は、WebRTCがもたらす複雑性を隠蔽するためのラッパーとして、通常のクラスライブラリとして開発していました。

ビデオチャットは必然的にエンドユーザーと視覚的に対話する必要があるため、ライブラリユーザーはDOM操作やイベントの取り回し(WebRTC由来のイベントがクラスライブラリからある程度染み出してくる)をする必要があります。

Kommuのターゲットは、JavaScriptや非同期分散プログラミング、ローレイヤでのマルチメディア処理に熟達した凄腕エンジニア…ではなく、入門レベルのJavaScriptエンジニアです。理想的には、6ヶ月程度のトレーニングを受けた駆け出しレベルのメンバーがほとんどのチームでも、安心して採用できるSDKを目指しています。

そんなわけで、ライブラリをロードして、いくつかの設定をするだけで導入が完了するようなUIヘルパーを提供したいと思いました。

私たちは、受託開発や社内ツールのUI開発ではSvelteを多用していたので、 Kommu の UI 要素の開発にも Svelte を採用するのが自然でした。

一方、このような使い方、つまりライブラリ開発では、ReactやVueを使ったプロジェクトでは Svelte で書かれた部分が使えないため、Svelteのシェアが比較的小さいことが課題になります。

幸い、 Svelte は Web Components をサポートしていて、 Svelte コンポーネントを自動的に Web Components としてビルドすることができます。というわけで、ライブラリのUI部分については、 Web Components として提供することにしました。

Web Componentsとは

Web Components に馴染みのない方のために、簡単に概要を紹介します。

Web Components とは、JavaScript でカスタムのDOM要素を定義するためのWeb標準です。簡単にいうと、次のようなHTML標準にないタグを自由に定義できる仕組みです。

<clipboard-copy for="blob-path">Copy full URL</clipboard-copy>
<a id="blob-path" href="/path/to#my-blob">Link text will not be copied</a>

<clipboard-copy>というタグはHTML標準にはありません。これらは Web Componentsで作られていて、データをクリップボードにコピーするボタンを提供しています。タグを使う人は、JavaScriptでクリップボードにデータをコピーすることが内部でどのように実装されているかをするかを気にする必要はありません。

この機能は、GitHubのリポジトリで試すことができます。GitHubはWeb Componentsのヘビーユーザーとして知られています。

How we use Web Components at GitHub | The GitHub Blog

Web Components と Svelte コンポーネントの対比

Web Components は、技術的には「カスタム要素」と呼ばれています。カスタム要素は、Svelteのコンポーネントと似た機能を提供することができます。

カスタム要素にも、HTMLでお馴染みの属性 (attribute) があります。これは Svelte でいう prop のように使用することができます。実際、 Svelte コンポーネントをカスタム要素にビルドすると、 props は属性に変換されます。

もうひとつは、Props の重要な特性であるリアクティビティがあります。

Svelteのソースコードとは異なり、生のHTMLドキュメントに動的な変数を置くことはできません。「属性値」という変化しない文字通りの文字列リテラルがあるだけです。

属性値の変更は JavaScript で直接書いてあげる必要がありますが、DOM API での代入さえしてしまえば、Svelteのリアクティビティがそれを処理し、カスタム要素でその属性値に依存する部分をすべて更新してくれます。

カスタムイベントも Svelte コンポーネントの便利な機能です。Svelteコンポーネントはイベントを発することができ、対応するon: props、すなわち(例えば on:click )を介して渡されたハンドラによって処理されます。

カスタム要素はDOMイベントを発することができ、それは Svelte コンポーネントから発せられるイベントとほぼ同じように動作します。

最後に、Svelte のコンポーネントには双方向のデータバインディングがあります。カスタム要素にはそのようなものはありません。

では、カスタム要素のある状態が変更されたとき、どのようにして知ることができるのでしょうか? そのためには、カスタムDOMイベントを使うことができます。DOM要素のイベント、つまりonclickをリッスンするのと同じように、カスタム要素が発する非標準のイベントをリッスンすることができます。

変数に論理的にバインドされた props がカスタム要素内で変更されたときに、ページ上の変数を更新することができます。もちろん、実際にはそれ以上のことができますが、複雑性が高くなりがちなので、双方向データバインディングの範囲に留めた方が保守性の良いコードが書けるでしょう。

Svelte での Web Comopnents 機能の有効化

Svelteでは、 <svelte:tag name="custom-element-name"> のような特別なタグを追加して、コンパイラオプションの設定をするだけで、コンポーネントをカスタム要素にコンパイルするように指定することができます。

カスタム要素は、 実体としてはCustomElementRegistory API の呼び出しです。タグを書くと、そのタグの代わりにSvelteコンポーネントが呼び出されているように見えます。正しくロードされていれば、Reactコンポーネントと並べても問題なく動作します。また、フロントエンドライブラリのないプロジェクトでも使うことができます。

Kommu での Web Components の使い方

では、実際に Svelte で作った Web Components をどのように使うのか見てみたいと思います。

Kommu の Web Components SDK を使って簡単にビデオチャットを作ることができます。

<kommu-starter
  api-key="kommu_pk_OHBtY3VkdnBEWkxlbU1CUGZTWTJjRzI3c21fNTdWdU1BWHFlNFJqRk52QTo="
  room-id="meet-on-kommu-lp">
</kommu-starter>

<script src="https://unpkg.com/kommu@latest/dist/kommu.min.js"></script>
<script src="https://unpkg.com/kommu@latest/dist/kommu-web-components.min.js"></script>

コード下部でバンドルを読み込んでいる他には、通常のHTMLでは見慣れない <kommu-starter>というタグが書かれているだけです。これがカスタム要素です。

属性として渡される2つのパラメータが見えますね。 api-key はAPIキーで、完全に静的な文字列ですが、後者の room-id はユーザーによって異なる部屋を使用したい場合があります。

今回の例では、ページ上の全員が同じ部屋に入っても問題ありませんが、より現実的な使い方として、ユーザーは特定のグループの人たちと会いたいと思うでしょう。これを実現するには、次のようにelement.roomNameを動的に設定します。

<body>
  <kommu-zoom api-key="kommu_pk_…" room-name="roomId"></kommu-zoom>
  <script src="/sdk/latest/kommu-webcomponents.js"></script>
  <script>
    var element = getElementsByTagName("kommu-zoom")[0];
    element["room-name"] = "someDynamicString";
  </script>
</body>

ユーザーが何かのキーワードを入力する入力欄を設け、それを取得して要素に渡すと、そのキーワードを知っているユーザー同士だけでビデオ通話をすることができます。

面白いのは、これらのコンポーネントをSvelteのコンポーネントとしても使える点です。他のSvelteのコンポーネントと組み合わせたり、他のコンポーネント内からインポートすることもできます。

つまり、ライブラリ開発者としては Svelte で効率的に開発しながら、ライブラリ利用者には相互運用性に優れたカスタム要素を提供することができます。

カスタム要素を作ってみる

早速、小さなカスタム要素を作ってみましょう。いつものように、ボイラープレートを取得して TypeScript に変換するところから始めましょう。

$ npx degit sveltejs/template sveltesummit-kommu
$ cd sveltesummit-kommu
$ node scripts/setupTypeScript.js

Svelte の Web Components サポートを有効にするには、 rollup.config.jsplugins.svelte.compilerOptions.customElementtrueに設定します。

export default {
  input: 'src/main.ts',
  output: {
    sourcemap: true,
    format: 'iife',
    name: 'app',
    file: 'public/build/bundle.js'
  },
  plugins: [
    svelte({
      preprocess: sveltePreprocess({ sourceMap: !production }),
      compilerOptions: {
        // enable run-time checks when not in production
        dev: !production,
        customElement: true
      }
    }),

この設定はコードベース全体で有効になることに注意してください。モジュール単位のような細かい制御はできません。そして、すべてのコンポーネントに<svelte:options tag="custom-element-name">を記述する必要があります。これがカスタム要素を使うときのタグ名になります。

それでは、 Svelte コンポーネントの3つの主要な機能である Props, リアクティビティ, 双方向データバインディングをカスタム要素を使って実装してみます。

まずは、App.svelteに特別なタグを追加してみましょう。シンプルな挨拶のテキストが入っていますが、もっとハッピーな感じにしたいので、happy birthday's にしましょう。

<svelte:options tag="my-first-custom-element" />

<script lang="ts">
  export let name: string;
</script>

<main>
  <h1>Happy Birthday, {name}!</h1>
</main>

これで完成です。これで、前述のカスタム要素を定義したバンドルが作成されます。試しに使ってみるにはもう一仕事必要です。

main.tsを開くと、Appコンポーネントをページの document.body 全体にマウントしていることがわかります。Appコンポーネントをページ全体にマウントするmain.tsを開きます。そのため、このファイルにはimport文だけを記述します。そして、publicディレクトリのindex.htmlに、カスタム要素を属性付きで配置します。

// main.ts
import App from './App.svelte';

// index.html
<body>
  <my-first-custom-element name=“Haruka Nakamura”> </my-first-custom-element>
</body>

npm run devで起動して http://localhost:5000を開きます。すると、prop で渡された名前が表示されます。うまくいきましたね。

Svelteコンポーネントのもう一つの重要な特徴は、リアクティビティです。もちろん、HTML文書は静的なものですから、属性値を文字通り変更することはできませんが、 JavaScript を使えば変更することができます。

index.htmlにボタンを置いて、クリックされたらカスタム要素を取得して、属性を変更するようにします。

<my-first-custom-element name="Haruka Nakamura"></my-first-custom-element>
<button>Celebrate another</button>
<script>
  const input = document.getElementsByTagName('button')[0];
  input.addEventListener('click', (e) => {
    const custom = document.getElementsByTagName('my-first-custom-element')[1];
    custom.name = 'Kazuo Ishiguro';
  })
</script>

すると、それに反応してコンポーネントの表示も更新されます。舞台裏では Web Componentsの「ライフサイクルコールバック」と呼ばれるイベントドリブンな機能が活用されています。Svelteは、 props をライフサイクルコールバックの設定に自動的に変換しますが、Svelteによってうまく隠蔽され、開発者が気にしなくて良いようになっています。

さて、今度は逆にそれほどうまく隠蔽されていないところを試してみたいと思います。双方向データバインディングです。というのも、 DOM の世界にはこれに直接対応するものがありません。というわけで、同じ目的を達成できる別の方法を考える必要があります。

最も直感的で DOM 的に自然な方法は、カスタムイベントを使用することだと思います。DOMでのプログラミングの経験があれば、ライブラリのドキュメントに「この値が更新されたときにすぐに受け取るためには、このカスタムイベントをリッスンしてください」と書いてあっても、それほど驚かないはずです。

Svelte のコンポーネントでは、カスタムイベントを発行するのは比較的簡単です。svelteパッケージのcreateEventDispatcherを使用するだけです。

<svelte:options tag="my-first-custom-element" />

<script lang="ts">
  export let name: string;
  import { createEventDispatcher } from 'svelte';
  const dispatch = createEventDispatcher();

  $: (name) && dispatch('namechanged', { name })
</script>

<main>
  <h1>Happy Birthday, {name}!</h1>
  <button on:click={() => { name = 'Siro Masamune' }}>Celebrate another person</button>
</main>

on:namechanged props にハンドラを渡しておけば、 "namechanged"イベントをリッスンすることができます。addEventListener などでリッスンできる DOM カスタムイベントが自動的に発行されれば良いのですが、残念ながらそうはなりません。そのためには、Component.dispatchEventを使い、CustomEventのインスタンスを渡す必要があります。以上を合わせて、

<svelte:options tag="my-first-custom-element" />

<script lang="ts">
  export let name: string;
  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 }));
+ }

  $: (name) && dispatch('namechanged', { name })
</script>

<main>
  <h1>Happy Birthday, {name}!</h1>
  <button on:click={() => { name = 'Siro Masamune' }}>Celebrate another person</button>
</main>

このようなヘルパー関数があれば、コンポーネントがSvelteコンポーネントとして使われても、カスタム要素として使われても、正しくカスタムイベントを発することができるようになります。

このイベントをリッスンしてみましょう。通常通りaddEventListenerを使用します。注意点としては、wehnDefinedメソッドを使って、カスタム要素が利用可能になるまで待つ必要があります。そうしないと、getElementsByTagNameundefinedを返してしまい、addEventListenerを呼び出すことができなくなってしまいます。

<body>
  <div id="svelte"></div>
  <my-first-custom-element name="Haruka Nakamura"></my-first-custom-element>
  <script>
    customElements.whenDefined('my-first-custom-element').then(() => {
      const custom = document.getElementsByTagName('my-first-custom-element')[1];
      custom.addEventListener('namechanged', (e) => alert(e.detail.name));
    });
  </script>
</body>

こう書きながら振り返ると簡単そうに感じるのですが、実際にはこうした回避策を見つけるのはそれなりの苦労でした。Svelte の Web Component 対応にはまだまだ荒削りな部分があるというのが事実です。

しかし、それを乗り越えることができたのは、Svelte の温かく活発なコミュニティのおかげです。私はコミュニティの中ではROM専に近いメンバーですが、将来的には少なくとも何らかの貢献をしたいと思っていますし、今回の発表のようなことが誰かの役に立つことを願っています。

しかしまずは、 Props, リアクティビティ, 双方向データバインディングの機能を備えたカスタム要素を完成させた私たち自身に賞賛の言葉を贈りましょう!

ライブラリ利用者(カスタム要素を使う人)の視点で見ると、普通のHTMLタグやごく初歩的なJavaScript だけで完結しています。これがポイントです。皆さんがUIパーツを含むライブラリを作る際には、カスタム要素として使える仕組みを提供ことをぜひ検討してみてください。

Web Components サポートの欠点

ここからは、 kommu を開発する中で私が遭遇した Svelte の Web Components サポートの落とし穴をいくつか紹介していきます。

ケバブケース

まず、HTMLでは、ほとんどの属性名がケバブケース(小文字の単語とハイフンを組み合わせたもの)で呼ばれています。これに対して、 Svelte の props は、JS変数の宣言のセットとして記述されており、その名前は内部にハイフンを含むことができません。

<a href="/tokyo" data-attr="Paris">Berlin</a>

export let dataAttr;
export let data-attr; <---- Syntax error

素朴な回避策としては、このような状況では $$props を使って props にアクセスすることになります。

export let dataAttr = $$props['data-attr'];

<swc-kebab your-name={name}></swc-kebab>

しかし、これはHTMLでカスタム要素を直接使用する場合にのみ有効です。カスタム要素のエンドユーザーはそう使うので問題ありませんが、コンポーネントの開発者にとっては問題があります。Svelte コンポーネントとして呼び出した場合、コンポーネントがインスタンス化された時点では、意図せずすべての props が undefined になってしまいます

これを可能にするもう一つの回避策は、Svelteのデフォルト動作をインターセプトするラッパークラスを用意することです。

import Kebab from  './Kebab.svelte'

// Thanks to the workaround suggested here: https://github.com/sveltejs/svelte/issues/3852

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);

属性名の大文字小文字

コンポーネントがカスタム要素としてマウントされている場合、属性名に大文字を使用することができません。

実際には、コンパイルに失敗することはありませんが、値が undefined のままとなってしまいます。

例えば、このように指定しても yournameのように小文字に変換されてしまいます。

<your-anchor label="/tokyo" dataAttr=“Paris">Berlin</your-anchor>

export let dataAttr;
          ^ Syntactically correct, but ends up undefined.
export let dataattr; // works

これは Svelte の Web Components サポートの問題ではなく、上記で説明した命名規則に従うように名前を変換するブラウザの問題のようです。

JavaScript ではキャメルケースが事実上の標準的な命名方法です。それに合わせて通常通り prop を yourNameのように命名すると、うまく属性値が渡りません。この場合、属性値を小文字だけで書くように変更することで動作するようになります。

興味深いことに、呼び出し側の属性名には大文字がいくつ含まれていても問題ありません。例えばこんな感じです:

<a href="/tokyo" dataattr=“Paris">Berlin</a>
<a href="/tokyo" dataAttr=“Paris">Berlin</a>
<a href="/tokyo" DaTaAtTr="Paris">Berlin</a>

export let dataattr; // works

しかし、当然ながら混乱します。やめておきましょう。

まとめ

Svelte での Web Components 開発がどのようなものかを説明してきました。少しおさらいしておきましょう。

まず、 Web Components は、カプセル化されたコンポーネントを vanlla JS に提供する方法であることを学びました。

もしあなたがライブラリを作っていて、Webページにたくさんのビジュアルアイテムを提供したいのであれば、 Web Components の採用を検討しても良いでしょう。

もちろん、最もスムーズな方法は React や Vue などのフロントエンドライブラリでコンポーネントを提供することです。しかし、もちろんすべてのプロジェクトがどちらかを採用しているわけではありませんし、 Vue のコンポーネントを提供すれば、 React のプロジェクトからは利用できません。

コンポーネントをカスタム要素にコンパイルしてしまえば、 Svelte のコードベースで作業ができるので、開発速度を大きく犠牲にすることなく vannia JS のプロジェクトでも簡単に採用することができる SDK を開発することができます。

次に、Svelte のコンポーネントと同等の Props, リアクティビティ, 双方向データバインディングの機能を持つカスタム要素を構築する方法を紹介しました。

DOM は Svelte とは根本的に異なるパラダイムで動作しているので、ライブラリ利用者の視点では完全に同等ではありませんが、ある程度は複雑さを軽減することができます。また、UIライブラリには不可欠な優れた抽象化レイヤーを提供することができます。

最後にご紹介したのは、 Svelte を使ってカスタム要素を開発する際の、いくつかの落とし穴と(ベスト)プラクティスです。確かに荒削りな部分もありますが、暖かくて素晴らしいコミュニティがあり、多くのことを助けてくれます。

デバッグする中で、いくつかのケースでは、Svelte コンパイラと Vite のコードの一部を読み込まなければなりませんでした。大変そうに聞こえるかもしれませんが、それほど膨大なコードベースはなく、簡潔で洞察に溢れた良いコードがほとんどです。最近のJSのエコシステムやツールチェーンについての理解を深める上で、とても良い経験になったと思います。この記事を読んで興味を持った方は、ご自身で試してみてください。

おわりに

以上で発表の内容は終わりです。

Svelte の Web Components 対応は、現在はコアメンテナチームの中では優先順位が高くないらしく、それならぜひプルリク送っていこうじゃないか、と思って準備しています。

Issue で仲間を募ったところ、「興味あるが Svelte コンパイラの内部実装について詳しくないのでどうしたものか」という声があったので、コードの読書会でもやりながら wiki にでもまとめていこうかな、と思っています。

もしご興味のある方がいたらTwitterやDiscordでお声がけください。

Discussion