実践編:jQueryプラグインをVanilla JSで書き直す

「jQueryプラグインをモダンに書き換えたいけど、どこから始めればいい?」 「カスタムイベントの実装ってどうするの?」 「オプションの受け渡しはどうやって設計する?」

前回までに学んだモダンJavaScriptの知識を活かして、実際のjQueryプラグインをVanilla JSで書き直す方法を解説します。例として、よくある「タブ切り替え」プラグインのリファクタリングを行っていきましょう。

オリジナルのjQueryプラグイン

まず、リファクタリング対象のjQueryプラグインを見てみましょう:

JS
// jQuery版タブプラグイン
(function($) {
    $.fn.simpleTabs = function(options) {
        var settings = $.extend({
            activeClass: 'active',
            onChange: null
        }, options);

        return this.each(function() {
            var $container = $(this);
            var $tabs = $container.find('.tab');
            var $contents = $container.find('.tab-content');

            $tabs.on('click', function(e) {
                e.preventDefault();
                var $tab = $(this);
                var target = $tab.data('target');

                $tabs.removeClass(settings.activeClass);
                $tab.addClass(settings.activeClass);

                $contents.hide();
                $('#' + target).show();

                if (settings.onChange) {
                    settings.onChange(target);
                }
            });
        });
    };
})(jQuery);

// 使用例
$('#tabContainer').simpleTabs({
    onChange: function(target) {
        console.log('Tab changed to:', target);
    }
});

モダンなクラスベースの実装

JS
class SimpleTabs {
    #options;
    #container;
    #tabs;
    #contents;

    static defaultOptions = {
        activeClass: 'active',
        onChange: null
    };

    constructor(selector, options = {}) {
        this.#options = { ...SimpleTabs.defaultOptions, ...options };
        this.#container = document.querySelector(selector);
        if (!this.#container) throw new Error('Container element not found');

        this.#tabs = this.#container.querySelectorAll('.tab');
        this.#contents = this.#container.querySelectorAll('.tab-content');
        
        this.#init();
    }

    #init() {
        this.#tabs.forEach(tab => {
            tab.addEventListener('click', (e) => {
                e.preventDefault();
                this.#handleTabClick(tab);
            });
        });
    }

    #handleTabClick(selectedTab) {
        const target = selectedTab.dataset.target;

        // タブの切り替え
        this.#tabs.forEach(tab => {
            tab.classList.toggle(
                this.#options.activeClass,
                tab === selectedTab
            );
        });

        // コンテンツの切り替え
        this.#contents.forEach(content => {
            content.style.display = 
                content.id === target ? 'block' : 'none';
        });

        // カスタムイベントの発火
        this.#emit('change', target);

        // コールバックの実行
        if (this.#options.onChange) {
            this.#options.onChange(target);
        }
    }

    #emit(eventName, detail) {
        const event = new CustomEvent(`tabs:${eventName}`, {
            bubbles: true,
            detail
        });
        this.#container.dispatchEvent(event);
    }
}

クラスベース実装のポイント:

  • プライベートフィールドで内部状態を保護
  • 静的プロパティでデフォルトオプションを定義
  • カスタムイベントを活用
  • メソッドの責務を明確に分離

使い方の例

JS
// 基本的な初期化
const tabs = new SimpleTabs('#tabContainer', {
    onChange: (target) => {
        console.log('Tab changed to:', target);
    }
});

// カスタムイベントのリスニング
document.querySelector('#tabContainer')
    .addEventListener('tabs:change', (e) => {
        console.log('Tab changed event:', e.detail);
    });

拡張性を考慮した実装

より柔軟な実装を目指す場合:

JS
// 基底コンポーネントクラス
class Component {
    #element;
    #events = {};

    constructor(element) {
        this.#element = element;
    }

    on(eventName, handler) {
        if (!this.#events[eventName]) {
            this.#events[eventName] = [];
        }
        this.#events[eventName].push(handler);
    }

    off(eventName, handler) {
        if (!this.#events[eventName]) return;
        if (handler) {
            this.#events[eventName] = this.#events[eventName]
                .filter(h => h !== handler);
        } else {
            delete this.#events[eventName];
        }
    }

    emit(eventName, data) {
        if (!this.#events[eventName]) return;
        this.#events[eventName].forEach(handler => handler(data));
    }

    getElement() {
        return this.#element;
    }
}

// 拡張版タブクラス
class AdvancedTabs extends Component {
    #options;
    #tabs;
    #contents;
    #activeTab = null;

    constructor(selector, options = {}) {
        const element = document.querySelector(selector);
        super(element);

        this.#options = {
            activeClass: 'active',
            onChange: null,
            onInit: null,
            ...options
        };

        this.#init();
    }

    #init() {
        // 初期化処理
        this.#setup();
        this.#bindEvents();

        if (this.#options.onInit) {
            this.#options.onInit(this);
        }
    }

    #setup() {
        this.#tabs = [...this.getElement().querySelectorAll('.tab')];
        this.#contents = [...this.getElement().querySelectorAll('.tab-content')];
        
        // 最初のタブをアクティブに
        if (this.#tabs.length) {
            this.switchTab(this.#tabs[0]);
        }
    }

    #bindEvents() {
        this.#tabs.forEach(tab => {
            tab.addEventListener('click', (e) => {
                e.preventDefault();
                this.switchTab(tab);
            });
        });
    }

    switchTab(tab) {
        const target = tab.dataset.target;
        const content = document.getElementById(target);

        if (!content || tab === this.#activeTab) return;

        // 以前のアクティブ状態をクリア
        if (this.#activeTab) {
            this.#activeTab.classList.remove(this.#options.activeClass);
        }

        // 新しいタブをアクティブに
        this.#activeTab = tab;
        tab.classList.add(this.#options.activeClass);

        // コンテンツの切り替え
        this.#contents.forEach(c => {
            c.style.display = c === content ? 'block' : 'none';
        });

        // イベントの発火
        this.emit('change', { tab, content });

        if (this.#options.onChange) {
            this.#options.onChange(target);
        }
    }

    getCurrentTab() {
        return this.#activeTab;
    }
}

アニメーション対応版

JS
class AnimatedTabs extends AdvancedTabs {
    constructor(selector, options = {}) {
        super(selector, {
            ...options,
            animationDuration: 300,
            animationType: 'fade' // 'fade' or 'slide'
        });
    }

    async switchTab(tab) {
        const target = tab.dataset.target;
        const content = document.getElementById(target);
        const oldContent = this.getCurrentContent();

        if (!content || tab === this.getCurrentTab()) return;

        // アニメーションの適用
        await this.#animateContentSwitch(oldContent, content);

        // 親クラスの処理を呼び出し
        super.switchTab(tab);
    }

    async #animateContentSwitch(oldContent, newContent) {
        if (this.#options.animationType === 'fade') {
            await this.#fadeAnimation(oldContent, newContent);
        } else {
            await this.#slideAnimation(oldContent, newContent);
        }
    }

    #fadeAnimation(oldContent, newContent) {
        return new Promise(resolve => {
            const duration = this.#options.animationDuration;

            // フェードアウト
            oldContent.style.opacity = '0';
            oldContent.style.transition = `opacity ${duration}ms`;

            setTimeout(() => {
                oldContent.style.display = 'none';
                
                // フェードイン
                newContent.style.display = 'block';
                newContent.style.opacity = '0';
                
                requestAnimationFrame(() => {
                    newContent.style.opacity = '1';
                    setTimeout(resolve, duration);
                });
            }, duration);
        });
    }
}

パフォーマンス最適化

JS
class OptimizedTabs extends AdvancedTabs {
    #intersectionObserver;
    #tabPositions = new Map();
    #isScrolling = false;

    constructor(selector, options = {}) {
        super(selector, options);
        this.#setupLazyLoading();
        this.#cachePositions();
    }

    #setupLazyLoading() {
        this.#intersectionObserver = new IntersectionObserver(
            (entries) => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        this.#loadContent(entry.target);
                    }
                });
            },
            { rootMargin: '50px' }
        );

        this.#contents.forEach(content => {
            if (content.dataset.src) {
                this.#intersectionObserver.observe(content);
            }
        });
    }

    async #loadContent(content) {
        if (content.dataset.src) {
            try {
                const response = await fetch(content.dataset.src);
                const html = await response.text();
                content.innerHTML = html;
                delete content.dataset.src;
            } catch (error) {
                console.error('Content loading failed:', error);
            }
        }
    }

    #cachePositions() {
        this.#tabs.forEach(tab => {
            this.#tabPositions.set(tab, tab.getBoundingClientRect());
        });
    }
}

まとめ

jQueryプラグインからVanilla JSへの移行で得られるメリット:

  • より良いカプセル化
  • 型安全性の向上
  • パフォーマンスの改善
  • モダンな機能の活用