「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への移行で得られるメリット:
- より良いカプセル化
- 型安全性の向上
- パフォーマンスの改善
- モダンな機能の活用