Material Symbolsの全アイコン(3466個)を一覧表示するWebページを作成していた際、深刻なパフォーマンス問題に直面しました。各アイコンにコピー機能を付けるため、全ての要素に個別のイベントリスナーを登録していたところ、クリック時の反応が著しく悪化してしまったのです。

元の問題のあるコード

// 問題のあるアプローチ
let arrCopyables = Array.prototype.slice.call(document.querySelectorAll(".be-copyable"));

let fnCopyableNUJA = function () {
  for (let n = 0; n < arrCopyables.length; n++) {
    arrCopyables[n].addEventListener('click', function (event) {
      // 個別の要素ごとにイベントリスナーを登録
      event.preventDefault();
      var dataCopy = event.currentTarget.getAttribute("data-copy");
      navigator.clipboard.writeText(dataCopy);
    });
  }
}();

この方法では、3466個の要素×2(アイコン本体とCodeボタン)= 約7000個のイベントリスナーがブラウザに登録されることになり、メモリ使用量とパフォーマンスに大きく影響していました。

解決策1: イベントデリゲーションで劇的改善

最初に取り組んだのはイベントデリゲーションの導入です。個別要素ではなく、親要素(document)に1つだけイベントリスナーを登録し、イベントバブリングを利用して処理を行います。

改善されたコード

// イベントデリゲーションを使用した改善版
document.addEventListener('click', function(event) {
  const copyableElement = event.target.closest('.be-copyable');
  if (!copyableElement) return;
  
  event.preventDefault();
  const dataCopy = copyableElement.getAttribute("data-copy");
  
  if (!dataCopy) return;
  
  navigator.clipboard.writeText(dataCopy).then(() => {
    showCopyMessage();
    copyableElement.classList.add('copied');
    setTimeout(() => {
      copyableElement.classList.remove('copied');
    }, 1000);
  }).catch(err => {
    console.error("Could not copy text:", err);
  });
});

イベントデリゲーションの効果

  • メモリ使用量: 7000個 → 1個のイベントリスナー
  • 初期化速度: 大幅な高速化
  • クリック反応速度: 劇的な改善

解決策2: 仮想スクロールで初期表示を高速化

イベントデリゲーションでクリック時の問題は解決しましたが、3466個の要素を一度に表示すると初期描画に時間がかかる問題が残っていました。そこで仮想スクロール(段階読み込み)を実装しました。

仮想スクロールの実装

class VirtualIconGrid {
  constructor() {
    this.itemsPerPage = 200; // 一度に200個ずつ表示
    this.currentPage = 0;
    this.allData = [...iconData]; // 全データ
    this.filteredData = [...iconData]; // フィルタ済みデータ
    this.isLoading = false;
  }
  
  renderPage() {
    if (this.isLoading) return;
    
    this.isLoading = true;
    const startIndex = this.currentPage * this.itemsPerPage;
    const endIndex = Math.min(startIndex + this.itemsPerPage, this.filteredData.length);
    const pageData = this.filteredData.slice(startIndex, endIndex);
    
    const html = pageData.map(item => this.createIconCard(item)).join('');
    
    if (this.currentPage === 0) {
      this.container.innerHTML = html;
    } else {
      this.container.insertAdjacentHTML('beforeend', html);
    }
    
    this.currentPage++;
    this.isLoading = false;
  }
  
  setupInfiniteScroll() {
    window.addEventListener('scroll', () => {
      const scrollPercent = (window.scrollY + window.innerHeight) / document.body.scrollHeight;
      
      if (scrollPercent > 0.9 && !this.isLoading) {
        const remainingItems = this.filteredData.length - (this.currentPage * this.itemsPerPage);
        if (remainingItems > 0) {
          this.renderPage();
        }
      }
    });
  }
}

仮想スクロールの効果

  • 初期表示時間: 大幅短縮(3466個 → 200個の描画)
  • メモリ使用量: 効率的な使用
  • スクロール体験: 自然な無限スクロール

解決策3: 検索機能とリアルタイムフィルタリング

大量のデータを扱う際は、ユーザーが目的の要素を素早く見つけられる検索機能があると便利です。

setupEventListeners() {
  // リアルタイム検索
  this.searchInput.addEventListener('input', (e) => {
    const query = e.target.value.toLowerCase().trim();
    
    if (query === '') {
      this.filteredData = [...this.allData];
    } else {
      this.filteredData = this.allData.filter(item =>
        item.name.toLowerCase().includes(query)
      );
    }
    
    // 検索時は最初からリセット
    this.currentPage = 0;
    this.container.innerHTML = '';
    this.renderPage();
  });
}

CSSの最適化

JavaScriptの最適化と合わせて、CSSでも描画パフォーマンスを向上させました。

/* GPU加速とContainmentの活用 */
.be-copyable {
  transform: translateZ(0); /* GPU加速 */
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.card {
  contain: layout style paint; /* CSS Containment */
}

.cards-wrapper {
  contain: layout;
}

/* スクロール最適化 */
.cards-container {
  overflow-anchor: none;
}

CSS最適化のポイント

  1. GPU加速: transform: translateZ(0)でハードウェア加速を有効化
  2. CSS Containment: ブラウザの描画最適化を支援
  3. スクロールアンカリング無効化: 大量要素での不要な再計算を防止

大量のDOMデータを扱う際は、これらのテクニックを組み合わせることで、ユーザーにとって快適な体験を提供できます。

続きを読む