⚛️

Declarative Shadow DOMを試す

に公開

コードはここに。
https://github.com/musou1500/webcomponents-nodejs

テンプレートエンジン等を使ってHTMLをレンダリングして返す、というような仕組みのアプリケーションにインタラクティブなUIを組み込む方法を模索したい。

普通にReactを入れても良いんだけど、そんなに複雑なことはやらないからビルド周りを複雑にしたくない、静的ファイルポン置きでなんとかしたい、というケースを想定して、Declarative Shadow DOM + EJSを試した。

つくるもの

UIフレームワークのデモでよくある、名前を入力すると Hello, ${名前}! というようなメッセージがテキストで表示されるものを作ってみる。

マークアップはこんな感じ。

<app-greeter default-name="John">
  <template shadowrootmode="open">
    <input type="text" placeholder="Enter your name" id="input" />
    <p id="output"></p>
  </template>
</app-greeter>

ハマったこと

connectedCallback メソッド内で shadowRoot プロパティが null になってしまう

下記のようなコードを書いていていると例外が投げられる。

    connectedCallback() {
      if (!this.shadowRoot) {
        throw new Error("shadow root is null");
      }

これは script タグを app-greeter タグより前に置いていたことが原因だった。

マークアップを毎回書くのが面倒

先述したマークアップのコードにある通り、本来コンポーネント側に隠蔽されていてほしいマークアップもコンポーネントを使う側で書く必要がある。
本来は <app-greeter default-name="John" /> とするだけで何とかなって欲しいけど、それを実現しようと思うとJSで要素を初期化処理を書く必要がある。
現状ではテンプレートエンジンの機能でなんとかするのが良さそう。EJSだと include が使える。

<!-- greeter.ejs -->
<app-greeter default-name="<%= locals?.defaultName %>">
  <template shadowrootmode="open">
    <input type="text" placeholder="Enter your name" id="input" />
    <p id="output"></p>
  </template>
</app-greeter>

<!-- index.ejs -->
<%- include("greeter") -%>
<%- include("greeter", { defaultName: "John" }) -%>

スタイル定義について

普段はBEMでやることが多いんだけど、Shadow DOMの中の要素は、外で定義されたスタイルの影響を受けない。

<style>
.inside {
  color: red;
}
.outside {
  color: red;
}
</style>

<app-custom-element>
  <template shadowrootmode="open">
    <!-- 赤色にならない! -->
    <p class="inside">inside</p>
    <slot></slot>
  </template>
  <!-- ここは赤色になる -->
  <p class="outside">outside</p>
</app-custom-element>

Shadow DOMの中の要素にスタイルを当てたい場合、CSS変数か、Shadow Partを使うと良い。

<style>
/* CSS変数を使うか… */
app-custom-element {
  --app-custom-element-color: red;
}

/* Shadow Partを使う */
app-custom-element::part(inside) {
  color: red;
}
</style>

<app-custom-element>
  <template shadowrootmode="open">
    <style>
    .inside {
      color: var(--app-custom-element-color);
    }
    </style>
    <p class="inside" part="inside">inside</p>
  </template>
</app-custom-element>

既存のスタイル定義を使いたいという場合にどうするか、というのは気になった。というのも、Shadow DOMの中の要素は、外で定義したスタイルの影響を受けないからだ。

<link rel="stylesheet" href="/my-css-library.css" />
<app-custom-element>
  <template shadowrootmode="open">
    <!-- ここにスタイルは反映されない -->
    <button class="button button--primary">primary button</button>
  </template>
</app-custom-element>

こういうケースでは、templateタグの中でCSSを読み込めば良い。

<app-custom-element>
  <template shadowrootmode="open">
    <link rel="stylesheet" href="/my-css-library.css" />
    <!-- スタイルが反映される! -->
    <button class="button button--primary">primary button1</button>
  </template>
</app-custom-element>

<app-custom-element2>
  <template shadowrootmode="open">
    <link rel="stylesheet" href="/my-css-library.css" />
    <!-- スタイルが反映される! -->
    <button class="button button--secondary">secondary button</button>
  </template>
</app-custom-element2>

何回も linkタグを書くと、読み込みやパース、メモリオーバーヘッドの観点で問題があるかも、とも思ったが、問題ないようだった。

https://web.dev/articles/declarative-shadow-dom?hl=ja
同じスタイルシートが複数の宣言型シャドウルートに存在する場合、そのスタイルシートは 1 回だけ読み込まれ、解析されます。ブラウザは、すべてのシャドールートによって共有される単一のバッキング CSSStyleSheet を使用するため、重複するメモリ オーバーヘッドがなくなります。

感想

Declarative Shadow DOMを使うことによって、JSで書き下すと煩雑になりがちな要素の初期化処理を宣言的に書くことができて便利だった。ただ、やっぱり状態をDOMに反映するという部分については命令的に処理を書く必要があり、それも含めて宣言的に書けるReactって偉いなぁと思った。

Discussion