Web Componentsの実用に向けて

UI開発者 吉田・齋藤

この記事はミツエーリンクス Advent Calendar 2020 - Adventarの18日目の記事です。

はじめに

Web Componentsは以前から注目されてきた技術ですが、一部ブラウザの対応状況が整っておらず、ポリフィルを利用しなければ実用的なレベルで使用できませんでした。

しかし、今年Microsoft EdgeがChromiumブラウザエンジンを採用したことでInternet Explorer 11(以下IE11)を除くすべてのモダンブラウザで、ポリフィルやフレームワークを利用せずにWeb Componentsが動作するようになりました。

また、これまでIE11での利用をサポートしていたWebサイトやWebサービスのサポート終了への動きが活発化しており、当社もIE11の動向に関するコラムを公開してきました。

こういった状況からWeb Componentsは「いつか使えるようになる技術」ではなく「今すぐ使える技術」になったと言えます。

Web Componentsに期待すること

Webサイトの構築では規模が大きくなるほど下記のような問題が顕著になります。

  • コンポーネントの数が多くなり、管理コストがかかる
  • レイアウトやコンポーネントの構造・パターン・組み合わせが多く、ソースコードが複雑になる
  • 複数人で開発する必要があるためマークアップの揺れが発生し、品質の維持が困難になる
  • コーディングルールの周知やメンテナンスにかかるコストが高い

これまではこういった問題に対してCSSアーキテクチャ(FLOCSS、BEMなど)で仕組みを工夫したり、ライブラリやフレームワークを導入することで解決してきましたが、根本的な解決方法ではなかったり、依存するライブラリやフレームワークの仕様に振り回されることもありました。

今回は、新しい解決のアプローチとしてWebサイトの構築でWeb Componentsを導入するメリットや期待することを書いていきます。

導入手順

ディスクロージャーを例にWeb Componentsを実装します。

テンプレートはどこに書くべきか

Web Componentsを実装していくにあたり、まずテンプレートはどこに記述するべきか、という悩ましい課題があります。Web Componentsのよくある実装方法としては次のようなものがあります。

  1. HTML(Webページ)にtemplate要素を記述しJavaScriptで取得する
  2. JavaScriptにテンプレートリテラルでHTMLとCSSを記述する

1に関しては、汎用性が低くなってしまうため再利用性を考えると取り回しが良くなくあまり実用的ではありません。また、外部ファイルとしてtemplate要素を記述したHTMLファイルを用意し、link要素で読み込むHTMLインポートは現在は廃止された仕様です。

2に関しては、JavaScript内に必要な記述をまとめることでコンパクトにコンポーネント化できますが、メンテナンス性や可読性の観点からスタイルやマークアップとスクリプトは分けて管理したほうが良いでしょう。

今回はテンプレートの記述をHTMLファイル・SCSSファイルそれぞれに記述し、Webpackでバンドルする方法で実装します。

npmパッケージのインストール

今回使用するパッケージは下記のコマンドでインストールします。

npm install --save-dev webpack webpack-cli sass raw-loader sass-loader html-loader

ファイル構成と記述内容

(root)
├── src
│       └── components
│                      ├── disclosure
│                      │           ├── disclosure.js
│                      │           ├── style.scss
│                      │           └── template.html
│                      └── index.js
└── webpack.config.js

Webpackの設定ファイルは下記のようになります。

webpack.config.js

const path = require('path');

module.exports = {
    mode: 'development',
    entry: {
        common: './src/components/index.js',
    },
    output: {
        filename: 'components.js',
        path: path.resolve(__dirname, 'dist'),
    },
    module: {
        rules: [{
            test: /.html$/i,
            use: 'html-loader',
        },
        {
            test: /.scss$/,
            use: [
                'raw-loader',
                {
                    loader: 'sass-loader',
                    options: {
                        sassOptions: {
                           includePaths: [path.resolve(__dirname, 'node_modules')],
                        }
                    }
                }
            ]
        }],
    },
};

/components直下のindex.jsファイルが配下のWeb Componentsを集約するハブの役割をします。

src/components/index.js

import {Disclosure} from './disclosure/disclosure';

customElements.define('c-disclosure', Disclosure);

テンプレートのマークアップは/disclosure配下のtemplate.htmlに記述します。

src/components/disclosure/template.html

<button type="button" id="button"><slot name="title"></slot></button>

<div id="content">
    <slot name="content"></slot>
</div>

テンプレートのスタイルは/disclosure配下のstyle.scssに記述します。

src/components/disclosure/style.scss

:host {
  border: 1px solid #333;
  display: block;
}

#button {
  background: transparent;
  border-width: 0;
  display: block;
  padding: 10px 40px 10px 16px;
  position: relative;
  text-align: left;
  width: 100%;

  &::after {
    border-left: 5px solid transparent;
    border-right: 5px solid transparent;
    border-top: 5px solid #333;
    content: '';
    height: 0;
    pointer-events: none;
    position: absolute;
    right: 16px;
    top: 50%;
    transform: translateY(-50%);
    width: 0;
  }

  &[aria-expanded="true"] {
    &::after {
      border-bottom: 5px solid #333;
      border-top-width: 0;
    }
  }
}

#content {
  border-top: 1px solid #333;
  padding: 10px 16px;

  &:not(.is-show) {
    display: none;
  }
}

今回は記載していませんが、WebpackでSCSSファイルを切り出したことにより、@useによるSCSSファイルの読み込みも可能になります。例えば、Webサイト全体で定義しているベーススタイルの変数なども読み込み、適応することができます。

@use 'src/sass/_base/var';

:host {
  color: var.$COLOR;
}

テンプレートの記述を読み込み、Custom ElementsやShadow DOMをdisclosure.jsに記述します。 今回は雰囲気を掴んでいただくことが目的のため最低限のロジックのみ記載しています。

src/components/disclosure/disclosure.js

import style from './style.scss';
import html from './template.html';

const template = document.createElement('template');

template.innerHTML = `<style>${style}</style>${html}`;

export class Disclosure extends HTMLElement {
    constructor() {
        super();

        this.attachShadow({mode: 'open'});
        this.shadowRoot.appendChild(template.content.cloneNode(true));

        this._button = this.shadowRoot.getElementById('button');
        this._content = this.shadowRoot.getElementById('content');
    }

    connectedCallback() {
        this._button.setAttribute('aria-controls', this._content.id);

        this._button.addEventListener('click', this._clickHandler.bind(this));

        this.hide();
    }

    get expanded() {
        const bool = this._button.getAttribute('aria-expanded');

        if (!bool) {
            return false;
        }

        return bool.toLowerCase() === 'true';
    }

    toggle() {
        this[this.expanded ? 'hide' : 'show']();
    }

    show() {
        this._content.classList.add('is-show');
        this._button.setAttribute('aria-expanded', 'true');
    }

    hide() {
        this._content.classList.remove('is-show');
        this._button.setAttribute('aria-expanded', 'false');
    }

    _clickHandler(event) {
        event.preventDefault();

        this.toggle();
    }
}

Web Componentsの使用

下記コマンドでWebpackを実行しcomponents.jsを生成します。

npx webpack

/dist直下に生成されたcomponents.jsをHTMLから読み込むと定義したカスタム要素が使用できるようになります。下記が今回定義したディスクロージャーの使用例になります。

dist/index.html

...
省略
...

    <c-disclosure>
        <span slot="title">折り畳みタイトル1</span>
        <div slot="content">折り畳みテキスト1</div>
    </c-disclosure>

    <script src="components.js"></script>
</body>

</html>

ディスクロージャーが構築された後のShadow DOMは下記のような構造を想定しています。

<c-disclosure>
    <button type="button" id="button" aria-controls="content" aria-expanded="false"><slot name="title"></slot></button>

    <div id="content">
        <slot name="content"></slot>
    </div>
</c-disclosure>

コンポーネント呼び出し時と構築されたコンポーネントを比較していただくと、id属性値やaria-**属性値が追加されていることに気付くかと思います。

Web Componentsを使用しない場合、これらの指定は(一部JavaScript側で指定できるものもありますが)ページの実装者が記述することになります。

ページ内で使用されているIDを調べて一意の値に設定したり、適切にaria-**属性値が設定できているかどうかを確認するコストはコンポーネントの使用箇所に比例して肥大します。

Web Componentsでは内部実装がカプセル化されているため、IDの重複や複雑なaria-**属性値の設定を気にする必要なくカスタム要素を使用することで誰でも同じ品質で実装することができます。

導入して気付いたこと

実際に、上記の環境で手を動かしてみて「良い点」と「悪い点」を感じることができました。

良い点

  1. Custom elementsによって、複雑なコードを簡略化することができ、編集可能領域を最低限に絞り込むことで、前述の問題点の多くを解決できる
  2. Shadow DOMによって、スタイルやJavaScript機能がカプセル化されることで他コンポーネントとの各種競合を気にせず開発できるようになるため、自分の作業スコープのみに専念することができる
  3. IDの影響範囲も封じ込めることができるため、Shadow DOM同士のID属性値の重複を気にする必要がなくなる

1、2は広く理解されているWeb Componentsの特徴であり、魅力です。

前項でご紹介させていただきましたが、筆者は、特に3が非常にパワフルだと感じました。フォームやWAI-ARIAでラベルとIDを関連付ける場合に、逐一ユニークな値を設定する手間がありましたが、それらの問題を完全に無視できるのはかなり気が楽になります。

悪い点

  1. CSSやJavaScriptがカプセル化されたことにより、サイト全体を通しての機能開発やスタイリングに工夫が必要になる
  2. サイトの規模によっては、導入やそれにまつわる環境整備のコストのほうが高くつく
  3. 実装・運用の問題が、設計効率や表示パフォーマンスの問題にすり替わっただけ

1については、筆者のように1ファイルにすべてのコンポーネントのスタイルをまとめていたり、1ファイルにすべての機能をまとめている設計者にとっては、少し考え方を変えないとプロジェクトが進むにつれて苦しくなります。

2については、「そもそも」ですが、Web Components導入と問題解決を天秤にかけたときに、前者の方がコストが高いであろうサイトボリュームや要件の場合、無理してまで導入すべきか、というところです。

3については、本来必要のないJavaScriptを読み込むため、少なからず表示パフォーマンスには影響が出ます。それとトレードオフにしてでも解決したい問題なのか検討の余地があると思います。

Web Componentsとの付き合い方

Webサイトの構築におけるWeb Componentsの導入はある程度の導入コストは必要ですが、複数チームで持ち場を分けて作業を並行するような開発では互いの作業の干渉を抑えながら効率良く作業をすることが可能です。 また、一度質の高いコンポーネントが開発できれば、コンポーネントの利用者は内部実装を気にすることなく低コストで何度でも同じ品質を再現できます。

Web Componentsにはデメリットや課題もいくつかありますが、メリットをうまく活用することで導入コストに見合った効果が期待できると感じています。