モーダルを閉じたらヘッダーのバッジも更新したい、カートに商品を足したら合計金額の表示も変えたい。こうした「ある処理をきっかけに、別の場所も反応させたい」場面で、呼び出し先の関数を直接呼ぶと、部品どうしが強く結びついてしまいます。CustomEvent と dispatchEvent() を使えば、click や submit と同じ仕組みで自分で決めた名前のイベントを発火でき、受け取る側は addEventListener() で待つだけで済みます。この記事では独自イベントの作り方、データの渡し方、そして引っかかりやすい bubbles の既定値について解説します。
目次
独自イベントを使うと何がうれしいか
たとえば「カートに追加」ボタンの処理を考えます。素直に書けば、ボタンのクリックハンドラーの中で合計金額の再計算とヘッダーのバッジ更新を呼び出すことになります。
button.addEventListener( 'click', () => {
addToCart( item );
updateCartTotal(); // カート側の関数を知っている必要がある
updateHeaderBadge(); // ヘッダー側の関数も知っている必要がある
showToast(); // 増えるたびにここへ追記していく
} );
動きはしますが、ボタンの処理がページ中のあらゆる場所の事情を知っている状態になります。反応させたい箇所が増えるたびにここへ行を足すことになり、逆にヘッダーを別ページで使い回そうとすると、この関数を呼んでくれる誰かを毎回用意しなければなりません。
独自イベントに置き換えると、ボタン側の関心は「カートに追加されたという事実を知らせる」ところで止まります。誰がそれを聞いているかを知る必要はありません。
button.addEventListener( 'click', () => {
addToCart( item );
// 「追加された」ことだけを知らせる
button.dispatchEvent( new CustomEvent( 'cart:add', {
detail: item,
bubbles: true,
} ) );
} );
CustomEvent の作り方
イベントは new CustomEvent() で作ります。第1引数がイベント名、第2引数がオプションです。
const event = new CustomEvent( 'cart:add', {
detail: { name: 'コーヒー', price: 480 },
bubbles: true,
cancelable: true,
} );
| オプション | 既定値 | 内容 |
|---|---|---|
detail | null | 受け取り側に渡す任意のデータ。何を入れてもよい |
bubbles | false | true なら親要素へ伝播する |
cancelable | false | true なら preventDefault() で取り消せる |
composed | false | true ならシャドウ DOM の境界を越えて伝播する |
データを渡す必要がないなら、CustomEvent ではなく new Event( 'cart:clear', { bubbles: true } ) でも構いません。違いは detail を持てるかどうかだけなので、迷ったら CustomEvent を使っておけば問題ありません。
イベント名の付け方
イベント名に決まりはありませんが、標準のイベント名(click、change など)と衝突しない名前にしてください。慣例として、cart:add のようにコロンで区切る、あるいは cart-add のようにハイフンでつなぐ形がよく使われます。すべて小文字にしておくのも大切です。cartAdd のような大文字混じりでも動きますが、HTML 属性経由での指定など小文字化される場面があるため、避けたほうが無難です。
dispatchEvent() で発火する
作ったイベントは、どの要素で起きたことにするかを決めて dispatchEvent() に渡します。この要素が event.target になります。
// 発火する側
button.dispatchEvent( event );
// document や window でも発火できる(ページ全体のお知らせ向き)
document.dispatchEvent( new CustomEvent( 'app:ready' ) );
受け取る側は、標準のイベントとまったく同じ書き方です。detail に入れたデータは event.detail で取り出せます。
document.addEventListener( 'cart:add', ( event ) => {
const { name, price } = event.detail;
console.log( `${ name } が ${ price } 円で追加されました` );
} );
実際に動かしてみましょう。ボタン側は cart:add を発火するだけ、カート側はそれを聞いて表示を更新するだけ、という構成です。JavaScript タブの中で、両者が互いの関数を一切呼んでいないことに注目してください。
<div class="shop">
<button class="add" data-name="コーヒー" data-price="480">コーヒーを追加</button>
<button class="add" data-name="サンドイッチ" data-price="620">サンドイッチを追加</button>
</div>
<div class="cart">
<p>カート:<span class="cart__count">0</span> 点/合計 <span class="cart__total">0</span> 円</p>
<p class="cart__last">まだ追加されていません</p>
</div>
.shop {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.add {
padding: 10px 18px;
border: none;
border-radius: 6px;
background: #2f6fed;
color: #fff;
font-size: 15px;
cursor: pointer;
}
.cart {
padding: 14px 18px;
border: 1px solid #ddd;
border-radius: 8px;
font-family: sans-serif;
}
.cart p {
margin: 0;
}
.cart__last {
margin-top: 6px !important;
font-size: 13px;
color: #666;
}
const shop = document.querySelector('.shop');
// ボタンが押されたら、カスタムイベントを発火するだけ
shop.addEventListener('click', (event) => {
const button = event.target.closest('.add');
if (!button) return;
const addEvent = new CustomEvent('cart:add', {
// 受け取り側に渡したいデータは detail に入れる
detail: {
name: button.dataset.name,
price: Number(button.dataset.price),
},
// 親要素までイベントを伝える
bubbles: true,
});
button.dispatchEvent(addEvent);
});
// カート側は「cart:add が起きたら更新する」ことだけ知っていればよい
let count = 0;
let total = 0;
document.addEventListener('cart:add', (event) => {
const { name, price } = event.detail;
count += 1;
total += price;
document.querySelector('.cart__count').textContent = count;
document.querySelector('.cart__total').textContent = total.toLocaleString();
document.querySelector('.cart__last').textContent = `最後に追加:${name}(${price} 円)`;
});
カート側のコードは document で待ち受けています。ボタン自体を querySelector で探していないので、あとから商品ボタンを増やしても、Ajax で商品一覧を差し替えても、カートのコードを書き換える必要がありません。
bubbles: false が既定という落とし穴
独自イベントでいちばんつまずくのがここです。click のような標準イベントは親要素へ伝播するのが当たり前ですが、CustomEvent の bubbles は既定で false です。
// bubbles を指定していないので、親には伝わらない
button.dispatchEvent( new CustomEvent( 'cart:add', { detail: item } ) );
// button 自身で聞いていれば反応する
button.addEventListener( 'cart:add', handler ); // ← 動く
// document で聞いていても届かない
document.addEventListener( 'cart:add', handler ); // ← 動かない
「発火しているのにハンドラーが呼ばれない」というときは、まず bubbles: true を書いたか確認してください。発火した要素そのもので受け取るなら不要ですが、document や親要素でまとめて受けたい場合には必須です。
逆に、ページ全体に知らせたいなら最初から document や window で発火するという手もあります。この場合はもう上に伝える先が無いので bubbles は不要です。「どの要素で起きたか」に意味があるかどうかで選んでください。商品ボタンのように発生源が重要なら要素で発火して伝播させ、「アプリの初期化が終わった」のような全体の出来事なら document で発火するのが自然です。
cancelable で処理を取り消せるようにする
cancelable: true を付けると、受け取り側が preventDefault() を呼んで「その処理をやめてほしい」と伝えられます。dispatchEvent() は、取り消されたときに false を返します。
function closeModal( modal ) {
const event = new CustomEvent( 'modal:beforeclose', {
bubbles: true,
cancelable: true,
} );
// 取り消されたら false が返る
const allowed = modal.dispatchEvent( event );
if ( ! allowed ) {
return;
}
modal.hidden = true;
}
// 入力途中なら閉じさせない
form.addEventListener( 'modal:beforeclose', ( event ) => {
if ( form.querySelector( 'input' ).value !== '' ) {
event.preventDefault();
}
} );
beforeunload や submit と同じ考え方で、「これから処理する」ことを事前に知らせ、拒否権を渡す形です。cancelable: false のイベントで preventDefault() を呼んでも何も起きず、dispatchEvent() は常に true を返します。
知っておきたい挙動
ハンドラーは同期的に実行される
dispatchEvent() は、登録されているハンドラーをその場ですべて実行してから戻ります。setTimeout() のように後回しにはなりません。
document.addEventListener( 'my:event', () => console.log( '2. ハンドラー' ) );
console.log( '1. 発火前' );
document.dispatchEvent( new CustomEvent( 'my:event' ) );
console.log( '3. 発火後' );
// 1 → 2 → 3 の順に出力される
ハンドラーの中で重い処理をすると、その間 dispatchEvent() の呼び出し元も止まります。また、ハンドラーが async 関数でも dispatchEvent() は完了を待ちません。非同期の結果を受け取りたい場合は、detail に Promise を入れて渡すなどの工夫が必要です。
リスナーは発火より先に登録しておく
イベントは「そのとき聞いている人」にしか届きません。DOMContentLoaded の後に登録するコードと、それより前に発火するコードが混ざっていると、何も起きずに終わります。スクリプトの読み込み順や defer の有無を確認してください。あとから登録した人にも届けたい場合は、イベントではなく状態を保持する仕組みが必要になります。
detail のデータはコピーされない
detail に渡したオブジェクトは、そのままの参照が受け取り側に届きます。ハンドラーの中で書き換えれば、発火した側の変数も変わります。意図しない書き換えを防ぎたいなら、必要な値だけを新しいオブジェクトに詰め直して渡すのが安全です。
シャドウ DOM の外には届かない
Web Components の中からイベントを発火して外側で受け取りたい場合は、bubbles: true に加えて composed: true が必要です。これが無いと、シャドウルートの境界でイベントが止まります。カスタム要素を作るときはセットで指定してください。
まとめ
CustomEvent と dispatchEvent() を使うと、click などの標準イベントとまったく同じ仕組みで独自のイベントを流せます。発火する側は「何が起きたか」を知らせるだけ、受け取る側は addEventListener() で待つだけ。互いに相手の関数を知らなくてよいので、部品ごとに独立して書けるようになります。
渡したいデータは detail に入れ、受け取り側は event.detail で取り出します。そして最大の注意点は bubbles が既定で false であることです。document でまとめて受けるつもりなら bubbles: true を忘れないでください。処理を止められるようにしたいなら cancelable: true を付け、dispatchEvent() の戻り値で判定します。
まずは、関数を直接呼び合っている箇所を1つ選んで、独自イベントに置き換えてみてください。呼び出し側から相手の名前が消えるだけで、あとから機能を足すときの見通しがずいぶん良くなります。