jQuery3における高度なイベント制御とDOM操作の最適化

イベント委任のパフォーマンスと実装戦略

動的なWebアプリケーションでは、要素の追加・削除が頻繁に発生します。イベントハンドラを個別のDOMノードに直接バインドすると、新規要素に反応させるたびに再設定が必要になり、メモリリークや処理負荷の原因となります。これを回避するには、親要素に単一のリスナーを配置し、イベントのバブリングフェーズでターゲットを判定する「イベント委任」が有効です。

以下の例では、画像グリッドのコンテナーにマウスの出入りイベントを委任し、該当するアイテムの詳細パネルをフェードイン・フェードアウトさせています。closest()is()を組み合わせることで、ネストされた要素からの正確なマッチングが可能になります。

const gridContainer = document.getElementById('media-grid');

// jQuery形式の委任記法
$('#media-grid').on('mouseenter mouseleave', '.card-item', function(event) {
  const $targetCard = $(this);
  const $infoPanel = $targetCard.find('.info-overlay');
  
  if (event.type === 'mouseenter') {
    $infoPanel.stop(true).fadeTo(150, 0.85);
  } else {
    $infoPanel.stop(true).fadeOut(150);
  }
});

委任先としてdocumentbodyを指定すると記述は簡潔になりますが、ページ内の全イベントが対象となり、パフォーマンスが劣化する場合があるため、可能な限り限定された親コンテナーを指定することが推奨されます。

カスタムイベントによるロジックの分離

標準のDOMイベントに加え、アプリケーション固有の状態変化を伝えるためにカスタムイベントを定義できます。これにより、イベント発火のトリガーと実際の処理を完全に切り離し、疎結合なアーキテクチャを構築できます。

例えば、ページネーションやスクロールトリガーによるデータ取得をdataFetchイベントに統一し、引数で表示位置の制御を渡すことができます。

let currentPageIndex = 1;
const maxPages = 20;

$(document).on('click', '#load-more-btn', function(e) {
  e.preventDefault();
  // 第2引数以降で配列を渡すと、ハンドラ側で分割して受け取れる
  $(document).trigger('dataFetch', [true]); 
});

$(document).on('dataFetch', function(e, shouldScrollTop) {
  if (currentPageIndex >= maxPages) {
    $('#load-more-btn').remove();
    return;
  }

  const endpoint = `api/items?page=${currentPageIndex + 1}`;
  
  $.getJSON(endpoint).done(function(response) {
    const $newItems = $(response.items).appendTo('#item-list');
    if (shouldScrollTop) {
      $('html, body').animate({ scrollTop: $newItems.first().offset().top });
    }
    currentPageIndex++;
  });
});

高頻度イベントの制限(スロットリング)

scrollresizeなどのネイティブイベントは、ユーザー操作に応じて短時間に数十回〜数百回発生します。これらに重い計算処理を直接紐付けると、メインスレッドがブロックされUIが卡顿します。一定期間内に実行回数を制限する「スロットリング」を実装します。

以下の実装では、イベント発生時にタイマーがセットされていないかを確認し、指定ミリ秒経過した後にのみチェック関数を呼び出します。

let scrollTimer = null;

$(window).on('scroll', function() {
  if (!scrollTimer) {
    scrollTimer = setTimeout(() => {
      validateViewport();
      scrollTimer = null;
    }, 200);
  }
});

function validateViewport() {
  const windowBottom = $(window).scrollTop() + $(window).height();
  const containerHeight = $('#item-list').height();
  
  if (containerHeight - 100 <= windowBottom) {
    $(document).trigger('dataFetch');
  }
}

jQuery特殊イベントフックの活用

上記のスロットリング処理を再利用可能な形で提供する場合、jQueryの特殊イベントシステムを利用すると、呼び出し元のコードがシンプルになります。$.event.specialにプロパティを追加し、setupteardownでネイティブイベントのバインド/解除を制御します。

$.event.special.limitedScroll = {
  setup: function() {
    const handler = function() {
      const el = this;
      if (!el._throttleId) {
        el._throttleId = setTimeout(() => {
          $(el).triggerHandler('limitedScroll');
          el._throttleId = null;
        }, 250);
      }
    };
    $(this).on('scroll.internal', handler);
  },
  teardown: function() {
    $(this).off('scroll.internal');
    this._throttleId = clearTimeout(this._throttleId);
  }
};

// 利用側は通常のイベントと同じように記述可能
$(window).on('limitedScroll', validateViewport).trigger('limitedScroll');

アニメーションキューとPromise制御

連続するマウスホバー操作などでアニメーションがキューイングされ、意図しない動作の遅延が発生することがあります。.stop()メソッドを使用することで、現在のアニメーションを中断し、新しいキューへ遷移できます。

$('.member-avatar').on('mouseenter', function() {
  $(this).stop(true, false).animate({
    width: 90,
    height: 90,
    padding: 2
  }, 300);
}).on('mouseleave', function() {
  $(this).stop(true, false).animate({
    width: 75,
    height: 75,
    padding: 8
  }, 300);
});

複数の要素に対して並列に実行されたアニメーションがすべて完了した時点で処理を連鎖させる場合、jQuery 3.0以降はネイティブのPromise仕様と互換性のある.promise()が提供されています。

$('.profile-details').each(function(i) {
  $(this).animate({ top: i * 25 }, { duration: 400, easing: 'easeOutQuad' });
}).promise().then(() => {
  // すべてのアニメーションが完了後に実行される
  $('#status-log').text('表示処理が正常に完了しました');
});

クライアントサイドソートとデータキャッシュ

テーブルやリストのソート処理をブラウザ側で行う場合、DOM操作の回数を最小限に抑えるのが鍵です。ネイティブのArray.prototype.sort()を利用するには、jQueryオブジェクトをDOMノードの配列に変換する必要があります。また、比較関数内で毎回DOMからテキストを取得するとパフォーマンスが低下するため、.data()メソッドを用いて比較キーをキャッシュします。

$('#data-table').on('click', 'th[data-sort-key]', function() {
  const colIndex = $(this).index();
  const sortField = $(this).data('sort-key');
  const direction = $(this).data('direction') === 'asc' ? 1 : -1;
  
  // 既存クラスをリセットし、新しい方向を記録
  $(this).siblings().removeClass('sorted');
  $(this).addClass(`sorted-${direction === 1 ? 'asc' : 'desc'}`);
  
  // データ属性が定義されていない場合のみキーを抽出してキャッシュ
  $('tbody tr').each(function() {
    const $cell = $(this).find('td').eq(colIndex);
    const cached = $cell.data('computed-key');
    if (cached === undefined) {
      $cell.data('computed-key', $cell.text().trim().toLowerCase());
    }
  });
  
  // DOMノードの配列を取得してソート
  const rows = $('tbody tr').get().sort((a, b) => {
    const valA = $(a).find('td').eq(colIndex).data('computed-key');
    const valB = $(b).find('td').eq(colIndex).data('computed-key');
    return (valA < valB ? -1 : valA > valB ? 1 : 0) * direction;
  });
  
  // 並べ替えた配列をDOMに挿入(移動のみで複製は発生しない)
  $('tbody').append(rows);
});

HTML5のdata-*属性に数値や日付をあらかじめフォーマット済みの値として格納しておけば、JavaScript側でのパース処理が不要になり、ソート精度と速度が向上します。

CSSフックによるプロパティ拡張

jQueryの内部システムを拡張し、独自のプロパティ名でCSSを操作する場合、$.cssHooksを定義します。これにより、ベンダープレフィックスの自動付加や、複雑な計算プロパティの読み書きを隠蔽できます。

$.cssHooks.glowEffect = {
  set: function(elem, value) {
    // noneの場合は影を消去、それ以外は指定色でテキストシャドウを適用
    elem.style.textShadow = value === 'none' ? '' : `0 0 4px ${value}`;
  },
  get: function(elem) {
    // 簡易的な取得処理(実際の色解析はより複雑になる場合がある)
    return elem.style.textShadow || 'none';
  }
};

// 利用例
$('.highlight-title').css('glowEffect', '#00d4ff');
$('.highlight-title').animate({ glowEffect: '#ff4488' }, 500);

フックを登録することで、標準の.css().animate()の呼び出し構文をそのまま維持しつつ、ブラウザ固有の挙動や独自スタイルを透過的に扱うことが可能になります。

タグ: jQuery イベント委任 カスタムイベント アニメーションキュー DOMソート

7月20日 19:14 投稿