Web Components(+Lit)

2025 03 09
14

Web Components導入の必要性

さまざまな技術スタックを使用するサービスでは、複数の言語やフレームワークが混在して利用されています。
私が担当していたホテルシステムはReactで開発されていましたが、別カテゴリーの予約システムはPHPやVueで構築されていました。
各サービスの上部ナビゲーションバーは同じデザインと機能を持っていたものの、実際にはそれぞれ別々に実装されていたため、修正が発生するたびに各サービス担当者が個別に開発・デプロイを行わなければなりませんでした。
このような非効率を解消し、メンテナンス性およびユーザー体験を一貫して維持するために、上部ナビゲーションバーを統合的に管理する必要があり、その解決策としてクロスプラットフォーム技術であるWeb Componentsを導入しました。


Web Componentsの概要

Web Componentsは再利用可能でカプセル化されたウェブコンポーネントを作成するためのウェブ標準技術です。
この技術を用いることで独立して動作しページの他部分と隔離されたカスタムHTML要素を生成できます。

主な概念と構成要素
Web Componentsは大きく分けて以下の三つの技術で構成されます。

Custom Elements

Custom Elementsを使用すると、開発者自身が独自のHTMLタグを定義できます。
これにより再利用可能なコンポーネントを作成できます。

例えば、カスタムボタンタグ<my-button>を作って、これを読み込んで他のHTMLに組み込んで利用できます。

<template id="my-button-template">
  <button>Hollo World!</button>
</template>
<script>
  class MyButton extends HTMLElement {
    constructor() {
      super();
      const template = document.getElementById('my-button-template').content;
      const shadowRoot = this.attachShadow({mode: 'open'}).appendChild(template.cloneNode(true));
    }
  }
  customElements.define('my-button', MyButton);
</script>
<my-button></my-button>

Shadow DOM

Shadow DOMは通常のDOMから独立したDOMツリーを作成し、スタイルやマークアップをカプセル化します。
これによりコンポーネントはページの残り部分と独立して動作できます。

HTML Templates

HTML Templatesは即時レンダリングされず、JavaScriptを通して必要なタイミングに動的に利用されます。
これによりコンポーネントの再利用性を高め、メモリ使用量を効率的に管理できます。

<template id="concert-ticket">
  <div class="concert">
    <img src="artist.jpg">
    <p class="title"></p>
    <p class="price"></p>
  </div>
</template>
<script>
  const ticketTemplate = document.getElementById("concert-ticket').content;
  // テンプレートをdeep copyして子要素まで複製
  const clone = document.importNode(ticketTemplate, true);
  clone.querySelector('.title').textContent = 'New Live';
  clone.querySelector('.price').textContent = '¥12,000';
  document.body.appendChild(clone); // 복제된 템플릿의 값을 수정하여 삽입
</script>

これら三つのコア技術を活用することで、フレームワークやライブラリに依存しない一貫したUIコンポーネントを構築できます。

Lit ライブラリの紹介

Litライブラリとは?
LitはWeb Componentsをより簡潔かつ効率的に実装するためのライブラリです。
Web Components標準をベースに、リアクティブプロパティや宣言的テンプレート機能を提供し、コンポーネント開発の生産性を向上させます。

Litを使う理由

Litはデータが変更された際に、動的に変更されたところのみ効率的に再レンダリングすることで高速なパフォーマンスを実現します。 なお、ライブラリ自体が軽量なため、ロード時間を短縮し、バンドルサイズを最小限に抑えます。
JavaScriptやReactに似た文法のおかげで学習コストも低く、導入がスムーズです。

import {html, css, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
 
@customElement('simple-greeting') // 1. Custom Elements
export class SimpleGreeting extends LitElement {
  static styles = css`p { color: blue }`; // 2. Scoped styles
 
  @property() // 3. Reactive properties
  name = 'Somebody';
 
  render() {
    return html`<p>Hello, ${this.name}!</p>`; 4. // Declarative templates
  }
}
<simple-greeting name="World"></simple-greeting>

Litの特徴は以下となります

  1. Custom Elements: Litコンポーネントは標準のカスタム要素として扱われ、ブラウザがネイティブのHTML要素と同様に処理します。
  2. Scoped styles: LitはShadow DOMを使用してスタイルを自動でスコープ指定します。これによりCSSセレクタをシンプルに維持しながら、ページ内の他のスタイルから独立し、スタイルの衝突を防ぎます。
  3. Reactive properties: リアクティブプロパティを宣言することで、コンポーネントのAPIや内部状態をモデル化し、プロパティが変更されるたびにLitコンポーネントが効率的に再レンダリングされます。
  4. Declarative templates: タグ付きテンプレートリテラルに基づき、HTMLマークアップとJavaScriptを組み合わせて簡単かつ速めに作成できます。

実際のプロジェクトでの実装

本サービスはPC、タブレット、スマートフォンに対応しているため、ディレクトリ構造をindexからpc, tb, spに分岐しそれぞれに最適化したレンダリングを行う設計にしました。
ヘッダーはロゴやログイン機能を含むバー(bar)コンポーネントと、全サービスメニューを表示するナビゲーション(nav)コンポーネントで構成されています。

それぞれのヘッダーはログイン状態、言語、サービス名、お知らせなどのプロパティを持つようになっています。

// header 親コンポーネント
@customElement('header-pc')
export class HeaderPc extends LitElement {
  @property({ type: Boolean })
  'is-login': boolean = false
 
  @property({ type: String })
  language: string = ''
 
  ...
 
  render() {
      return html`
        <header-bar-pc
          language=${this.language}
          country=${this.country}
          ...
        ></header-bar-pc>
        ${this['show-line-menu']
          ? html`
              <header-nav-pc
                service=${this.service}
                z-index=${this['z-index']}
              ></header-nav-pc>
            `
          : html``}
      `
    }
}
 
declare global {
  interface HTMLElementTagNameMap {
    'header-pc': HeaderPc
  }
}
// header bar コンポーネント
@customElement('header-bar-pc')
export class HeaderBarPC extends LitElement {
  @property({ type: Boolean })
  'is-login': boolean = false
 
  @property({ type: Number })
  notice: number = 0
 
  @property({ type: String })
  language: string = ''
 
  ...
 
  @state()
  _showNavMainMenu = false
 
  willUpdate() {
    ...
  }
 
  setShowNavHelpMenu() {
    this._showNavMainMenu = false
    ...
  }
 
  setShowNavMainMenu() {
    ...
  }
 
  faderHandler(event: Event) {
    event.type === 'click' && this._showNavMainMenu
      ? (this._showNavMainMenu = false)
      : (this._showNavHelpMenu = false)
    this.requestUpdate()
  }
 
  clickHandler() {
    ...
  }
 
  ...
 
  static get styles() {
    return [
      resetCss,
      css`
        .header-bar {
          background-color: ${colors.mainColor};
          color: ${colors.white};
          width: 100%;
        }
        .header-bar-wrapper {
          margin: 0 auto;
          position: relative;
          max-width: 1024px;
          width: 100%;
        }
        ...
      `,
    ]
  }
 
  render() {
    const mainMenuBtnStyles = {
      backgroundColor: `${colors.subColor}`,
    }
    const pulldownBgStyles = {
      backgroundColor: `${colors.subColor}`,
    }
 
    return html`
      <header class="header-bar">
        <div class="header-bar-wrapper">
          <div class="header-bar-content">
            ...
                    <div class="pull-down-i18n">
                      <div class="header-pull-down-wrapper">
                        <div
                          class="header-pull-down-title"
                          @mouseover=${this.pulldownLanguageHandler}
                          style=${this._showPullDownLanguage
                            ? styleMap(pulldownBgStyles)
                            : ''}
                        >
                          <span class="header-pull-down-name"
                            >${this.language}</span
                          >
                        </div>
        ...
        <div
          class="header-bar-fader"
          @click=${this.faderHandler}
          @mouseover=${this.faderHandler}
          style=${this._showNavMainMenu || this._showNavHelpMenu
            ? styleMap({
                display: 'block',
                marginTop: this._showNavHelpMenu ? '-60px' : '',
              })
            : ''}
        ></div>
      </header>
    `
  }
}
 
declare global {
  interface HTMLElementTagNameMap {
    'header-bar-pc': HeaderBarPC
  }
}

barコンポーネントではステータス変化、動的レンダリングのため、以下とような機能が使用されました。

  • PropertyとState
    @propertyデコレーターはコンポーネント外部から指定可能なプロパティを定義するため使用されます。
    このプロパティはコンポーネントの外部で変更可能で、変更されるとコンポーネントが自動的にアップデートされます。
    例えば、loginlanguage のように、外部からコンポーネントの状態や設定を調整する必要がある場合に使います。
    @stateデコレーターは内部状態の管理のため使用されます。
    このステータスはコンポーネントの内部でのみ管理され変更された場合のみコンポーネントがアップデートされます。
    例として、サンプルコンポーネントではヘルプ表示のオン/オフを制御するために使われています。
    Reactと比較すると、@propertyprops@stateuseState に相当します。
  • Lifecycle Methods
    willUpdateはプロパティや状態が変更されてアップデートが発生する直前に呼び出されます。
    このメソッド内で、変更されるプロパティに応じたスタイルやロジックを適用できます。
  • Event Handlers
    @clickや@mouseoverでDOMイベントを処理します。
  • styleMap
    styleMapはLitライブラリのディレクティブの一つで、インラインスタイルを動的に管理できます。
    主に条件付きでスタイルを適用したいときに使います。
    インラインスタイル用の styleMapのほかに、クラスを動的に切り替えるclassMapもあります。

この中でも最も核心的で頻繁に使用されるのがLifecycle Methodsです。これらのメソッドは以下の順序で動作します。

LitのLifecycle Methods

  1. Pre-update
    コンポーネントの一つ以上のプロパティが変更されるか、requestUpdate()メソッドが呼び出されるとアップデートがスケジュールされます。
    コンポーネントの状態に変化があったことをLitフレームワークに通知し新しいレンダリングの準備を行います。
  2. Update
    属性反映(Reflecting Attributes): HTML属性として反映すべきプロパティ値がDOM に反映されます。
    内部DOMアップデート(Updating Internal DOM): コンポーネントのrenderメソッドが呼び出され、内部DOMが最新状態に更新されます。
  3. Post-Update
    すべての更新処理が完了すると、updateCompleteプロミスが解決(resolved)されます。
    このプロミスは外部からアップデートが完了されたことを完治するため、アップデート後追加作業を指示することができます。

Pre-Update


Update

Post-Update

ブラウザでのShadow DOM

Shadow DOMは以下のように構成されます。
Shadow DOMShadow DOM
Shadow Host : ShadowDOMが接続された通常のDOMノード
Shadow Tree : ShadowDOM内部のDOMツリー
Shadow Boundary : ShadowDOMの終了点と通常DOMの開始点
Shadow Root : Shadowツリーのルートノード

ブラウザで確認すると、それぞれのWeb ComponentsがShadowDomでカプセル化されていることが分かります。
ブラウザでのShadowDom構造ブラウザでのShadowDom構造

プロジェクトの振り返りと感想

社内で初めてWeb Componentsを導入した際、すべての作業を一人で担当したため当初は非常に大きなプレッシャーを感じました。また、韓国や日本ではWeb Componentsに関するコミュニティがまだ成熟しておらず、ほとんどの資料が英語で提供されている不便さもありました。
それでもReactとの類似点が多かったためラーニングカーブは緩やかで、新技術に触れる喜びや完成したときのやりがいも大きかったです。

プロジェクトの開始から維持保守までを一人で担当したことでこのコンポーネントに強い愛着が生まれ、前職を離れた今もたまにアクセスして正常に稼働しているかを確認したりします。 👀

おわりに

2011年に公開されたWeb Componentsは、コンポーネントの再利用性とカプセル化されたスタイルを目的としてウェブ開発の標準的アプローチを提示しました。
提示しました。そしてこのコンポーネントベース開発はReactやVueへと受け継がれ、現在ではフロントエンドの主流技術となっています。

異なる環境間でも一貫した機能とスタイルを維持できる再利用可能なコンポーネントは、開発プロセスと保守を簡素化する大きな利点を持ちます。
特にクロスプラットフォーム技術ということは開発者にとって大きな魅力と言わざるを得ません。
しかし現状ではWeb Componentsの利用率はまだ高くありません。
Web Components使用統計Web Components使用統計

メリットは確かに存在しますが、既存のフレームワークやライブラリが提供する利便性とコミュニティも無視できないし
実際に使ってみた感覚ではクロスプラットフォームのメリットがなければReactやNext.jsを置き換える必要性は感じませんでした。

それでもWeb Componentsの方向性はウェブ開発において検討すべき重要な技術であり、
webComponentsを経験してみて、この可能性を考えられる良い機会だったと思います。

🔗 参考

https://en.wikipedia.org/wiki/Web_Components
https://developer.mozilla.org/en-US/docs/Web/API/Web_components
https://lit.dev/
https://trends.builtwith.com/javascript/Web-Components