🌐

Shadow DOM で複雑なバナー広告を実装する

2020/12/18に公開

この記事は Ubie Advent Calendar 2020 の 18 日目の記事です。

外部ウェブサイトに掲載するバナー広告を実装するにあたって、一般的なバナー画像だけでは表現力が足りず要件を満たせなかったため、Shadow DOM を採用しました。その実装過程を紹介します。

Shadow DOM とは

Shadow DOM は Web Components を構成する仕組みの一部で、超ざっくり言うと DOM ツリーをカプセル化することができます。

caniuse によるとモダンブラウザではすでに使えます (iOS Safari は一部バグあり)。

詳しくは Google の記事MDN を参照してください。

完成形

リポジトリ: https://github.com/yukukotani/shadow-dom-banner

このようなスニペットを HTML 中に埋め込むだけでバナーが表示される状態を目指します。(バナーは実際のものからかなり簡略化しています)

<script defer src="https://gitcdn.link/repo/yukukotani/shadow-dom-banner/master/dist.js"></script>
<div id="ubie-banner" data-width="480"></div>

バナーの要件は以下の通りです。

  • Ubie, inc. の部分をクリックするとコーポレートサイトに飛ぶ。
  • それ以外のバナー領域をクリックするとWebアプリに飛ぶ。
  • width を属性として HTML 側から設定できる。
  • IE11 はサポートしない。

実装

Shadow Root の作成

まず、Shadow Root という Shadow DOM のルート要素のようなものを作成します。

const element = document.getElementById("custom-banner");
const shadow = element.attachShadow({ mode: "closed" });

mode プロパティはカプセル化の強さを設定するものです。
"open" だと親要素から element.shadowRoot プロパティを通して Shadow Root を取得し、操作することができます。"closed" だと element.shadowRoot プロパティは null になり、attachShadow が返す参照を通してのみ操作できます。

カスタムデータ属性の読み取り

HTML 側でカスタムデータ属性 として定義した width を取得します。

const element = document.getElementById("custom-banner");
const shadow = element.attachShadow({ mode: "closed" });
const width = parseInt(element.dataset.width);

要素の data-${name} 属性に渡した値が、JavaScript では element.dataset[name] として取得できます。

HTML ノードの生成と描画

HTML ノードを生成し、Shadow Root の子要素として描画します。

import { html, render } from 'uhtml';

const element = document.getElementById("custom-banner");
const shadow = element.attachShadow({ mode: "closed" });
const width = parseInt(element.dataset.width);

const style = `
a, a:link, a:visited, a:hover, a:active {
  text-decoration: none;
  color: black;
}
.wrapper {
  width: ${width}px;
  padding: 8px;
  border: 1px solid black;
}
.app-img {
  width: inherit;
}
.corp-link {
  text-align: right;
}
.corp-link > a {
  text-decoration: underline;
  color: #cf4a7b;
}
  `;

const node = html.node`
  <style>${style}</style>
  <a class="app-link" href="https://ubie.app" target="_blank" rel="noopener">
    <div class="wrapper">
      <img class="app-img" src="https://ubie.app/img/ubie-logo-ja.png" />
      <div class="corp-link">
        Powered by <a href="https://ubie.life" target="_blank" rel="noopener">Ubie, inc.<a/>
      </div>
    </div>
  </a>
  `;

render(shadow, node);

ここではバンドルサイズと開発体験のバランスを考え、~2.5KB の uhtml というライブラリを使っています。

width をスタイルに埋め込み、それを uhtml の node 関数を用いて宣言的に HTML の <style> タグに埋め込んでいます。この <style> タグは Shadow Root 内にあるので、外のレイアウトには影響を与えません。
また、Shadow Root 内のレイアウトは外の CSS の影響を受けないため、!important を使ったりセレクタを深くしたりして頑張る必要はありません。

最後に、生成したノードを uhtml の render 関数で Shadow Root の子として描画しています。

動的にしてみる

今の実装だと、ロゴ画像の読み込みが遅い場合に Powered by ~ だけが先に描画され、バナー内のレイアウトにガタつきが発生してしまいます。
そこで、<img> 要素の onload 属性を使って画像を読み込んだあとに Powered by ~ を描画するようにします。

import { html, render } from 'uhtml';

const element = document.getElementById("custom-banner");
const shadow = element.attachShadow({ mode: "closed" });
const width = parseInt(element.dataset.width);

const style = `
a, a:link, a:visited, a:hover, a:active {
  text-decoration: none;
  color: black;
}
.wrapper {
  width: ${width}px;
  padding: 8px;
  border: 1px solid black;
}
.app-img {
  width: inherit;
}
.corp-link {
  text-align: right;
}
.corp-link > a {
  text-decoration: underline;
  color: #cf4a7b;
}
  `;
  
const onImageLoad = () => {
  const node = html.node`
  Powered by <a href="https://ubie.life" target="_blank" rel="noopener">Ubie, inc.<a/>
  `;
  render(shadow.getElementById("corp-link"), node);
};

const node = html.node`
  <style>${style}</style>
  <a class="app-link" href="https://ubie.app" target="_blank" rel="noopener">
    <div class="wrapper">
      <img class="app-img" src="https://ubie.app/img/ubie-logo-ja.png" onload=${onImageLoad} />
      <div id="corp-link" class="corp-link"></div>
    </div>
  </a>
  `;

render(shadow, node);

uhtml のおかげで、イベントハンドラ関数 onImageLoadonload 属性に宣言的に渡すだけで動きます。便利!

Shadow DOM 非対応ブラウザをハンドルする

今回の要件では IE11 を含むレガシーブラウザをサポートする必要がないので polyfill などは入れませんが、非対応環境で実行を試みてエラーが出る状態は避けるべきです。

Shadow DOM 非対応ブラウザでは element.attachShadow が生えていないため、その有無をチェックして描画しないようにするだけで済みます。

ビルド

サードパーティスクリプトなので、ビルド周りも軽く紹介します。

ビルドには Rollup を使っています(config)。安定の terser はもちろん使っていますが、それだけでは uhtml に渡しているタグ付きテンプレートリテラル の HTML が minify できないので、rollup-plugin-minify-html-literals を使っています。

結果、brotli 圧縮をかけた状態でギリギリ 3KB におさまっています。

$ cat dist.js | brotli | wc -c
    2981

terser のオプションを詰めていなかったり、style はさらに minify できそうだったりと、まだまだ削減の余地がありそうです。

その他細かいこと

polyfill

IE11 を含むレガシーブラウザをサポートする場合は polyfill が使えます。ただし、Shadow DOM による CSS のカプセル化などはエミュレートできないため、クラスの命名が衝突しないよう気をつける必要があります。

ES Modules の利用

caniuse によると Shadow DOM をサポートするブラウザは ES Modules のインポート をサポートしているため、uhtml をバンドルせずにブラウザ上でモジュール解決をすることもできます。

そうすることでバナー実装を更新しても uhtml にキャッシュが効くなどの恩恵がありますが、今回のケースでは全体で 3KB と軽量なので、バンドルして HTTP コネクションの確立を削減したほうが効率的だと考えました。測定はしてません。

終わりに

Web Components の採用というと仰々しく聞こえるかもですが、あまり Web に詳しくない自分でも、このようにカプセル化の用途で Shadow DOM を切り出して気軽に使うことができました。Shadow DOM を用いた CSS in JS ライブラリなどもあり、今後もいろいろ面白いものが生まれると嬉しいです。

Ubie テックブログ

Discussion