JavaScript
実案件でのjQueryからVanilla JSへの段階的移行 - 失敗から学んだベストプラクティス
カービー
#JavaScript#jQuery#リファクタリング#実体験#ベストプラクティス
実際のECサイトでjQueryからVanilla JavaScriptへ移行した際の経験談。失敗パターンと成功パターンを具体的なコード例とともに解説します。
目次
プロジェクト概要
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) {
// さらに複雑な処理...
}
});
});
});
});
移行を決断した理由
技術的な問題
- バンドルサイズ: jQuery本体で85KB(圧縮済み)
- メンテナンス性: スパゲティコード化したイベント処理
- パフォーマンス: モバイルでの表示速度低下
- 開発効率: 新機能追加時の影響範囲が予測困難
ビジネス上の要求
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');
});
});
失敗した理由
- 互換性の問題: 既存のjQueryプラグインとの衝突
- 工数の見積もりミス: 3週間 → 実際は2ヶ月必要
- テストが不十分: エッジケースでのバグが多発
- チーム内の知識格差: 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%高速化
まとめと教訓
成功要因
- 段階的アプローチ: 一気にやらず、フェーズ分けして実施
- 互換性の維持: 既存コードと新コードが共存できる設計
- チーム教育: 移行前にVanilla JSの研修を実施
- 十分なテスト: 各フェーズでリグレッションテストを徹底
失敗から学んだ教訓
// ❌ 避けるべきパターン
- 一括置換による大規模リファクタリング
- 互換性を考慮しない書き換え
- テスト不足での本番リリース
- チームスキルを考慮しない技術選択
// ✅ 推奨パターン
- 小さく始めて段階的に拡大
- 既存システムとの共存を前提とした設計
- 自動テストとマニュアルテストの両立
- チーム全体のスキルレベルに合わせた実装
現場で使える移行チェックリスト
事前準備
- 既存コードの依存関係マップ作成
- 移行フェーズの計画策定
- チームのスキルアセスメント
- テスト環境の整備
移行実施
- 新機能はVanilla JSで実装
- 独立性の高いコンポーネントから移行
- 各フェーズでパフォーマンス測定
- ユーザーフィードバックの収集
移行完了後
- jQuery完全削除
- バンドルサイズ最適化
- モニタリング体制確立
- ドキュメント整備
次に読むべき記事
- Fetch APIとPromise: jQuery AjaxからModern JSへの完全移行ガイド
- Web Componentsで再利用可能なUIライブラリを作る
- パフォーマンス最適化の実践: Core Web Vitals改善事例
実際のプロジェクトでの移行経験を共有しました。同様の移行を検討されている方の参考になれば幸いです。質問があればお気軽にお問い合わせください。