Web Components関連技術を始めとするモダンブラウザーの機能でiframeを代替する


こんにちは。ニコニコQセクションのHajime-san(GitHub)です。

所属部署では開発に携わっている「ニコニコオーディション」のシステム間APIやWebフロントエンドモジュール(以後、ウィジェット)をファミリーサービスに提供しています。ウィジェットはこれまでiframeによるHTMLの埋め込みによって実現されてきました。本稿では、この度Web Components関連技術を始めとするモダンブラウザーの機能によってiframeを代替するまでの経緯、具体的な実装およびそれらを支える技術仕様などを紹介します。

変更点

本稿で取り上げる内容を簡潔にまとめると、ニコニコオーディションのウィジェットを利用する側のHTMLの変更点は以下の通りになります。ウィジェットの利用者側で必要な変更は、iframe要素およびそのiframeに対してpostMessageを実行するscript要素の代わりに新たなJavaScriptファイルをscript要素として設置するだけです。

<!DOCTYPE html>
...
-   <iframe src="https://example.com/service.html?id=1&foo=bar"></iframe>
-   <script src="https://example.com/postMessage.js"></script>
+   <script type="module" src="https://example.com/service.js?id=1&foo=bar"></script>
...
</html>

ニコニコオーディションの紹介

当ブログで以前に筆者が執筆した記事にも登場していますが、改めてニコニコオーディションについて紹介します。

ニコニコではサービスのお知らせを集約しているニコニコインフォがあります。 また、開催するイベントによってはドメインが*.nicovideo.jpではない特設ページを設置することがあります。ニコニコオーディションでは、これらのWebページ向けにニコニコオーディションのバックエンドシステムやその他ニコニコのファミリーサービスや内部サービスなどから取得したデータを基に、イベントに参加するための動的なエントリーフォームやランキング情報などを表示するウィジェットとして提供しています。例えばエントリーフォームのウィジェットをニコニコインフォに埋め込んだ際の表示例は以下のようになります。

ニコニコインフォに埋め込んだエントリーフォームのウィジェット例

このウィジェットには、ブラウザーの幅やランキングの件数に応じて動的な表示、およびイベントに紐づけるファミリーサービスやユーザーが利用しているネイティブアプリの種別に応じてリンク先の分岐が必要になります。

ウィジェットはこれまでiframeによるHTMLの埋め込みによって実現されてきました。これらの要求に対応するため、非同期のpostMessageを用いて親ドキュメントとウィジェット間で通信し、ウィジェットの高さや表示内容を動的に変更していました。

iframeの抱える問題

しかしながら、サービスの要求が複雑になるにつれ埋め込み元である親ドキュメントのURLや、そのドメインに紐付けられているブラウザーのストレージなどの機能が必要になりました。これらは1つずつpostMessageとしてウィジェットに対してデータを送信可能ではあるものの、実装の複雑化が懸念されました。

また、ウィジェットの種類自体も増加しており、一般的にiframeを1つのドキュメントに対して多数設置することはユーザー端末におけるパフォーマンスの悪化を招く1と知られています。

これらに対し、Web Components関連技術によって解決できないかという案が浮かび上がりました。実装自体は試行錯誤を重ねつつ最終的にサービスの要求を満たすものとなりました。とはいえ、その時点では筆者にとってWeb Components関連技術およびその比較対象であったiframeはほぼブラックボックスであると感じていました。

そこで、この移行に説得力のある技術仕様やブラウザーの実装などの裏付けがあることで、今後ウィジェットを拡張する際により自信を持って取り組んだり説明できると考えました。以降では、まずiframeを支える技術仕様から確認していきます。

iframe要素を配置すると起きること

iframe要素は置換要素2の1つであり、その内容はCSSの視覚整形モデルの対象外です。また、デバイスが十分な画面の大きさを持つ場合には、既定の表示サイズとして幅300px高さ150pxを持ちます。 加えて、iframe要素はナビゲーブル3かつ子ナビゲーブルです。 その表示領域を変更するには、例えば親ナビゲーブルにおいて対象iframe要素のwidth属性およびheight属性、あるいはCSSで調整することになります。 この手法は予めiframe要素の表示領域が静的に定まる場合は有効ですが、動的に幅や高さが決まる場合には適していません。 この時、多くの場合に子ナビゲーブルで適当な表示領域を取得し、postMessageを用いて親ナビゲーブルへ送信し、親ナビゲーブルは受け取った表示領域をiframe要素に設定するという手法が用いられていることでしょう。

それ以外では、親ナビゲーブルとiframe要素である子ナビゲーブルが同一オリジンの場合は親ナビゲーブルの閲覧コンテキスト4に含まれるレルム(Realm)グローバルオブジェクトwindow.parentで、子ナビゲーブルに対してはHTMLIFrameElement.contentWindowで、それぞれWindowProxyを通じて取得できます。ただし、オリジンが異なる場合5は、CrossOriginPropertiesで列挙されているlocationparentpostMessageなどにアクセスできる一方、それ以外のプロパティへのアクセスは大きく制限されます。

また、iframe要素がナビゲーブルを通じて閲覧コンテキストを作成する際、類似オリジンウィンドウエージェントおよびレルムなどを始めとするリソースを都度必要とすることにより、iframe要素が増えるたびに、特にメモリの少ないモバイル端末への懸念があることは容易に想像できます。

Web Components

Web Componentsはカスタム要素、シャドウDOM、template要素およびslot要素から成る、再利用可能なカプセル化されたHTML要素を構築するための技術の総称です。本稿の題材となるウィジェット実装では、これらの技術に加えてESモジュールも活用し、可能な限りiframeの代替となるような構成を目指しました。

iframeとWeb Components関連技術の違い

まず、JavaScriptの観点からみるとiframeとWeb Componentsでは主に実行環境の分離に関する振る舞いが異なります。この違いがWeb Componentsを用いたウィジェットに移行した際のレンダリングなどのパフォーマンス向上に寄与すると考えています。そのような差異を導いた要因として、ブラウザーの仕様であるエージェントやECMAScript仕様のグローバル環境レコードなどに触れながら解説します。

JavaScript

前述のJavaScript実行環境の分離について、iframeではエージェント単位の分離が実現されます。一方、Web Components関連技術ではモジュール6を利用することで、グローバル環境レコードへの影響を低減できる可能性があります。

以降で扱うJavaScriptコードはブラウザー環境のものを想定します。

JavaScriptの実行にはエージェントというアーキテクチャに依存しない並行性を制御するための実行スレッドの仕組みがあります。 例えばa.example.comというページにアクセスして、そのHTMLにb.example.comというオリジンのiframe要素が含まれる場合、ブラウザーは類似オリジンウィンドウエージェント7を2つ作成します。

エージェントを構成する実行コンテキストは、JavaScriptエンジンによるコードの実行時評価を追跡するための仕様上の概念およびその実装です。ある時点で実際にコードを実行している実行コンテキストは各エージェントにつき高々1つです。 実行コンテキストはまた、実行コンテキストに関連付けられたコードを生成したクラシックスクリプト(ECMAScript)(HTML)もしくはモジュールを、それらが利用するグローバルオブジェクトなどを保持するレルムを構成要素として持ちます。このグローバルオブジェクトはECMAScript実装より例えばStringDateなどが含まれます。 更に、グローバルオブジェクトをブラウザー仕様にマッピングする際、レルムは、類似オリジンウィンドウエージェントに所属する場合はwindow、その他のエージェントに所属する場合は基底インターフェースであるWorkerGlobalScope.self、などをそれぞれECMAScript実装に加えてグローバルオブジェクトに設定します。

例えばa.example.comというページにアクセスして、そのHTMLにa.example.com/foo.htmlというsrc属性のiframe要素が含まれる場合、ブラウザーは類似オリジンウィンドウエージェントを1つ、レルムを2つ作成します。 このようにエージェントが同一でもレルムが異なる場合は、a.example.comwindowのプロパティに好きな値を代入しようともレルムが異なるa.example.com/foo.htmlwindowには影響を及ぼさないという分離が実現されます。

ただし、類似オリジンウィンドウエージェントが同一の場合にこのレルム単位の分離が有効でない場面も存在します。 localStorageなどの一部のグローバルオブジェクトはオリジン、タイムスタンプの精度などの、 ブラウザーの行動基準を規定する環境設定オブジェクトを参照します。これにより、現状ではグローバルオブジェクト自体がレルム単位で分離されていても、オリジンが同一であれば、実行結果は各実行コンテキストで評価されるコードに依存します。例えば、a.example.comというページでlocalStorage.setItem('key', 'value');を記述し、そのHTMLへa.example.com/foo.htmlというsrc属性のiframe要素を含めるとします。このとき、a.example.com/foo.htmllocalStorage.getItem('key');を記述すると、"value"が取得できます。

次に、前述のグローバル環境レコードについて触れる前に、その基底概念である環境レコードについて簡潔に説明します。 環境レコードは、JavaScriptコード構文のネスト構造に基づいて、識別子と特定の変数および関数との関連付けを定義するために使用される基底クラスと例えることが可能な仕様概念であり、外部環境への参照である[[OuterEnv]]の値を持ちます。 それを継承するモジュール環境レコードは、当該モジュールにおけるトップレベルの宣言やimportされた別のモジュールへの束縛を持ち、[[OuterEnv]]はグローバル環境レコードが紐づけられます。

グローバル環境レコードは、単一のレルムにおける組み込みグローバル変数への束縛、グローバルオブジェクトのプロパティやクラシックスクリプトにおける最上位レベル宣言から構成されます。

例えばa.example.comというページにアクセスして、そのHTMLにscript要素が2つあり、1つはクラシックスクリプトでもう1つはモジュールの場合、それらが参照するレルムは同一です。 この時、クラシックスクリプトで宣言された最上位レベルの変数などはグローバル環境レコードの最上位レベル宣言として他のモジュールあるいはクラシックスクリプトから操作可能になります。 一方、それらがモジュールで宣言された場合、他のクラシックスクリプトあるいはモジュールから操作できません。 とは言うものの、モジュール環境レコードの[[OuterEnv]]は、レルムにおけるグローバルオブジェクトのプロパティを操作可能なため、windowの特定のプロパティに値を代入するなどして他のクラシックスクリプトあるいはモジュールに影響を与えることが可能です。

...
<script>
    let a = 1;
    (function () {
        let b = 2;
    })();
</script>
<script type="module">
    console.log(a); // 1
    console.log(b); // Uncaught ReferenceError: b is not defined
    let c = 3;
</script>
<script>
    console.log(c); // Uncaught ReferenceError: c is not defined
</script>
...

これらを改めてまとめると、iframeでは一部の例を除いてレルムやエージェントなどのJavaScript実行基盤ごと分離されます。そして、それらを支える様々なリソースが作成されることにより、クライアント端末へのパフォーマンス影響は大きくなります。 モジュールを用いる場合、前述のような分離は実現できないものの、比較的少ないリソースで、記述次第で他のクラシックスクリプトあるいはモジュールへの影響をより低減できる可能性があります。

CSS

続いてはCSSについて見ていきましょう。iframeは親ナビゲーブルであるドキュメントと自身として子ナビゲーブルであるiframe要素それぞれのドキュメント単位の分離が可能です。Web Components関連技術では、シャドウDOMを利用したセレクターの分離やCSSプロパティの継承など柔軟な表現が実現できます。

CSSの設計原則として、カスケードによって複数のスタイルシートから単一のドキュメントの表示に影響を及ぼすことが可能になっています。iframeによるドキュメントの分離を示す例として、例えば親ナビゲーブルであるドキュメントにこのような宣言を含むスタイルシートが存在するとします。

body {
  font-family: "My Cool Font";
}

その子ナビゲーブルであるiframeはドキュメントが異なることによりそのスタイルシートとは通常、関係性を持ちません8

これに対し、Web Components関連技術ではシャドウDOMを活用できます。シャドウDOMの主な挙動としては、外部への影響なくスコープ化されたスタイルシートを記述できる点がよく挙げられます。実際にその挙動を再現するための仕様としては、セレクターの照合アルゴリズムが該当します。

以降で引用するCSS仕様はCSS WGより、安定した仕様を優先して参照しています。

セレクターが特定の要素と照合するためのアルゴリズムは、その多くをDOM仕様に依存しています。DOMツリーはその木構造を事前に順序付けられた深さ優先で走査します。そのような前提の後に、走査の起点となるルートを確定します。これによりセレクターは特定の要素を照合することが可能になります。Nodeインターフェースを実装するDOMノードツリーはこの規則に従います。シャドウDOM(シャドウツリー)に関しては別途アルゴリズムが定められており、シャドウルートがまさにその起点となります。 CSSのセレクターにおいてもこの原則が適用9され、ある任意のシャドウツリーに属するスタイルシートは、シャドウホストに対する照合が行われた後に、そのルートであるシャドウルートから特定の要素への走査が行われます。

さて、Web Componentsの主なユースケースとしては、ドキュメントに対してコンポーネントという単位で部品を組み合わせてページを構築するのが主たるものでしょう。 その場合、コンポーネントを複数組み込みつつページ全体でスタイルに規則性を持たせるためにはCSSのプロパティ継承が有用です。

通常、CSSのプロパティ継承はDOMツリーに従って親要素から子要素へとプロパティが伝播します。

しかしながら、先ほどのセレクターのアルゴリズムに登場したDOMツリーでは、シャドウツリーのルートがシャドウルートとして分離されていました。そのツリー構造およびアルゴリズムそのままでは、シャドウツリーの親要素が存在しないことになるため、シャドウツリーの外側のプロパティ継承を適用できません。 そこで、シャドウツリーにおけるプロパティ継承には、ドキュメントルートをツリーの頂点とする平坦化された要素ツリーが別途設けられています。これに従うことにより、シャドウホストはライトツリーに参加することが出来るようになり、シャドウルートの子要素はシャドウホストを介してライトツリーの要素からプロパティ継承することが可能になります。

余談ですが、筆者はこの仕様を読むだけではこの平坦化された要素ツリーの構造が腑に落ちなかったため、以降に理解を深めるための疑似コードを併せて掲載します。実際にはこのような単純な処理ではないことに注意してください。

TypeScriptを用いた平坦化された要素ツリー構築アルゴリズムの疑似コード
type PendingNode = Node;
type AssociatedParent = Node | null;

let flattenedElementTree = new Map<PendingNode, AssociatedParent>();
let pendingNodes = [ documentRoot ];

while (pendingNodes.length > 0) {
  let pendingNode = pendingNodes.pop()!;
  let associatedParent = pendingNode.associatedParent ?? null;
  flattenedElementTree.set(pendingNode, associatedParent);

  if(pendingNode.isDocumentRoot) {
    let documentRoot = pendingNode;

    let childNodes = documentRoot.getLightTreeNodes();
    let lightTreeNodes = childNodes;
    for(let j = 0; j < lightTreeNodes.length; j++) {
      let lightTreeNode = lightTreeNodes[j];
      lightTreeNode.associatedParent = documentRoot;
      pendingNodes.push(lightTreeNode);
    }
  } else if (pendingNode.isShadowHost) {
    let shadowHost = pendingNode;

    let childNodes = shadowHost.getShadowTreeNodes();
    let shadowTreeNodes = childNodes;
    for(let j = 0; j < shadowTreeNodes.length; j++) {
      let shadowTreeNode = shadowTreeNodes[j];
      shadowTreeNode.associatedParent = shadowHost;
      pendingNodes.push(shadowTreeNode);
    }
  } else if (pendingNode.isSlot) {
    let slot = pendingNode;
    let slottables = slot.findSlottables();

    if(slottables.length > 0) {
      for(let i = 0; i < slottables.length; i++) {
        let slottable = slottables[i];
        slottable.associatedParent = slot;
        pendingNodes.push(slottable);
      }
    } else {
      let childNodes = slot.getChildTreeNodes();
      let slotChildTreeNodes = childNodes;
      for(let j = 0; j < slotChildTreeNodes.length; j++) {
        let childNode = slotChildTreeNodes[j];
        childNode.associatedParent = slot;
        pendingNodes.push(childNode);
      }
    }

  } else if (pendingNode.isLightTreeNode) {
    let lightTreeNode = pendingNode;

    let childNodes = lightTreeNode.getLightTreeNodes();
    let lightTreeNodes = childNodes;
    for(let j = 0; j < lightTreeNodes.length; j++) {
      let childNode = lightTreeNodes[j];
      childNode.associatedParent = lightTreeNode;
      pendingNodes.push(childNode);
    }
  }
}

また、カスケードには、最終的に適用されるCSSプロパティ値の決定処理があります。ここでは先ほど登場したプロパティ継承の挙動と合わせて、colorプロパティを用いた例を示します。以下のようなライトツリー(コンテキストの外側)の著者オリジンの宣言とシャドウツリー(コンテキストの内側)の著者オリジンの宣言は、同一のdiv要素に対して照合されます。この例ではクラスセレクターが(0,1,0)の、:host擬似クラスセレクターが(0,1,0)詳細度で計算されますが、カスケードにおいてはオリジンおよびカプセル化されたコンテキストの確定は詳細度より上位の概念です。つまり、この例において詳細度はカスケードに影響を及ぼしません。

<!DOCTYPE html>
<html>
<head>
  <style>
    .normal {
      color: green;
    }
    .important {
      color: blue !important;
    }
  </style>
</head>
<body>

  <div class="normal">
    <template shadowrootmode="open">
      <style>
        :host {
          color: red;
        }
      </style>

      <p>color: green</p>
    </template>
  </div>

  <div class="important">
    <template shadowrootmode="open">
      <style>
        :host {
          color: red !important;
        }
      </style>

      <p>color: red</p>
    </template>
  </div>

</body>
</html>

これを解決するのはカスケードの宣言ソート順序およびそのソート結果により出力された値です。先ほど登場したセレクターに関するDOMツリーのアルゴリズムと同様のものを用いて、(ツリー)コンテキストの外側の通常の宣言が内側の宣言より優先されます。!importantアノテーションの宣言の場合は、ソート順序を反転することにより、コンテキストの内側の宣言が優先されます。

プロパティ継承およびカプセル化されたコンテキストの挙動が組み合わさることで、シャドウツリー全体に継承したいプロパティを:hostセレクターのフォールバック値として設定しつつ、ライトツリーの宣言によってシャドウツリーを含むドキュメント全体へのプロパティを適用させることが可能になります。継承せずに個別上書きしたいものはシャドウツリーに含まれる宣言で!importantアノテーションを付与することもまた可能です。

CSSに関して、iframeを用いる場合は、JavaScriptの時と同様にドキュメント分離に伴うパフォーマンスコストの増大が懸念されます。一方でシャドウDOMを用いる場合、比較的少ないと想像されるシャドウDOM特有の挙動を実現するためのリソースで、柔軟なスタイルシートの適用が可能になります。

実装における工夫

さて、これまではiframeとWeb Componentsの機能性の違いなどについて仕様およびその挙動を確認してきました。ここからは実際のアプリケーションを構築する上で工夫した点などを紹介します。本稿の冒頭で記載のあった通り、実際のHTMLに追加されるものはscript要素のみです。このscript要素の位置を基準としてカスタム要素をDOMツリーに加えるには、以下のようなヘルパーを用いました。

function appendCustomElementToDOM(
  constructor: typeof MyCustomElement,
  customElementName: string,
  entryPointModuleUrl: URL
) {
  // 同名のカスタム要素が存在しない場合のみ登録する
  if (!customElements.get(customElementName)) {
    customElements.define(customElementName, constructor);
  }
  const customElement = document.createElement(customElementName);
  // エントリーポイントのモジュールのURLパラメーターをカスタム要素の属性に設定
  entryPointModuleUrl.searchParams.forEach((value, key) => {
    customElement.setAttribute(key, value);
  });
  // 呼び出し先のDOMツリーからエントリーポイントのモジュールを呼び出ししているscript要素を検索
  const scriptElement = document.querySelector(`script[src="${entryPointModuleUrl.toString()}"][type="module"]`);
  // 指定の要素が存在しない場合のエラーがコンソールに出る方がミスが分かりやすいため、nullチェックは行わない
  scriptElement!.insertAdjacentElement('afterend', customElement);
}

// entrypoint.js
appendCustomElementToDOM(MyCustomElement, 'my-custom-element', new URL(import.meta.url));

上から順に見ていきましょう。まずは類似オリジンウィンドウエージェントで一意のスコープの無いカスタム要素10を作成するためのチェックをかけつつ、定義します。他に重要な点としては、appendCustomElementToDOMの第三引数にnew URL(import.meta.url)を割り当てている点です。これによりモジュール自身のURLを基にquerySelectorでDOMツリーから当該script要素を照合可能になります。また、自身のURLに含まれるURLパラメーターをカスタム要素の属性に割り当てることにより、カスタム要素からElement.attributes経由で値を取得できます。

注意点としては、実際のアプリケーション開発ではwebpackViteのようなモジュールバンドラークライアントを利用することが多いことでしょう。この時、appendCustomElementToDOMを含むモジュールが<script type="module" src="https://example.com/service.js?id=1&foo=bar"></script>というscript要素のエントリーポイントとなるようにバンドルする必要があります。

また、同一のsrc属性を持つscript要素がDOMツリーに2つ以上存在する場合、最初に照合された要素をDOMツリーの基準としてしまいます。これに関しては、同一のsrc属性を持つscript要素を複数個DOMツリーに追加する要件が無いので、問題としていません。

通常、1年間という上限は設けられつつ、ほぼ永続するキャッシュ期限を設定されることの多いJavaScriptファイルです。しかしながら、所謂キャッシュバストとしてファイルハッシュを持つファイル名、service-b43i2we.jsをビルド時に生成してしまい、一意のエントリーポイントURLを参照できない可能性があります。これはアプリケーションの構成次第で動的なエントリーポイントのファイル名を取得できれば問題ないですが、そうでない場合はエントリーポイントファイルにはファイルハッシュを付与しないようにする必要が出てくることでしょう。また、その場合にエントリーポイントファイルのキャッシュ期限は比較的短いものを設定することで、デプロイ後の更新を受け取るまでの時間が短くなります。こちらに関しても、CDNのキャッシュをデプロイ時に破棄するなど、コンテンツ配信の構成次第で取るべき手段やトレードオフが異なります。

実運用における注意点

Web Componentsが策定されるまで@font-faceルールのfont-family記述子はドキュメントで一意の名前を宣言し、その定義を対応するCSSプロパティから参照できる仕組みとなっていました。この仕様に関しても、シャドウDOMの登場で、ドキュメントではなくシャドウツリーで一意であるべきであるという議論11が交わされました。しかしながらこの仕様に関しては、まだモダンブラウザーで正しく実装12されていません。

これは、例えばGoogle Fontsが提供するNoto Sans JPをシャドウツリーへ適用したい場面で問題が発生します。Noto Sans JPを利用する際、おおよその場面で以下のようなlink要素をHTMLに配置することが多いでしょう。

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100..900&display=swap" rel="stylesheet">

3つ目のlink要素のファイルの中身はこのようになっています。

@font-face {
  font-family: 'Noto Sans JP';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(https://fonts.gstatic.com/../.woff2) format('woff2');
  unicode-range: ...;
}
...

現在のモダンブラウザーで動作させるには、このようにしてシャドウツリーの外側であるライトツリーに配置して、シャドウツリーにおける宣言からfont-family: Noto Sans JP;のように参照できるようにする回避策が必要になります。

<my-cool-component>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100..900&display=swap" rel="stylesheet">

  <template shadowrootmode="open">
    <style>
      p {
        font-family: 'Noto Sans JP';
      }
    </style>
    <p>...</p>
  </template>
</my-cool-component>

このような回避策はある一方、幾つか注意しておくべき点はあります。1つは、ライトツリーにおいて複数のNoto Sans JPの名前空間(仕様ではツリースコープ名)が衝突し、意図しない宣言となってしまう可能性があります。また、一般的なWeb Componentsのユースケースとしてはドキュメント全体でフォントを統一したいことが多いと考えられることにより、その場合は先ほど紹介したCSSプロパティ継承の仕様に従うことで実現できます。そして、このような実態があることにより、@font-faceルールがドキュメント全体ではなくシャドウツリーで一意であるという仕様に対して、ブラウザー実装側では仕様に追従するための実装の優先度はどうしても低くなると見ています。

また、前述の通りモジュールによるJavaScriptの分離には限界があります。自分達が管理するGit等のバージョン管理ツールに含まれるコードには問題がない場合でも、サードパーティーライブラリーがそこを徹底しているとも限らないため、入念な調査および動作確認が必要になります。

パフォーマンスの差異

このような置き換えに対して、実際にどの程度のパフォーマンス差異があるのか、計測しました。対象のブラウザープロジェクトのバージョンはChromium 143.0、Firefox 144.0およびWebKit 26.0です。実行環境は以下に示します。

  • 実行環境: macOS 26.3.1
  • マシン: MacBook Air
  • SoC: Apple M4
  • CPUコア数: 10コア(Pコア4、Eコア6)
  • メモリ: 24 GB

計測に用いるコンテンツですが、iframeとWeb Componentsそれぞれ4つずつを1つのドキュメントに埋め込む形で、実際のサービス運用とほぼ同様の内容となるようにしました。

また、計測の対象とする指標ですが、意義のある読み込みの速さを比較するためにCore Web VitalsよりLargest Contentful Paint(LCP)を用いています。PerformanceObserver APIを用いてlargest-contentful-paintの値を計測しました。ビューポートは幅664高さ720ピクセルという経験的に決めた値を設定しています。システム側でクライアント端末のビューポートを集計していないため、この値についてはウィジェットを利用しているニコニコインフォなどのコンテンツ幅を考慮した、ある程度の決め打ちと言えます。

また、Playwrightでブラウザーをセットアップし、前述のような環境を想定した設定およびパフォーマンス計測のコードを記述しました。

試行回数を100として、結果をP50/P75/P99で算出したものが以下の通りです。LCPのP75はWeb Components実装の方がおよそ4倍ほど高速化されています。

図1: 各ブラウザーにおけるLCPの比較 (n=100)
Browser Mode P50 (ms) P75 (ms) P99 (ms) Min (ms) Max (ms)
Chromium iframe 208.00 241.00 673.72 156.00 844.00
Chromium Web Components 44.00 52.00 66.44 32.00 308.00
Firefox iframe 217.50 238.25 330.76 159.00 505.00
Firefox Web Components 51.00 54.00 66.16 41.00 82.00
WebKit iframe 372.00 423.25 1009.66 220.00 2065.00
WebKit Web Components 110.00 120.25 471.93 86.00 564.00

メモリ使用量についても算出の対象に含めようと検討しましたが、iframeに関してはオリジンごとにプロセスを分離するか否かは実装依存13であることと、 利用可能なAPIは一部ブラウザーで提供されている14ものの、信頼できる値を算出することが難しいと考えて対象としませんでした。

まとめ

モダンブラウザーによる実装とその普及が進み、本稿のようなユースケースにおいてWeb Componentsがiframeを代替できるようになりました。ニコニコオーディションの本番環境において、Web Componentsによるウィジェットの提供の開始からおよそ1年ほど経過しました。移行してから提供するウィジェットの個数も増えている中で、現在でも安定して運用できています。

一方で、Web Componentsはセキュリティ機能ではないため、厳密なオリジンごとのリソース分離を実現したい場合であったり、JavaScriptの利用が困難な場合にはiframeを利用することになると想定されます。

また、本稿の事例とは異なるため大きく取り上げなかったものの、シャドウツリーとその外側では連携できない場合15があります。このような状況ではWeb Componentsで代替とならないこともあるため、要件に基づいて事前に検証する必要があります。

他に、本稿で紹介したレルムという単語は、一部の読者にShadowRealm APIのプロポーザルを想起させる語でもあります。レルムと併せて登場したエージェントなどの概念や仕様を踏まえると、当APIで可能なことと不可能なことはある程度想像できます。この提案自体はおおよそ10年ほど前に行われたものの、HTML仕様および非ブラウザー環境へどのようにマッピングすべきであるのかという核心の部分について結論が出ておらず、標準化に大きな進展は無いようです。

本稿で取り上げた仕様や実装を1つのポインタとし、適宜最適な実装を行えるとよいでしょう。


株式会社ドワンゴでは、標準仕様とその実装と睨めっこしたり、様々なサービス、コンテンツを一緒につくるメンバーを募集しています。 ドワンゴに興味がある。または応募しようか迷っている方がいれば、気軽に応募してみてください。


  1. <iframe>: インラインフレーム要素 ↩︎

  2. それぞれHTML仕様およびCSS 2.2仕様の置換要素定義。 ↩︎

  3. ナビゲーブル(Navigable)は、後述の閲覧コンテキスト4の概念をより適切に扱う目的で、2022年頃にブラウザー仕様へ追加されました。ブラウザーのウィンドウ、タブやiframeなどを表すブラウザー利用者向けの概念であり、終了フラグやセッション履歴など、ブラウザー利用者にドキュメントを案内するための仕様を含みます。HTML仕様に則った上でドキュメントの親子関係を説明する場合、特にiframeを扱う際にはナビゲーブルの方が仕様を読む上で適当と考えてナビゲーブルを扱っています。 一方で、執筆時点ではブラウザーの長い歴史で見ると比較的新しい概念であり、主要ブラウザーエンジンのナビゲーブルの実装はほとんどの箇所で閲覧コンテキストによって実現されているようです。 そんな中、新興ブラウザーエンジンであるLadyBirdLibWebナビゲーブルの実装閲覧コンテキストの実装が区別されています。 ↩︎

  4. 閲覧コンテキスト(Browsing Context)は常にWindowProxyと1対1の関係になり、JavaScript実行など開発者向け機構の概念です。一方で、HTML仕様において、現在ではほとんどの場合ナビゲーブルおよびドキュメントでその概念を賄うべきとの注意書きがあります。ナビゲーブル間の開発者実装によるコミュニケーション、例えばpostMessageを用いる場合は、ナビゲーブルが保持する閲覧コンテキスト間のコミュニケーションと捉える方が適切でしょう。 ↩︎ ↩︎

  5. サブドメイン(同一サイト)からdocument.domainのsetterによって同一オリジンとなる場合も考えられるが、今では推奨されない機能であり、Google Chromeでは既に無効化されている。 ↩︎ ↩︎

  6. (通称ESM)それぞれECMAScript仕様およびHTML仕様 ↩︎

  7. これは5が存在することによって、similar originと命名されたと考えられます。 ↩︎

  8. ただし、iframe要素が同一オリジンの場合は、CSS Object Modelで定義されているJavaScript経由、例えばHTMLElement.styleで特定の要素のスタイルを操作可能です。 ↩︎

  9. CSS Shadow Module Level 1 ↩︎

  10. CustomElementRegistryは類似オリジンウィンドウエージェントを作成する際に、閲覧コンテキスト作成フローで初期化されます。以前までは、その単一のCustomElementRegistryを使い回していることにより、同一の要素名に対して複数のコンストラクターを定義できませんでした。比較的最近、スコープ付きのCustomElementRegistryを作成可能な提案がHTML仕様およびDOM仕様に追加されました。執筆時点ではSafari 26.0よりこの機能が利用可能になっています。これにより、要素名の衝突を考慮せずにカスタム要素を定義することが容易になります。 ↩︎

  11. [css-scoping] Handling global name-defining constructs in shadow trees ↩︎

  12. WebKitは一見動いているように見えるものの、実際はシャドウツリーにおける宣言がグローバルに巻き上げられるという挙動になっています。
    @font-face rules in shadow trees should not leak outside to document
    @font-face definitions in shadowRoot cannot be used within the shadowRoot
    @font-face definitions in shadowRoot cannot be used within the shadowRoot ↩︎

  13. Requesting performance isolation with the Origin-Agent-Cluster header ↩︎

  14. Monitor your web page’s total memory usage with measureUserAgentSpecificMemory() ↩︎

  15. シャドウの境界を跨ぐ要素の参照が出来ないとの指摘。Solving Cross-root ARIA Issues in Shadow DOM ↩︎