lit-html と ShadyCSS

lit-html が v0.9.0 で ShadyCSS をサポートするようになったということなので、いろいろと調べてみた。

TL;DR

  • 現時点では、Shadow DOM のスタイルカプセル化に対応するには ShadyCSS を使う必要がある。
  • lit-html 経由で ShadyCSS を使うと便利。

Shadow DOM の Polyfill

2018 年 2 月現在、各ブラウザの Shadow DOM v1 のサポート状況は次のようになっている。

  • Chrome と Opera は済み 🙆‍
  • Safari と iOS Safari は一部バグあり 🤔
  • Firefox と Edge はまだ 🙅‍

(参考:https://caniuse.com/#feat=shadowdomv1

したがって、幅広いブラウザに対応するには、 現段階では Web Components の Polyfill (webcomponents.js) の利用が前提となる。

Polyfill には webcomponents-lite.jswebcomponents-sd-ce.js などターゲットブラウザとサポートしたい仕様に応じていくつかのファイルが公開されているが、これらのうち「Shady DOM/CSS をサポート」となっているものには、実は「Shadow DOM のスタイルがカプセル化されない」という問題がある。(Shady CSS をサポートって書いてあるのに!!)

Shadow DOM のスタイルカプセル化とは

例えば <my-avatar> という次のような Custom Element があったとする。

import { html, render } from 'lit-html';

class MyAvatar extends HTMLElement {
  //...
  _render() {
    const template = html`
    <style>
      img {
        border-radius: 50%;
        border: 1px solid #ccc;
      }
    </style>
    <img src="${this.src}" width="160" height="160" />
    `;
    render(template, this.attachShadow({ mode: 'open' }));
  }
}

element.attachShadow() で Shadow DOM を生成しているので、ここで定義した <img> のスタイルは Shadow DOM 内にのみ適用される = カプセル化されているというのが期待する挙動だ。

それを確認するために、次のような HTML を用意してみる。

<img src="https://randomuser.me/api/portraits/lego/7.jpg" width="160" />
<my-avatar src="https://randomuser.me/api/portraits/lego/7.jpg"></my-avatar>

Chrome 64 と Firefox 58 でそれぞれ確認した結果がこれ。

Chromeでの結果(左:<img> 右:<my-avatar>)
Chrome 64 での結果(左:<img> 右:<my-avatar>
Firefox 58 での結果(左:<img> 右:<my-avatar>)
Firefox 58 での結果(左:<img> 右:<my-avatar>

Chrome では、期待どおり <my-avatar> にだけスタイルが適用されているのがわかる。

しかし、Firefox の場合、<my-avatar> で定義したスタイルが通常の <img> にも適用されてしまっている。Shadow DOM 内のスタイルが外に漏れ出しているのだ。逆に Shadow DOM の外で定義されたスタイルも Shadow DOM 内を汚染することになる。

ShadyCSS とは

この問題を解決するためのライブラリ(Polyfill)が ShadyCSS だ。「スタイルのカプセル化」をサポートしたい場合、webcomponents.js の Polyfill に加えて、ShadyCSS も使う必要がある。

https://github.com/webcomponents/shadycss

ShadyCSS の処理を通すと、Firefox などのブラウザでは、上記の <my-avatar> のコードが次のように擬似的な Scoped Style に変換される。

<head>
  <style scope="my-avatar">
    img.kz-avatar {
      border-radius: 50%;
      border: 1px solid #ccc;
    }
  </style>
</head>
<body>
  <kz-avatar src="https://randomuser.me/api/portraits/lego/7.jpg">
    <img class="style-scope kz-avatar" src="https://randomuser.me/api/portraits/lego/7.jpg" height="160" width="160">
  </kz-avatar>
</body>

ただ、Usage にあるように素で使おうとすると結構めんどくさいコードを書く必要がある。

でも、lit-html 経由で ShadyCSS を利用するととても簡単に書くことができる。

lit-html で ShadyCSS を使う

というわけで、ようやく本題。

lit-html で ShadyCSS を利用するには、 lit-htmlrender() の代わりに lit-html/lib/shady-render.jsrender() を使う。

import { html, render } from 'lit-html/lib/shady-render.js';

class MyAvatar extends HTMLElement {
  static get is() {
    return 'my-avatar';
  }
  //...
  _render() {
    const template = html`
    <style>
      img {
        border-radius: 50%;
        border: 1px solid #ccc;
      }
    </style>
    <img src="${this.src}" width="160" height="160" />
    `;
    render(template, this.attachShadow({ mode: 'open' }), MyAvatar.is);
  }
}
window.customElements.define(MyAvatar.is, MyAvatar);

lit-htmlrender() との違いは、第三パラメータにスコープ名を渡す必要がある点。ここで渡した値が <style> の scope 属性にセットされる。これは、タグ名と一致している必要がある。いずれにせよ、最小限のコードで ShadyCSS が使えるのはうれしい。

ただし、制約もあって、例えば、

const template = html`
<style>
  img {
    width: ${this.size}px;
  }
</style>
<img src="${this.src}" width="160" height="160" />
`;

というように、 <style> の中で変数を展開しようとすると、

<style scope="my-avatar">
img.kz-avatar {
  width: <!-- {
    lit-8364277839110217
  }
}
</style>

というように変換されてしまってうまくいかない。こういうユースケースに対応したいならば、おとなしく Polymer などのライブラリを利用するほうがよいだろう。

まとめ

というわけで、Firefox と Edge が Shadow DOM をサポートするまでは、lit-html + ShadyCSS の組み合わせが有力な選択肢になりそう。