JavaScriptのイベントハンドリング完全ガイド|クリック・スクロール・フォーム送信を正しく扱う


はじめに

前回の記事ではCSS GridとFlexboxの使い分けと2次元レイアウトの実装方法を解説しました。今回はJavaScriptのイベントハンドリングを解説します。「ボタンをクリックしたら何かする」「フォームを送信したらバリデーションする」「スクロールしたらヘッダーを変化させる」といったユーザーの操作に応じて動作するJavaScriptはすべてイベントハンドリングで実装します。正しい書き方を身につけることで、バグが少なくメンテナンスしやすいコードが書けるようになります。


1. イベントとは何か

1.1 イベントの基本概念

イベントとは:

Webページ上で発生する「出来事」のこと。
ユーザーの操作・ブラウザの動作など
さまざまなタイミングで発生する。

よく使うイベントの種類:

マウス操作:
→ click       :クリックしたとき
→ dblclick    :ダブルクリックしたとき
→ mouseenter  :マウスが要素に入ったとき
→ mouseleave  :マウスが要素から出たとき
→ mousemove   :マウスが動いたとき

キーボード操作:
→ keydown     :キーを押したとき
→ keyup       :キーを離したとき
→ keypress    :キーを押し続けているとき(非推奨)

フォーム:
→ submit      :フォームが送信されたとき
→ change      :値が変わったとき
→ input       :入力のたびに発火
→ focus       :フォーカスが当たったとき
→ blur        :フォーカスが外れたとき

ページ・スクロール:
→ load        :ページが読み込まれたとき
→ DOMContentLoaded:DOMの解析が完了したとき
→ scroll      :スクロールしたとき
→ resize      :ウィンドウサイズが変わったとき

2. addEventListener の基本

2.1 正しいイベントリスナーの書き方

/*
  addEventListener の構文:
  要素.addEventListener( イベント名, 関数 )
*/

// ① 要素を取得する
const button = document.querySelector( '.btn-submit' );

// ② イベントリスナーを追加する
button.addEventListener( 'click', function() {
    console.log( 'ボタンがクリックされました' );
} );

// アロー関数で書く場合(推奨)
button.addEventListener( 'click', () => {
    console.log( 'ボタンがクリックされました' );
} );

// 関数を別で定義して渡す場合
function handleClick() {
    console.log( 'ボタンがクリックされました' );
}

button.addEventListener( 'click', handleClick );
// ※ handleClick() ではなく handleClick(括弧なし)
// 括弧をつけると即実行されてしまう

2.2 onclick との違い

// ❌ onclick属性(HTMLに書く方法)は避ける
// <button onclick="doSomething()">クリック</button>

// ❌ onclickプロパティも避ける
button.onclick = function() { ... };
// → 1つしか設定できない・後で上書きされる

// ✅ addEventListener を使う
button.addEventListener( 'click', handler1 );
button.addEventListener( 'click', handler2 );
// → 複数のリスナーを同じイベントに追加できる
// → 後から removeEventListener で削除できる

3. イベントオブジェクト

3.1 イベントオブジェクトとは

/*
  イベントリスナーの関数には
  自動的に「イベントオブジェクト」が渡される。
  慣例として e または event という引数名を使う。
*/

button.addEventListener( 'click', ( e ) => {
    console.log( e );           // イベントオブジェクト全体
    console.log( e.type );      // 'click'
    console.log( e.target );    // クリックされた要素
    console.log( e.clientX );   // クリックされたX座標
    console.log( e.clientY );   // クリックされたY座標
} );

3.2 preventDefault():デフォルトの動作を止める

/*
  preventDefault():
  ブラウザのデフォルトの動作をキャンセルする

  よく使う場面:
  → フォームの送信を止めてJSでバリデーションする
  → リンクのページ遷移を止める
  → チェックボックスのチェック動作を制御する
*/

// フォーム送信のデフォルト動作(ページリロード)を止める
const form = document.querySelector( '#contact-form' );

form.addEventListener( 'submit', ( e ) => {
    e.preventDefault(); // ← これがないとページがリロードされる

    // バリデーション処理
    const name = document.querySelector( '#name' ).value;
    if ( name === '' ) {
        alert( '名前を入力してください' );
        return;
    }

    // バリデーション通過後の処理
    console.log( '送信OK' );
} );

// リンクのページ遷移を止める
const link = document.querySelector( '.smooth-link' );

link.addEventListener( 'click', ( e ) => {
    e.preventDefault(); // 通常のページ遷移をキャンセル
    // スムーススクロールなどの独自処理を書く
} );

3.3 stopPropagation():イベントの伝播を止める

/*
  イベントバブリング:
  子要素で発生したイベントが
  親要素→祖父母要素→...と伝わっていく仕組み

  stopPropagation() で伝播を止められる
*/

const outer = document.querySelector( '.outer' );
const inner = document.querySelector( '.inner' );

outer.addEventListener( 'click', () => {
    console.log( 'outerがクリックされた' );
} );

inner.addEventListener( 'click', ( e ) => {
    e.stopPropagation(); // outerへの伝播を止める
    console.log( 'innerがクリックされた' );
} );

// innerをクリックした場合:
// → stopPropagationなし:inner→outerの順で両方発火
// → stopPropagationあり:innerだけ発火

4. クリックイベントの実践

4.1 ハンバーガーメニューの実装

const hamburger = document.querySelector( '.hamburger' );
const navMenu   = document.querySelector( '.nav-menu' );

hamburger.addEventListener( 'click', () => {
    // is-openクラスを付け外しする
    navMenu.classList.toggle( 'is-open' );
    hamburger.classList.toggle( 'is-active' );

    // アクセシビリティ対応
    const isOpen = navMenu.classList.contains( 'is-open' );
    hamburger.setAttribute( 'aria-expanded', isOpen );
} );

// メニューの外をクリックしたら閉じる
document.addEventListener( 'click', ( e ) => {
    // クリックされた要素がメニューまたはハンバーガーでなければ閉じる
    if (
        !navMenu.contains( e.target ) &&
        !hamburger.contains( e.target )
    ) {
        navMenu.classList.remove( 'is-open' );
        hamburger.classList.remove( 'is-active' );
        hamburger.setAttribute( 'aria-expanded', false );
    }
} );

4.2 モーダルの開閉

const modalOpenBtn  = document.querySelector( '.modal-open' );
const modalCloseBtns = document.querySelectorAll( '.modal-close' );
const modalOverlay  = document.querySelector( '.modal-overlay' );
const modal         = document.querySelector( '.modal' );

// モーダルを開く
function openModal() {
    modal.classList.add( 'is-visible' );
    document.body.style.overflow = 'hidden'; // 背景スクロールを止める
}

// モーダルを閉じる
function closeModal() {
    modal.classList.remove( 'is-visible' );
    document.body.style.overflow = ''; // スクロールを戻す
}

// 開くボタン
modalOpenBtn.addEventListener( 'click', openModal );

// 閉じるボタン(複数ある場合)
modalCloseBtns.forEach( ( btn ) => {
    btn.addEventListener( 'click', closeModal );
} );

// オーバーレイクリックで閉じる
modalOverlay.addEventListener( 'click', closeModal );

// Escキーで閉じる
document.addEventListener( 'keydown', ( e ) => {
    if ( e.key === 'Escape' ) {
        closeModal();
    }
} );

4.3 タブ切り替え

const tabBtns     = document.querySelectorAll( '.tab-btn' );
const tabContents = document.querySelectorAll( '.tab-content' );

tabBtns.forEach( ( btn ) => {
    btn.addEventListener( 'click', () => {
        // クリックされたタブのdata属性からターゲットを取得
        const target = btn.dataset.tab; // 例:'tab1'

        // すべてのタブからis-activeを外す
        tabBtns.forEach( ( b ) => b.classList.remove( 'is-active' ) );
        tabContents.forEach( ( c ) => c.classList.remove( 'is-active' ) );

        // クリックされたタブだけis-activeを付ける
        btn.classList.add( 'is-active' );
        document.querySelector( `#${ target }` ).classList.add( 'is-active' );
    } );
} );

/*
  HTML側の構造:
  <button class="tab-btn" data-tab="tab1">タブ1</button>
  <button class="tab-btn" data-tab="tab2">タブ2</button>

  <div class="tab-content is-active" id="tab1">...</div>
  <div class="tab-content" id="tab2">...</div>
*/

5. スクロールイベントの実践

5.1 スクロール量を取得する

window.addEventListener( 'scroll', () => {
    const scrollY = window.scrollY; // 縦方向のスクロール量(px)
    console.log( scrollY );
} );

5.2 スクロールでヘッダーのスタイルを変える

const header = document.querySelector( '.header' );

window.addEventListener( 'scroll', () => {
    if ( window.scrollY > 80 ) {
        // 80px以上スクロールしたら背景を追加
        header.classList.add( 'is-scrolled' );
    } else {
        header.classList.remove( 'is-scrolled' );
    }
} );

/*
  CSSで見た目を切り替える:
  .header {
      background: transparent;
      transition: background 0.3s ease;
  }

  .header.is-scrolled {
      background: #ffffff;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  }
*/

5.3 スクロールイベントのパフォーマンス改善

/*
  scroll・resize イベントは高頻度で発火するため
  そのままだと処理が重くなる。

  「スロットリング」でパフォーマンスを改善する:
  → 一定時間内に1回だけ実行するように制限する
*/

let isScrolling = false;

window.addEventListener( 'scroll', () => {
    if ( isScrolling ) return; // すでに処理中なら何もしない

    isScrolling = true;

    requestAnimationFrame( () => {
        // ここに処理を書く
        const scrollY = window.scrollY;

        if ( scrollY > 80 ) {
            header.classList.add( 'is-scrolled' );
        } else {
            header.classList.remove( 'is-scrolled' );
        }

        isScrolling = false; // 処理完了したらフラグをリセット
    } );
} );

/*
  requestAnimationFrame:
  → ブラウザの描画タイミング(約60fps)に合わせて実行される
  → スクロールアニメーションに最適
*/

5.4 特定の要素が画面に入ったら発火(IntersectionObserver)

/*
  スクロールイベントより効率的な方法:
  IntersectionObserver(交差監視API)

  → 要素が画面内に入った・出たときに発火する
  → スクロールイベントより処理が軽い
  → フェードインアニメーションに最適
*/

const observer = new IntersectionObserver(
    ( entries ) => {
        entries.forEach( ( entry ) => {
            if ( entry.isIntersecting ) {
                // 要素が画面内に入ったとき
                entry.target.classList.add( 'is-visible' );
            }
        } );
    },
    {
        threshold: 0.2, // 要素の20%が見えたら発火
    }
);

// 監視したい要素すべてに適用する
const fadeElements = document.querySelectorAll( '.fade-in' );
fadeElements.forEach( ( el ) => observer.observe( el ) );

/*
  CSSのフェードインアニメーション:
  .fade-in {
      opacity: 0;
      transform: translateY( 20px );
      transition: opacity 0.6s ease, transform 0.6s ease;
  }

  .fade-in.is-visible {
      opacity: 1;
      transform: translateY( 0 );
  }
*/

6. フォームイベントの実践

6.1 リアルタイムバリデーション

const emailInput   = document.querySelector( '#email' );
const emailError   = document.querySelector( '#email-error' );

// inputイベント:入力のたびに発火
emailInput.addEventListener( 'input', () => {
    const value = emailInput.value;
    const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

    if ( value === '' ) {
        showError( emailError, '' ); // エラーをクリア
    } else if ( !emailPattern.test( value ) ) {
        showError( emailError, '正しいメールアドレスを入力してください' );
    } else {
        showError( emailError, '' ); // エラーをクリア
        emailInput.classList.add( 'is-valid' );
    }
} );

// blurイベント:フォーカスが外れたとき
emailInput.addEventListener( 'blur', () => {
    if ( emailInput.value === '' ) {
        showError( emailError, 'メールアドレスを入力してください' );
    }
} );

function showError( errorEl, message ) {
    errorEl.textContent = message;
    errorEl.style.display = message ? 'block' : 'none';
}

6.2 フォーム送信のバリデーション

const form = document.querySelector( '#contact-form' );

form.addEventListener( 'submit', ( e ) => {
    e.preventDefault();

    // 各フィールドの値を取得する
    const name    = document.querySelector( '#name' ).value.trim();
    const email   = document.querySelector( '#email' ).value.trim();
    const message = document.querySelector( '#message' ).value.trim();

    // バリデーションエラーをリセットする
    clearErrors();

    // バリデーションを実行する
    let hasError = false;

    if ( name === '' ) {
        showError( 'name-error', 'お名前を入力してください' );
        hasError = true;
    }

    if ( email === '' ) {
        showError( 'email-error', 'メールアドレスを入力してください' );
        hasError = true;
    } else if ( !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( email ) ) {
        showError( 'email-error', '正しいメールアドレスを入力してください' );
        hasError = true;
    }

    if ( message === '' ) {
        showError( 'message-error', 'お問い合わせ内容を入力してください' );
        hasError = true;
    }

    // エラーがあれば送信を止める
    if ( hasError ) return;

    // バリデーション通過:送信処理
    submitForm( { name, email, message } );
} );

function showError( id, message ) {
    const el = document.querySelector( `#${ id }` );
    if ( el ) el.textContent = message;
}

function clearErrors() {
    document.querySelectorAll( '.error-message' ).forEach( ( el ) => {
        el.textContent = '';
    } );
}

async function submitForm( data ) {
    // APIへの送信処理(fetchを使う)
    try {
        const response = await fetch( '/api/contact', {
            method:  'POST',
            headers: { 'Content-Type': 'application/json' },
            body:    JSON.stringify( data ),
        } );

        if ( response.ok ) {
            form.reset();
            alert( 'お問い合わせを受け付けました' );
        } else {
            alert( '送信に失敗しました。もう一度お試しください。' );
        }
    } catch ( error ) {
        console.error( error );
        alert( '通信エラーが発生しました' );
    }
}

6.3 セレクトボックスの変更イベント

const prefSelect = document.querySelector( '#prefecture' );
const citySelect = document.querySelector( '#city' );

// 都道府県が変わったら市区町村を更新する
prefSelect.addEventListener( 'change', () => {
    const selectedPref = prefSelect.value;

    // 市区町村の選択肢をリセットする
    citySelect.innerHTML = '<option value="">市区町村を選択</option>';

    if ( selectedPref === '' ) return;

    // 選択された都道府県に対応する市区町村を追加する
    const cities = getCitiesByPref( selectedPref );

    cities.forEach( ( city ) => {
        const option = document.createElement( 'option' );
        option.value       = city.code;
        option.textContent = city.name;
        citySelect.appendChild( option );
    } );
} );

7. キーボードイベントの実践

7.1 特定のキーで処理を実行する

// 検索フィールドでEnterキーを押したら検索実行
const searchInput = document.querySelector( '#search' );

searchInput.addEventListener( 'keydown', ( e ) => {
    if ( e.key === 'Enter' ) {
        e.preventDefault(); // フォーム送信を防ぐ
        const query = searchInput.value.trim();
        if ( query ) {
            executeSearch( query );
        }
    }
} );

/*
  よく使うキー名:
  'Enter'     → Enterキー
  'Escape'    → Escキー
  'Tab'       → Tabキー
  'ArrowUp'   → 上矢印
  'ArrowDown' → 下矢印
  'ArrowLeft' → 左矢印
  'ArrowRight'→ 右矢印
  ' '         → スペースキー

  修飾キーの組み合わせ:
  e.ctrlKey  → Ctrlキーが押されているか
  e.shiftKey → Shiftキーが押されているか
  e.metaKey  → Command(Mac)/Windowsキーが押されているか
*/

// Ctrl+S でカスタム保存処理
document.addEventListener( 'keydown', ( e ) => {
    if ( ( e.ctrlKey || e.metaKey ) && e.key === 's' ) {
        e.preventDefault(); // ブラウザの保存ダイアログを止める
        saveContent(); // カスタムの保存処理
    }
} );

8. イベント委譲(Event Delegation)

8.1 イベント委譲とは

/*
  イベント委譲:
  個々の子要素ではなく
  親要素にイベントリスナーを設定する手法。

  メリット:
  → 動的に追加された要素にも効く
  → リスナーの数を減らせる(メモリ効率がよい)
*/

// ❌ 非効率:各アイテムにリスナーを付ける
const items = document.querySelectorAll( '.list-item' );
items.forEach( ( item ) => {
    item.addEventListener( 'click', handleItemClick );
} );
// → 100個のアイテムがあれば100個のリスナーが生成される
// → 動的に追加されたアイテムには効かない

// ✅ 効率的:親要素にリスナーを付ける(イベント委譲)
const list = document.querySelector( '.list' );

list.addEventListener( 'click', ( e ) => {
    // クリックされた要素が .list-item かどうかを確認する
    const item = e.target.closest( '.list-item' );

    if ( !item ) return; // list-item でなければ無視

    handleItemClick( item );
} );

function handleItemClick( item ) {
    console.log( 'アイテムがクリックされました', item.dataset.id );
}

8.2 動的に追加された要素へのイベント適用

/*
  イベント委譲が特に有効な場面:
  JavaScriptで動的に追加した要素にイベントを付けたいとき
*/

const todoList = document.querySelector( '#todo-list' );
const addBtn   = document.querySelector( '#add-todo' );
const input    = document.querySelector( '#todo-input' );

// 新しいTodoを追加する
addBtn.addEventListener( 'click', () => {
    const text = input.value.trim();
    if ( !text ) return;

    const li = document.createElement( 'li' );
    li.className     = 'todo-item';
    li.dataset.id    = Date.now();
    li.innerHTML = `
        <span>${ text }</span>
        <button class="todo-delete">削除</button>
    `;
    todoList.appendChild( li );
    input.value = '';
} );

// 削除ボタンへのイベント委譲
todoList.addEventListener( 'click', ( e ) => {
    // 削除ボタンがクリックされた場合
    if ( e.target.classList.contains( 'todo-delete' ) ) {
        const item = e.target.closest( '.todo-item' );
        item.remove();
    }
} );

9. removeEventListener:リスナーを削除する

/*
  使わなくなったリスナーは削除することで
  メモリリークを防げる。

  削除するには:
  → addEventListener と同じ関数の参照が必要
  → アロー関数を変数に入れて使う
*/

function handleScroll() {
    // スクロール処理
}

// リスナーを追加する
window.addEventListener( 'scroll', handleScroll );

// 不要になったらリスナーを削除する
window.removeEventListener( 'scroll', handleScroll );

// ❌ これは削除できない(別の関数として扱われる)
window.addEventListener( 'scroll', () => { ... } );
window.removeEventListener( 'scroll', () => { ... } ); // 効かない

// ✅ 変数に入れてから渡す
const handleResize = () => {
    // リサイズ処理
};

window.addEventListener( 'resize', handleResize );

// 後から削除できる
window.removeEventListener( 'resize', handleResize );

10. 実践チェックリスト

✓ addEventListener でイベントリスナーを正しく設定できたか

✓ フォームの送信イベントで e.preventDefault() を使ってリロードを防げたか

✓ イベントオブジェクトの e.target でクリックされた要素を取得できたか

✓ ハンバーガーメニューで classList.toggle() を使って開閉を実装できたか

✓ スクロールイベントで requestAnimationFrame を使ってパフォーマンスを改善したか

✓ IntersectionObserver でスクロールアニメーションを実装できたか

✓ リスト系UIで親要素へのイベント委譲を使って動的追加された要素にも対応したか

✓ 不要になったリスナーを removeEventListener で削除する意識があるか


まとめ

今回はJavaScriptのイベントハンドリングの基本と実践パターンを解説しました。ポイントをまとめると:

  • イベントリスナーは addEventListener( イベント名, 関数 ) で設定する・onclick プロパティではなく addEventListener を使う
  • イベントリスナーの関数には自動的にイベントオブジェクト e が渡される・e.target でクリックされた要素・e.preventDefault() でデフォルトの動作をキャンセルできる
  • フォームの送信は submit イベントで受け取り・e.preventDefault() でリロードを防いでからバリデーションを実行する
  • スクロールイベントは高頻度で発火するため requestAnimationFrame でパフォーマンスを改善する
  • 要素の表示を検知するには scroll イベントより IntersectionObserver が効率的
  • 動的に追加される要素には個別にリスナーを付けず・親要素へのイベント委譲を使う
  • removeEventListener でリスナーを削除するには・同じ関数の参照を渡す必要があるためアロー関数は変数に入れてから使う

次の記事では、jQueryの使い方を解説します。お楽しみに!

コメント

タイトルとURLをコピーしました