シンプルなタブUIの実装、それは多くの開発者が最初に手続き型で書き始めるコンポーネントの一つです。
しかし、タブの数が増えたり、アニメーションやイベント制御が必要になった時、そのコードはすぐに複雑化してしまいます。
この記事では、実際のタブUIの実装を例に、クラスベースの設計がもたらす明確なメリットと、段階的な改善方法を解説します。
はじめに:よくあるタブUI実装の課題
タブUIは、Webアプリケーションでよく使用されるUIパターンの一つです。一見シンプルに見えるこのコンポーネントですが、実際の開発では様々な課題に直面します。
よくある課題
コードの複雑化
- タブの数が増えるたびにイベントリスナーが増加
- 状態管理が煩雑になる
- セレクタの重複が発生
保守性の問題
- 機能追加時のコード修正が困難
- バグ修正の影響範囲が不明確
- テストが書きにくい
例えば、以下のような要件が追加されると、手続き型のコードはすぐに複雑化してしまいます:
- アクティブなタブのスタイル管理
- タブの動的な追加・削除
- タブ切り替えのアニメーション
- 非同期コンテンツの読み込み
このような課題を解決するため、私たちはコードをより構造化された形に改善する必要があります。
次のセクションでは、まず一般的な手続き型の実装を見た上で、クラスベースの設計にリファクタリングしていく過程を詳しく見ていきましょう。
手続き型による実装:シンプルなタブUIの基本
まずは、多くの開発者が最初に書くような手続き型のタブUI実装を見てみましょう。
See the Pen B-tabUI_001 by R (@rkpg) on CodePen.
const tabButtons = document.querySelectorAll('.tab-button');
const tabContents = document.querySelectorAll('.tab-content');
function switchTab(event) {
// アクティブクラスを全て削除
tabButtons.forEach(button => button.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// クリックされたボタンをアクティブに
const button = event.target;
button.classList.add('active');
// 対応するコンテンツをアクティブに
const tabId = button.dataset.tab;
const content = document.getElementById(tabId);
content.classList.add('active');
}
tabButtons.forEach(button => {
button.addEventListener('click', switchTab);
});
この実装の問題点
- スケーラビリティの欠如
- 新しいタブ(例:「Videos」や「Stories」)を追加する度にHTMLの修正が必要
- 動的なコンテンツ(例:投稿数やフレンド数の表示)の更新が難しい
- タブ内のコンテンツを非同期で読み込む場合の制御が複雑
- 状態管理の不透明さ
- 現在のアクティブタブの状態をURLと同期できない
- ブラウザの戻る/進むボタンでのタブ遷移に対応できない
- タブの履歴管理ができない
- 機能拡張の難しさ
- タブ切り替え時のローディング表示の追加が複雑
- タブ内のコンテンツ更新通知(新着表示)の実装が困難
- モバイル対応(スワイプでのタブ切り替えなど)の追加が面倒
- アクセシビリティ対応(キーボード操作、スクリーンリーダー対応)が不十分
- 保守性の課題
- イベントリスナーの管理が煩雑
- タブごとの独立したスタイル適用が難しい
- 複数のタブUIが存在する場合の管理が複雑
これらの問題点は、実際のSNSのような大規模アプリケーションでより顕著になります。次のセクションでは、これらの課題をクラスベースの設計でどのように解決できるかを見ていきましょう。
クラスベースへの改善:柔軟で拡張性のあるタブUI実装
先ほどの手続き型のコードを、より柔軟で保守性の高いクラスベースの実装に改善していきましょう。
See the Pen Untitled by R (@rkpg) on CodePen.
class TabUI {
constructor(containerSelector) {
this.container = document.querySelector(containerSelector);
this.tabButtons = this.container.querySelectorAll('.tab-button');
this.tabContents = this.container.querySelectorAll('.tab-content');
this.activeTabId = null;
this.callbacks = new Map(); // イベントコールバックの管理用
this.init();
}
init() {
// 初期アクティブタブの設定
const activeButton = this.container.querySelector('.tab-button.active');
if (activeButton) {
this.activeTabId = activeButton.dataset.tab;
}
// イベントリスナーの設定
this.tabButtons.forEach(button => {
button.addEventListener('click', (e) => this.switchTab(e));
button.addEventListener('keydown', (e) => this.handleKeyboard(e));
});
// アクセシビリティ対応
this.setupA11y();
}
switchTab(event) {
const button = event.target;
const newTabId = button.dataset.tab;
// 同じタブをクリックした場合は何もしない
if (this.activeTabId === newTabId) return;
// 状態の更新前に前回の状態を保存
const previousTabId = this.activeTabId;
// UI更新
this.updateUI(newTabId);
// 状態の更新
this.activeTabId = newTabId;
// カスタムイベントの発火
this.emit('tabChange', {
previousTabId,
currentTabId: newTabId
});
}
updateUI(tabId) {
// ボタンの状態更新
this.tabButtons.forEach(button => {
button.classList.toggle('active', button.dataset.tab === tabId);
button.setAttribute('aria-selected', button.dataset.tab === tabId);
});
// コンテンツの状態更新
this.tabContents.forEach(content => {
content.classList.toggle('active', content.id === tabId);
content.setAttribute('aria-hidden', content.id !== tabId);
});
}
// イベント管理機能
on(eventName, callback) {
if (!this.callbacks.has(eventName)) {
this.callbacks.set(eventName, []);
}
this.callbacks.get(eventName).push(callback);
}
emit(eventName, data) {
const callbacks = this.callbacks.get(eventName) || [];
callbacks.forEach(callback => callback(data));
}
// アクセシビリティ設定
setupA11y() {
this.container.setAttribute('role', 'tablist');
this.tabButtons.forEach(button => {
button.setAttribute('role', 'tab');
button.setAttribute('aria-selected', 'false');
});
this.tabContents.forEach(content => {
content.setAttribute('role', 'tabpanel');
content.setAttribute('aria-hidden', 'true');
});
}
}
次のセクションでは、この実装をベースに、さらなる機能拡張の方法を見ていきましょう。
実装の詳細解説:クラス設計の重要なポイント
クラスベースの実装について、重要なポイントを詳しく解説していきます。
1. コンストラクタでの初期化
constructor(containerSelector) {
this.container = document.querySelector(containerSelector);
this.tabButtons = this.container.querySelectorAll('.tab-button');
this.tabContents = this.container.querySelectorAll('.tab-content');
this.activeTabId = null;
this.callbacks = new Map();
this.init();
}
- セレクタを引数で受け取ることで、複数のタブUIを独立して管理可能
- プロパティを明確に定義し、スコープを限定
Map
を使用してイベントコールバックをクリーンに管理
2. イベント管理の改善
// イベントの購読
on(eventName, callback) {
if (!this.callbacks.has(eventName)) {
this.callbacks.set(eventName, []);
}
this.callbacks.get(eventName).push(callback);
}
// イベントの発火
emit(eventName, data) {
const callbacks = this.callbacks.get(eventName) || [];
callbacks.forEach(callback => callback(data));
}
このイベントシステムにより:
- タブの切り替え前後で任意の処理を追加可能
- 外部のコードとの疎結合な連携が実現
- アナリティクスやログ収集の容易な実装
3. 状態管理の明確化
switchTab(event) {
const button = event.target;
const newTabId = button.dataset.tab;
// 同じタブの重複クリック防止
if (this.activeTabId === newTabId) return;
const previousTabId = this.activeTabId;
this.updateUI(newTabId);
this.activeTabId = newTabId;
// 状態変更を通知
this.emit('tabChange', {
previousTabId,
currentTabId: newTabId
});
}
- アクティブタブのIDを明示的に管理
- 不要な再描画の防止
- 状態変更の履歴を追跡可能
このように、クラスベースの設計により、コードの見通しが良くなり、機能の追加も容易になります。
次のセクションでは、この基盤を活用した具体的な機能拡張の例を見ていきましょう。
まとめ:タブUIから学ぶクラス設計の価値
タブUIの実装を通じて、クラスベースの設計がもたらす具体的なメリットを見てきました。
手続き型からクラスベースへの移行で得られるもの
1.コードの構造化
- 関連する機能と状態を1つのクラスにカプセル化
- 責務の明確な分離
- メンテナンス性の向上
2.拡張性の確保
const tabs = new TabUI('.tab-container');
// 必要に応じて機能を拡張可能
tabs.on('tabChange', (data) => {
// 任意の処理を追加
});
3.再利用性の向上
- 複数のタブUIを独立して管理可能
- コードの重複を防止
- 一貫性のある実装
設計改善のポイント
- 状態管理を明確にする
- イベントシステムを導入する
- メソッドの責務を適切に分割する
- アクセシビリティを考慮する
このようなクラスベースの設計アプローチは、タブUIに限らず他のUIコンポーネントの実装にも応用できます。
よりメンテナンス性が高く、拡張性のあるコードを目指す際の参考にしていただければ幸いです。