前回に引き続き、ある程度目的をもって、SvelteでWebコンポーネントを開発してみています。そうすることで、いろいろとSvelteを理解できていない点が浮き彫りになるので、それらの気付いたことを書き留めたいと思います。
開発してみるもの
画面の片隅にウィンドウを表示し、リンクや入力に応じて応答を返す、チャットボットです。
- 問合せのカテゴリがツリー状にあって、リンクでドリルダウンされると、該当するQAを表示する。
- フリーワードで入力すると、それに関連するQAを表示する。
なんてイメージだけど、あくまでSvelteの勉強が目的なんで、UI的なところがそれっぽく動くことだけを目的とします。
なお、作成したソースはこちらにアップしています。
わかったこと
WebコンポーネントはShadow DOMを使用する。
そもそもWebコンポーネントの仕様の一部にShadow DOMがあること自体知りませんでした。たまたま開発者ツールで見ていたら
となっててShadow DOMの存在を知りました。
しかし、調べてみるとStencilやLitElementなど他のフレームワークではShadow DOMを使うか/使わないか切り替えられるようですが、Svelteでは現時点でShadow DOMを使う方式しか提供されていません。(こちらで議論はされているようです。)
なぜShadow DOMを使わない方式も必要かというと、Webコンポーネントのスタイルを外部から調整したい場合もあるからです。例えば今回であればチャットボットの色を外側に合わせて変えたいとかです。
part属性で外部からスタイル調整可能にする (でもVS Code上ではエラー表示されるからissueをあげてみた)。
ちなみに、それを近いかたちで実現する仕組として、Webコンポーネント側でタグにpart属性で名前を定義し、外部からCSSの::part疑似要素でスタイル調整するというのがあります。まだ草案ですが主要なブラウザは対応しているようです。
<div part="header">
<div part="title">{title}</div>
<button on:click={onToggleOpen} part="toggle">
</button>
</div>
chat-frame::part(header) {
display: flex;
}
chat-frame::part(title) {
margin: 1px 10px;
flex: 1 1 auto
}
これでちゃんとSvelteでビルドできて、外部からスタイルを調整できたのですが、VS Code上だと何故か
Type '{ part: string; }' is not assignable to type 'HTMLProps<HTMLDivElement>'.
Property 'part' does not exist on type 'HTMLProps<HTMLDivElement>'.
のようなエラーが表示されてしまいます。Svelte for VS Code v102.7.0の内部で使用しているsvelte2tsx(バージョン 0.1.151)でHTMLタグの属性を定義しているインターフェースにpartが存在しないのが原因でした。
せっかく原因を調べたので、つたない英語能力ですが、勇気を出してissueをあげたら約1時間ぐらいで修正してもらえました!それだけ活気があるOSSってことですね。
masterブランチには反映されているので近いうちにリリースされるかと思います。
【2020/12/12更新】 Svelte for VS Code v102.8.0でリリースされました。
コンポーネントの関数を動的に呼び出すにはひと手間が必要。
問合せカテゴリはjsonファイルに外だししており、こんな感にしています。
{
"message": "お問い合わせありがとう!<br/>今日はどんなご用件ですか?<br/>以下から選択するか、直接入力してね。<br/>1. {{ガンダム}}<br/>2. {{イデオン}}<br/>3. {{ザブングル}}",
"nodes": [
{
"id": "ガンダム",
"message": "ガンダムだね?<br/>1. {{地球連邦}}<br/>2. {{ジオン公国}}"
},
{
"id": "イデオン",
"message": "イデオンだね?<br/>1. {{地球}}<br/>2. {{バッフクラン}}"
},
親オブジェクトのmessage中にある{{...}}が子オブジェクトのIDを指しており、これをクリックされたらWebコンポーネントのonSelectScenarioNode関数を呼び出すリンクに変換し、
// scenarioは変換対象のjsonオブジェクト
function convertScenario(scenario: ScenarioNode, parentPath: string = '') {
let currentPath: string = // parentPathとscenario.id結合してパスを生成
[...scenario.message.matchAll(/\{\{(.+?)\}\}/g)]
.forEach(match => {
let childId = match[1];
let childNodePath = `${currentPath}/${childId}`;
scenario.message = scenario.message.replace(`{{${childId}}}`, `<a href="javascript:void(0);" onClick="onSelectScenarioNode('${childNodePath}')">${childId}</a>`)
})
// 変換後の文字列を格納
scenarioMap[currentPath] = scenario.message;
// 子オブジェクトを再帰処理
scenario.nodes?.forEach(node => convertScenario(node, currentPath))
}
function onSelectScenarioNode(nodePath: string) {
// 指定パスの文字列を、messages配列に追加。
messages = [...messages, { text: scenarioMap[nodePath]}]
}
この文字列を {@html ...}で表示させた訳ですが、
<script lang="ts">
let messages: ChatMessage[];
$: messages = [];
let scenarioMap: {[key: string]: string} = {};
onMount(async () => {
const res = await fetch(scenariourl);
let scenario = await res.json();
// jsonオブジェクトの変換
convertScenario(scenario);
// ルートのカテゴリ選択肢を表示
messages = [ { text: scenarioMap['']} ]
});
// 中略
</script>
<div part="body">
{#each messages as msg}
<div>
{@html msg.text}
</div>
{/each}
</div>
実際にクリックしてみると関数は見つかりません。そりゃそうですよね。
解決策を調べているなかで、手っ取り早くwindowオブジェクトに関数を登録してしまうというのがあり、実際に上手くは動くのですが、Webコンポーネントがグローバル空間を汚してしまうのって、やっぱり躊躇われます。
最終的には文字列を、文字とリンクにデータを分けて、Svelteの世界(?)でリンクを表示させるようにしました。
<div part="body">
{#each messages as msg}
<div>
{#each msg.nodes as msgNode}
{#if msgNode.link}
<!-- svelte-ignore a11y-invalid-attribute -->
<a href="javascript:void(0)" on:click={() => onSelectScenarioNode(msgNode.link)}>{@html msgNode.text}</a>
{:else}
{@html msgNode.text}
{/if}
{/each}
</div>
{/each}
</div>
<a href="javascript:void(0)" ...
だと「A11y: 'javascript:void(0)' is not a valid href attribute」になる。
対策として<a href={'#'} ...
とする記事もありましたが、URLの末尾に#がついてしまうのはWebコンポーネントとしては如何なものか?と思い、↑にもありますが、aタグの前に を書いて抑制しました。
HTMLコード部分でTypeScriptのキャストができない。
<a ... on:click={() => onSelectScenarioNode(msgNode.link)}>
の部分ですが、msgNodeの型を当初、
interface MessageNode {
type: string = 'text' | 'link';
text: string;
}
interface LinkMessageNode extends MessageNode {
link?: string;
}
のように継承させ、typeがlinkなら<a ... on:click={() => onSelectScenarioNode(msgNode.link)}>
を生成させるロジックにしようとしたのですが、、、
- そのままだと
Property 'link' does not exist on type 'MessageNode'.ts(2339)
。 -
<a ... on:click={() => onSelectScenarioNode((msgNode as LinkMessageNode).link)}>
だとSvelteのビルドでParseError: Unexpected token
。
とmsgNodeをMessageNodeからLinkMessageNodeにキャストする方法がわかりませんでした。
とりあえずキャストはあきらめてインターフェースを修正しました。
interface MessageNode {
text: string;
link?: string;
}
ブラウザでデバッグしたいけど行番号がおかしい。
既知の問題でした。
まとめ
よくないところを挙げ連ねる典型的な日本人な内容ですが、私のSvelteへの期待度は高まるばかりであり、これらの事例を共有することでSvelteに挑戦する方への一助となれば良いなと思います。