Webコンポーネント - シャドウDOM用テンプレート・ビューポートパターン

ここ数ヶ月、フロントエンドにネイティブのWebコンポーネントを使用する新しいノーコード(HTMLマークアップのみ)の「サーバレス」プラットフォームの開発に取り組んできました(https://saasufy.com/をご覧ください)。

早期に直面した問題の1つは、シャドウDOMを使用するコンポーネントのスタイリングに関連していました。知らない人のために、シャドウDOMとは、主なDOMに汚染せず、外部からスロットされた子要素と衝突したり上書きしたりせずに、実行時にコンポーネント内で新たな子要素を生成する機能をサポートするメカニズムです。

以前、シャドウDOMを使用せずにコンポーネントを作成していましたが、スロットされたコンテンツ/HTMLとコンポーネント内部から追加のHTMLを生成する必要がある状況に遭遇しました。

テンプレート

構築したかったコンポーネントは、一般用途のcollection-browserコンポーネントでした。このコンポーネントの目的は、バックエンドサーバーからのデータで任意に複雑なHTMLテンプレートを埋めてコレクション内の各エントリーを表示するカスタマイズ可能なリストをレンダリングすることでした。シャドウDOMにスロットされた<template>要素を使ってこれを実現できることに気づきました。

<template slot="item">
  <div>Hello {{username}}!</div>
</template>

残念ながら、シャドウDOM内で埋めたテンプレートをレンダリングすると、外部のCSS定義は適用されませんでした。これはcollection-browserコンポーネントを複数の異なるプロジェクトや会社全体で「一般用途」として使えないことを意味し、スタイリングの柔軟性が欠けていることになります。

シャドウDOM内の要素に外部CSSスタイルが適用されない理由は、シャドウDOMがスタイリングを完全にカプセル化するためです。これは、シャドウDOMからのスタイル定義が外部の要素に影響を与えないこと、また、シャドウDOM内の要素がコンポーネント外で定義されたCSSでスタイリングできないことを意味します。これは、一般用途のコンポーネントを構築する際に大きな問題を引き起こします。

シャドウDOMにスタイル情報を「注入」する方法があるものの、これらのアプローチは馴染みがなく複雑さを増します。collection-browserコンポーネントのHTMLは、魔法なしでページの主要なCSSスタイルに自動的に従う必要がありました。

これによりtemplate-viewport patternと呼ばれる単純なパターンを発見しました。シャドウDOM内で生成されたHTMLは外部でスタイルが適用されないため、コンポーネント内部で生成されたHTMLはシャドウDOMの外部(ライトDOM内)にインジェクトされる必要がありました。

ビューポート

これを可能にする最も単純な解決策は、「ビューポート」要素という概念を考案することでした。これはcollection-browserコンポーネントがスロットされた2つの要素を受け入れる必要があることを意味します。

  • 出力HTMLを生成するための入力として使用するテンプレート。
  • 出力HTMLのコンテナとして機能するビューポート要素。

この背後にある考え方は、ビューポート要素が外部からcollection-browserコンポーネントにスロットされる場合、その中にインジェクトされた (コンポーネントによって内部で生成されたものも含む)あらゆる要素は、ライトDOMの一部になるため、ページの主要なCSSスタイルに従います。

Webコンポーネントでスロットされた要素を扱うのは単純です。私はどのようにコンポーネントに対してrender()メソッドを定義しました。スロットされた要素のためのプレースホルダータグをこのように生成します。

this.shadowRoot.innerHTML = `
  <slot name="item"></slot>
  <slot name="viewport"></slot>
`;

ここで、name="item"というスロットは外部からスロットされた<template slot="item">要素を保持し、name="viewport"というスロットはスロットされた<div slot="viewport"></div>要素を保持します。

Shadow DOMの<slot name="viewport">要素を通じてコレクションブラウザー内からライトDOMビューポート要素を参照する方法は次のようになります。

let viewportNode = this.shadowRoot
  .querySelector('slot[name="viewport"]').assignedNodes()[0];

それを中にHTMLをレンダリングすることは、そのinnerHTMLプロパティをviewportNode.innerHTML = ...のように設定するだけの問題です。

コンポーネントの実例

外部から見たコンポーネントは次のようになります。

<!--
  サーバーからいくつかのチャットメッセージを読み込み、
  下部にあるslot="viewport"要素にテンプレートを基にして
  各メッセージをレンダリングする。
-->
<collection-browser
  collection-type="Chat"
  collection-fields="username,message,createdAt"
  collection-view="recentView"
  collection-view-params=""
  collection-page-size="50"
>
  <template slot="item">
    <div>
      <div class="chat-username">
        <b>{{Chat.username}}</b>
      </div>
      <div class="chat-message">{{Chat.message}}</div>
      <div class="chat-created-at">{{date(Chat.createdAt)}}</div>
    </div>
  </template>

  <div slot="viewport" class="chat-viewport"></div>
</collection-browser>

この構造は堅牢であり、任意のネストの量でうまく機能します。例えば、リスト内にリストを生成する別のcollection-browserを含むテンプレートをレンダリングするcollection-browserを持つことができます。

このアプローチのもう一つの良い使用例は、現在のURLのlocation.hashに基づいて異なるテンプレートを生成するapp-routerコンポーネントを構築することでした。

<app-router>
  <template slot="page" route-path="/home">
    <div>ホームページです</div>
  </template>

  <template slot="page" route-path="/about-us">
    <div>アバウトアスページです</div>
  </template>

  <template slot="page" route-path="/products">
    <div>商品ページです</div>
  </template>

  <div slot="viewport"></div>
</app-router>

再び、これは任意のネストレベルで良く機能し、異なるコンポーネントを混ぜ合わせることができます。

編集

このcollection-browserおよびapp-routerコードが動作するアプリを見たい場合は、GitHubリポジトリをチェックしてください: https://github.com/Saasufy/product-browser-demo/blob/main/index.html#L47-L80 - どこでも実行できます(Gitクローンするだけですが)、私と同じく怠け者の場合は、ここでホストされたバージョンを試してみたいでしょう: https://saasufy.github.io/product-browser-demo/index.html#/sport

このガイドで触れたチャットの例のリポジトリはこちらで見つけることができます: https://github.com/Saasufy/chat-app そしてこちらでホストされています: https://saasufy.github.io/chat-app/(ログインフォームの下部にあるリンクをクリックしてGitHubでログインできます)。

とにかく、それだけです。このパターンがあなた自身のプロジェクトで役に立つことを願っています。このガイドが役に立ったと思ったら、いいねボタンを押すことを躊躇しないでください。

* コンポーネントのシャドウDOM内の要素に明示的にスタイルを適用する1つのアプローチは、CSSパーツAPIを使用することです。https://developer.mozilla.org/en-US/docs/Web/CSS/::partを参照してください。このアプローチは、内部の要素にスタイルを適用したいが、ページのスタイル定義に従ってほしくない場合に理想的です。

こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/jondubois/web-components-the-template-viewport-pattern-for-the-shadow-dom-2gm4