JavaScript

実案件でのjQueryからVanilla JSへの段階的移行 - 失敗から学んだベストプラクティス

カービー
実案件でのjQueryからVanilla JSへの段階的移行 - 失敗から学んだベストプラクティス
#JavaScript#jQuery#リファクタリング#実体験#ベストプラクティス

実際のECサイトでjQueryからVanilla JavaScriptへ移行した際の経験談。失敗パターンと成功パターンを具体的なコード例とともに解説します。

目次

  1. プロジェクト概要
  2. 移行を決断した理由
  3. 失敗した第一回目の移行
  4. 成功した段階的アプローチ
  5. 具体的な移行パターン
  6. パフォーマンス改善の実測値
  7. まとめと教訓

プロジェクト概要

2024年に携わったECサイトリニューアルプロジェクトでの実体験を共有します。

プロジェクト詳細

  • サイト規模: 月間10万PV、商品数約5,000点
  • 既存技術: jQuery 3.2.1 + PHP + MySQL
  • 移行期間: 6ヶ月(段階的実施)
  • チーム構成: フロントエンド2名、バックエンド1名
  • 予算: 限られた予算でのリファクタリング

移行前の課題

// 既存コードの問題例(匿名化済み)
$(document).ready(function() {
    $('.product-item').each(function() {
        $(this).on('click', function() {
            var productId = $(this).data('product-id');
            // 300行以上のスパゲティコード...
            $.ajax({
                url: '/api/product/' + productId,
                success: function(data) {
                    // さらに複雑な処理...
                }
            });
        });
    });
});

移行を決断した理由

技術的な問題

  1. バンドルサイズ: jQuery本体で85KB(圧縮済み)
  2. メンテナンス性: スパゲティコード化したイベント処理
  3. パフォーマンス: モバイルでの表示速度低下
  4. 開発効率: 新機能追加時の影響範囲が予測困難

ビジネス上の要求

Core Web Vitals改善要求:
- LCP: 4.2s → 2.5s以下
- FID: 280ms → 100ms以下
- CLS: 0.25 → 0.1以下

失敗した第一回目の移行

失敗アプローチ:一括置換戦略

最初は「一気にすべてをVanilla JSに書き換えよう」と考えました。

// ❌ 失敗例:jQueryを機械的にVanilla JSに変換
// Before (jQuery)
$('.product-item').on('click', function() {
    $(this).addClass('selected');
});

// After (失敗したVanilla JS)
document.querySelectorAll('.product-item').forEach(function(item) {
    item.addEventListener('click', function() {
        this.classList.add('selected');
    });
});

失敗した理由

  1. 互換性の問題: 既存のjQueryプラグインとの衝突
  2. 工数の見積もりミス: 3週間 → 実際は2ヶ月必要
  3. テストが不十分: エッジケースでのバグが多発
  4. チーム内の知識格差: Vanilla JSの経験が浅いメンバーの対応

実際に発生した問題

// 問題1: イベント委譲の実装ミス
// jQuery版(動作OK)
$(document).on('click', '.dynamic-button', function() {
    // 動的に追加されたボタンも対応
});

// 失敗したVanilla JS版
document.querySelectorAll('.dynamic-button').forEach(btn => {
    btn.addEventListener('click', function() {
        // 後から追加された要素は反応しない!
    });
});

// 問題2: アニメーションの互換性
// jQuery版
$('.modal').fadeIn(300);

// 問題のあるVanilla JS版
element.style.display = 'block'; // アニメーションなし

成功した段階的アプローチ

移行戦略の見直し

graph TD
    A[Phase 1: 新機能はVanilla JS] --> B[Phase 2: 独立コンポーネント移行]
    B --> C[Phase 3: 共通ユーティリティ移行]
    C --> D[Phase 4: jQuery完全削除]

Phase 1: 新機能はVanilla JS(1ヶ月目)

// 新しい商品フィルター機能
class ProductFilter {
    constructor(containerSelector) {
        this.container = document.querySelector(containerSelector);
        this.init();
    }
    
    init() {
        this.bindEvents();
        this.loadInitialData();
    }
    
    bindEvents() {
        this.container.addEventListener('change', (e) => {
            if (e.target.matches('.filter-checkbox')) {
                this.applyFilter();
            }
        });
    }
    
    applyFilter() {
        // Fetch APIを使用
        fetch('/api/products/filter', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(this.getFilterData())
        })
        .then(response => response.json())
        .then(data => this.updateProductList(data))
        .catch(error => console.error('Filter error:', error));
    }
}

// 実装
const productFilter = new ProductFilter('#product-filter');

結果: 新機能は問題なく動作し、チームのVanilla JS スキル向上

Phase 2: 独立コンポーネント移行(2-3ヶ月目)

// モーダルコンポーネントの移行例
class Modal {
    constructor(modalId) {
        this.modal = document.getElementById(modalId);
        this.overlay = this.modal.querySelector('.modal-overlay');
        this.closeBtn = this.modal.querySelector('.modal-close');
        
        this.bindEvents();
    }
    
    bindEvents() {
        // ESCキーで閉じる
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape' && this.isOpen()) {
                this.close();
            }
        });
        
        // オーバーレイクリックで閉じる
        this.overlay.addEventListener('click', (e) => {
            if (e.target === this.overlay) {
                this.close();
            }
        });
        
        // 閉じるボタン
        this.closeBtn.addEventListener('click', () => this.close());
    }
    
    open() {
        this.modal.classList.add('is-active');
        document.body.style.overflow = 'hidden';
        
        // アニメーション
        requestAnimationFrame(() => {
            this.modal.style.opacity = '1';
        });
    }
    
    close() {
        this.modal.style.opacity = '0';
        
        setTimeout(() => {
            this.modal.classList.remove('is-active');
            document.body.style.overflow = '';
        }, 200);
    }
    
    isOpen() {
        return this.modal.classList.contains('is-active');
    }
}

// 既存のjQueryコードとの共存
window.Modal = Modal; // グローバルに公開

// 段階的置換
// $('.modal-trigger').on('click', function() {
//     const modal = new Modal($(this).data('target'));
//     modal.open();
// });

Phase 3: 共通ユーティリティ移行(4-5ヶ月目)

// jQuery的な書き味を維持したVanilla JSヘルパー
class $ {
    constructor(selector) {
        if (typeof selector === 'string') {
            this.elements = Array.from(document.querySelectorAll(selector));
        } else if (selector instanceof Element) {
            this.elements = [selector];
        } else {
            this.elements = [];
        }
    }
    
    on(event, callback) {
        this.elements.forEach(el => el.addEventListener(event, callback));
        return this;
    }
    
    addClass(className) {
        this.elements.forEach(el => el.classList.add(className));
        return this;
    }
    
    removeClass(className) {
        this.elements.forEach(el => el.classList.remove(className));
        return this;
    }
    
    text(content) {
        if (content === undefined) {
            return this.elements[0]?.textContent || '';
        }
        this.elements.forEach(el => el.textContent = content);
        return this;
    }
    
    fadeIn(duration = 300) {
        this.elements.forEach(el => {
            el.style.display = 'block';
            el.style.opacity = '0';
            
            requestAnimationFrame(() => {
                el.style.transition = `opacity ${duration}ms ease-in-out`;
                el.style.opacity = '1';
            });
        });
        return this;
    }
    
    fadeOut(duration = 300) {
        this.elements.forEach(el => {
            el.style.transition = `opacity ${duration}ms ease-in-out`;
            el.style.opacity = '0';
            
            setTimeout(() => {
                el.style.display = 'none';
            }, duration);
        });
        return this;
    }
}

// グローバル関数として提供(jQueryとの互換性)
function $(selector) {
    return new $(selector);
}

// 使用例(jQueryとほぼ同じ書き味)
$('.product-item').on('click', function() {
    $(this).addClass('selected');
});

具体的な移行パターン

パターン1: Ajax通信の移行

// Before: jQuery Ajax
$.ajax({
    url: '/api/products',
    method: 'GET',
    dataType: 'json',
    success: function(data) {
        renderProducts(data);
    },
    error: function(xhr, status, error) {
        showError('商品データの取得に失敗しました');
    }
});

// After: Fetch API with error handling
async function loadProducts() {
    try {
        const response = await fetch('/api/products');
        
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const data = await response.json();
        renderProducts(data);
        
    } catch (error) {
        console.error('Products load error:', error);
        showError('商品データの取得に失敗しました');
    }
}

// 互換性を保つラッパー関数
function ajax(options) {
    const {url, method = 'GET', data, success, error} = options;
    
    let fetchOptions = {
        method: method.toUpperCase(),
        headers: {
            'Content-Type': 'application/json'
        }
    };
    
    if (data) {
        fetchOptions.body = JSON.stringify(data);
    }
    
    fetch(url, fetchOptions)
        .then(response => response.json())
        .then(success)
        .catch(error);
}

パターン2: イベント処理の移行

// Before: jQuery event delegation
$(document).on('click', '.add-to-cart', function(e) {
    e.preventDefault();
    const productId = $(this).data('product-id');
    addToCart(productId);
});

// After: Vanilla JS with better performance
class CartManager {
    constructor() {
        this.bindEvents();
    }
    
    bindEvents() {
        // 単一のイベントリスナーで効率的に処理
        document.addEventListener('click', (e) => {
            if (e.target.matches('.add-to-cart') || e.target.closest('.add-to-cart')) {
                e.preventDefault();
                this.handleAddToCart(e.target.closest('.add-to-cart'));
            }
        });
    }
    
    handleAddToCart(button) {
        const productId = button.dataset.productId;
        const quantity = parseInt(button.dataset.quantity) || 1;
        
        this.addToCart(productId, quantity);
    }
    
    async addToCart(productId, quantity) {
        try {
            const response = await fetch('/api/cart/add', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
                },
                body: JSON.stringify({
                    product_id: productId,
                    quantity: quantity
                })
            });
            
            if (response.ok) {
                const result = await response.json();
                this.updateCartUI(result);
                this.showSuccessMessage('カートに追加しました');
            }
        } catch (error) {
            console.error('Add to cart error:', error);
            this.showErrorMessage('カートへの追加に失敗しました');
        }
    }
}

// 初期化
document.addEventListener('DOMContentLoaded', () => {
    new CartManager();
});

パフォーマンス改善の実測値

バンドルサイズの改善

移行前:
├── jquery.min.js: 85KB
├── jquery-ui.min.js: 63KB  
├── custom.js: 42KB
└── 合計: 190KB

移行後:
├── main.js: 28KB
├── components.js: 15KB
└── 合計: 43KB

改善率: 77%削減

Core Web Vitals の改善

指標 移行前 移行後 改善率
LCP 4.2s 2.1s 50%
FID 280ms 85ms 70%
CLS 0.25 0.08 68%

実際のユーザー体験の変化

// 移行前: jQuery版カート更新(ユーザー体感)
- ボタンクリック → 500ms待機 → 更新完了

// 移行後: Vanilla JS版(ユーザー体感)  
- ボタンクリック → 150ms待機 → 更新完了

改善: 70%高速化

まとめと教訓

成功要因

  1. 段階的アプローチ: 一気にやらず、フェーズ分けして実施
  2. 互換性の維持: 既存コードと新コードが共存できる設計
  3. チーム教育: 移行前にVanilla JSの研修を実施
  4. 十分なテスト: 各フェーズでリグレッションテストを徹底

失敗から学んだ教訓

// ❌ 避けるべきパターン
- 一括置換による大規模リファクタリング
- 互換性を考慮しない書き換え
- テスト不足での本番リリース
- チームスキルを考慮しない技術選択

// ✅ 推奨パターン
- 小さく始めて段階的に拡大
- 既存システムとの共存を前提とした設計
- 自動テストとマニュアルテストの両立
- チーム全体のスキルレベルに合わせた実装

現場で使える移行チェックリスト

事前準備

  • 既存コードの依存関係マップ作成
  • 移行フェーズの計画策定
  • チームのスキルアセスメント
  • テスト環境の整備

移行実施

  • 新機能はVanilla JSで実装
  • 独立性の高いコンポーネントから移行
  • 各フェーズでパフォーマンス測定
  • ユーザーフィードバックの収集

移行完了後

  • jQuery完全削除
  • バンドルサイズ最適化
  • モニタリング体制確立
  • ドキュメント整備

次に読むべき記事

実際のプロジェクトでの移行経験を共有しました。同様の移行を検討されている方の参考になれば幸いです。質問があればお気軽にお問い合わせください。