🎨 CSS Advent Calendar: Day 16 / Hard Core Scoping of Standard

Published on

Updated on

標準側での「スタイルのカプセル化」

Table of Contents

Table of Contents

はじめに

これまでに、エコシステム側で「スタイルのカプセル化」、具体的には「セレクタスコーピング」する手段を見てきました。

The W3C Markup Validation Service
W3C's easy-to-use markup validation service, based on SGML and XML parsers.
The W3C Markup Validation Service favicon validator.w3.org
The W3C Markup Validation Service

Shadow DOM

Web 標準で「カプセル化」を実現する手段として真っ先に上がるのは、おそらく Shadow DOM でしょう。

例えば、SVG 要素をクローンする <use> は、SVG を Shadow DOM の中に Clone します。

use タグを用いて SVG をクローンした時に用いられる Shadow DOM
use タグを用いて SVG をクローンした時に用いられる Shadow DOM


Form Controls の In-Page 要素も Shadow DOM を利用してカプセル化されている良い例です。

Form Controls の In-Page 要素に使われる Shadow DOM
Form Controls の In-Page 要素に使われる Shadow DOM

このほか一部の Form Controls の In-Page 部分や、 <details>&<summary> などでも Shadow DOM が利用されています。


以下の例では、Shadow DOM 内ユニバーサルセレクタが、 Light DOM 要素に影響を与えず、Light DOM からのスタイルが Shadow DOM 内の要素に影響を与えないことを示しています。
このように、Shadow DOM は「スタイルのカプセル化」を実現する手段として、特に Web Components の文脈で耳にすることが多いのではないでしょうか。

See the Pen Untitled by saku (@sakupi01) on CodePen.

Shadow DOM Style Sharing

複数のスタイルシートを DOM に適用/共有する方法の模索も進んでいます。

Shadow DOM とスタイルを共有する以上、そのスタイルはカプセル化されている必要があり、スタイルのスコーピングに関連してくるので、ここで現状を整理しておきます。

Shadow Tree 内で外部スタイルシートを読み込ませる方法として、 <link rel="stylesheet">@import を利用することができます。

<!-- link rel="stylesheet" -->
<my-element>
  <template shadowrootmode="open">
    <!-- スタイルシート 1 -->
    <link rel="stylesheet" href="/theme.css">
    <!-- スタイルシート 2 -->
    <link rel="stylesheet" href="/component.css">
  </template>
</my-element>

<!-- @import -->
<my-element>
  <template shadowrootmode="open">
    <style>
      /* スタイルシート 3 */
      @import url("/theme.css");
      /* スタイルシート 4 */
      @import url("/component.css");
    </style>
  </template>
</my-element>

ただ、複数のスタイルを適用/共有する際、同じスタイルシートでも各コンポーネントのスタイルシート毎にロードが発生するため、大量の Web Components 全てに共通スタイルを当てたい場合などは特にパフォーマンスの懸念がありました。

Constructable StyleSheets & adoptedStyleSheets

そこで、「共通の Constructable なスタイルシートを作成し、同期的に複数の DOM に適用可能にする」、Constructable StyleSheet(スタイルシートを CSSOM としてプログラムから操作可能にするインスタンス)と adoptedStyleSheets の実装が 2023年に揃いました。

基本的な使い方としては、Shadow/Light 問わず DOM 間で共通したスタイルシートインスタンスである Constructable StyleSheet を、new CSSStyleSheet() で作成し、その中にスタイルを書き込み、adoptedStyleSheets プロパティに追加することで、DOM に適用します。

const css = new CSSStyleSheet()
css.replaceSync(`
p {
  color: #00f;
}
`)

class AdoptedCss extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({mode: 'open'})
    this.shadowRoot.adoptedStyleSheets = [css]
  }

  connectedCallback() {
    this.render()
  }

  render() {
    this.shadowRoot.innerHTML = `<p>Adopted CSS</p>`
  }
}

customElements.define('adopted-css', AdoptedCss)

ただし、新たな課題として、JS 側で定義した CSS のみからしか Constructable StyleSheet を作成するため、CSS のパースを JS/CSS どちら側でも行う必要があります。

これへの対応としては、JS ではなく、.css で CSS を記述し、Constructable StyleSheet を作成する方法として提案されている、CSS Module Scripts(Imperative/Declarative)が利用できます。

CSS Module Scripts - Load Constructable StyleSheets Imperatively

コミュニティベースの CSS Module Scripts ではなく、標準側において ES Modules として Constructable StyleSheet を読み込む手段です。

CSS Module Scripts で Constructable StyleSheet としてスタイルシートをインポートし、adoptedStyleSheets プロパティでコンポーネントに適用します。

/*non-Declarative CSS Moudule Scripts*/
import themeSheet from './theme.css' with { type: 'css' };
import componentSheet from './component.css' with { type: 'css' };
// Constructable StyleSheet を adoptedStyleSheets で同期的に適用する
document.adoptedStyleSheets = [themeSheet, componentSheet];
shadowRoot.adoptedStyleSheets = [themeSheet, componentSheet];

Constructable Stylesheets の図
出典:Constructable Stylesheets  |  Articles  |  web.dev

CSS Module Scripts + Constructable StyleSheet + Adopted StyleSheets を組み合わせることで、特定のスタイルシートを同期的にコンポーネントに適用するだけでなく、Light DOM と Shadow DOM の両方でスタイルシートを共有することができます。
Web Components の文脈においては、Light DOM のグローバルスタイルのみを Shadow DOM (Web Components)に適用したいといった需要があるため、CSS Module Scripts は Web Components の文脈で特に重要になってきます。

Declarative Shadow DOM Style Sharing - CSS Module Scripts? or … @sheet

とはいえ、CSS Module Scripts は JS-way です。
2024年に Declarative Shadow DOM の実装が揃ったこともあり、宣言的なユースケースに対応した方法の需要が高まっているのが昨今です。
複数のスタイルシートを宣言的に DOM に適用する方法として、現状では主に以下の2つが議論中です。

Declarative CSS Module Scripts & Declarative Shadow DOM adoptedstylesheets attribute

まず、宣言的に Constructable StyleSheet を作成し、adoptedStyleSheets 属性で DOM にスタイルシートを適用する方法です。

<!-- module specifier(.css) を import attribute の代わりに使って、 -->
<!-- 宣言的に Constructable StyleSheet を作成し、 -->
<script type="css-module" specifier="/foo.css">
  #content {
    color: red;
  }
</script>
<script type="css-module" specifier="/bar.css">
  #content {
    font-family: sans-serif;
  }
</script>
<my-element>
  <!-- adoptedstylesheets 属性で Constructable StyleSheet を参照する -->
  <template shadowrootmode="open" adoptedstylesheets="/foo.css, /bar.css">
    <!-- blah blah... -->
  </template>
</my-element>

@sheet

また、そもそも Constructable StyleSheet を経由せず、複数のスタイルシートを直接 DOM に適用する宣言的な方法として、 @sheet が存在します。

@sheet は、ひとつのCSSファイル内で複数のシートを宣言できる at-rule です。

@sheet で特徴的なのは、スタイルシートの実質的な「ネイティブ手動バンドル」を可能にしている点です。
これにより、複数のスタイルシートを最小のネットワークリクエストで読み込むことができ、1つの通信で複数のスタイルシートが一回の通信で供給可能になることが @sheet の旨みです。

ただし、@sheet で複数のスタイルシートを一つにまとめるので、それらを取り出す手段が必要になります。
これに関しては、<link>sheet 属性を追加し、 #fragment-identifier を指定することで、特定の @sheet を参照可能にする Local References In <link> Tags の議論が進んでいます。

@sheet foo {
  div {
    color: red;
  }
}

@sheet bar {
  div {
    font-family: sans-serif;
  }
}

div {
  color: blue;
}
<style id="sheet">
    /* 以下のインポートは、単一のネットワークリクエストで済む */
    @import "external.css#foo";
    @import "external.css#bar";
</style>
<template shadowrootmode="open">
   <!-- foo.css 内の bar sheet だけを参照  -->
  <link rel="stylesheet" href="#sheet" sheet="foo" />
  <!-- blah blah... -->
</template>

Scoped CSS (<style scoped>)

かつて HTML に存在していた <style scoped> についても触れておきます。

<style scoped> は、HTML において、特定の要素内でのみスタイルを適用するための属性として提案されていました。

<style scoped> は、特定の HTML 区間にスタイルの適用範囲を限定する機能として提案されていました。
Shadow DOM とは異なり、外部からのスタイルは引き続き影響を与えることができ、内部のスタイルが外部に漏れ出さないようにする、いわゆる「片方向」のカプセル化を実現するものです。

<div>
  <!-- 親要素からのスタイルの影響は受ける -->
  <style scoped>
    /* このスタイルは親要素とその子孫にのみ適用される */
    p { color: red; }
  </style>
  <p>このテキストは赤色</p>
</div>
<p>このテキストは赤色ではない</p>

しかし、<style scoped> は 2016年に HTML から削除されています。

大きな理由としては、実装の複雑さと、より強力なカプセル化を実現する Shadow DOM の仕様策定が同時期に進んでいたことが挙げられます。

しかし、興味深いことに、CSS 側の @scope では、<style scoped> と似たようなカプセル化を提供しながらも、仕様策定・実装に至っています。
HTML で Removal となり、CSS で実現した背景に関しては、また後日見て行こうと思います。

<iframe>

最後に、<iframe> もスタイルをカプセル化する手段の一つであると言えます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="styelesheet" href="i-want-to-override.css">
  <style>
    iframe {
        width: 50%;
    }
   </style>
</head>
<body>
    <main>
        <iframe src="https://i-cannot-be-overridden.com" style="border: none; width: 100%; height: 500px;"></iframe>
    </main>
</body>
</html>

<iframe> にはセキュリティ上の理由から、スタイルの授受に関して閉塞的です。

<iframe> に対して外部からスタイルを適用することはできませんし、<iframe> が外部のスタイルに影響を与えることもできないため、実態としてはスタイルを「カプセル化」していると言えます。

Appendix