順番に読み進めながら学べます

イベント — addEventListener と伝播

クリックを受け取る addEventListener と event.target、親へ伝わるバブリングを使ったイベント委譲、stopPropagation、once と removeEventListener によるリスナーの外し方を扱います。

textContent への代入は、その行を実行したときに 1 回だけ要素を書き換えます。ボタンが押されたら件数を増やす処理は、いつ押されるかがコードからは分からないため、上から順に実行する書き方だけでは作れません。

この記事では、押されたときに関数を呼ぶ addEventListener と、子の要素のクリックを親の要素で受け取る書き方を扱います。

クリックされたら関数を呼ぶ — addEventListener

商品ページの「カートに入れる」ボタンが押されるたびに、カートの点数を 1 増やして表示したいとします。点数を書き換える行をそのまま並べると、ページを開いてコードが実行された時点で 1 回書き換わるだけで、ボタンを押しても点数は変わりません。

イベント(クリックや入力など、ページで起きた出来事)が起きたときに呼ぶ関数は、addEventListener("click", 関数) のようにイベント名と関数を渡して登録し、登録した関数をイベントリスナー(以下リスナー)と呼びます。コンソールではマウスで押せないため、cartButton.click() のように click メソッドでクリックを起こします。

document.body.innerHTML = `<button id="add-to-cart">カートに入れる</button><p>カート <span id="cart-count">0</span> 点</p>`;
const cartButton = document.querySelector("#add-to-cart");
const cartCount = document.querySelector("#cart-count");

// 押されたときに呼ぶ関数(ここでは定義するだけ)
function addToCart() {
  cartCount.textContent = Number(cartCount.textContent) + 1;
}

// 関数そのものを渡して登録する(この行では addToCart は呼ばれない)
cartButton.addEventListener("click", addToCart);
console.log(cartCount.textContent);   // 0

// クリックを起こすたびに、登録した addToCart が呼ばれる
cartButton.click();
cartButton.click();
console.log(cartCount.textContent);   // 2
addToCart の中身が動く行
addEventListener("click", addToCart)cartButton.click()1 回目cartButton.click()2 回目登録するだけで点数は 0 のままaddToCart が呼ばれ点数が 1 になるもう一度呼ばれ点数が 2 になる
登録の行では addToCart は呼ばれず、点数は 0 のままです。中身が動くのは、クリックが起きた行です

実際のページでは click() を書かなくても、利用者がボタンを押した時点でブラウザが addToCart を呼びます。呼ばれるのは登録した後に起きたクリックだけで、登録の行より前に押された分は点数に数えられません。

() を付けると登録の行で 1 回だけ動く

addEventListener("click", addToCart()) と書くと、登録の行で addToCart が 1 回呼ばれ、戻り値の undefined が渡されます。エラーは出ないため、押しても点数が増えない原因に気づきにくくなります。登録するときは、() を付けずに関数の名前だけを渡します。

著者ページで、フォローのボタンを押すたびに表示を切り替えます。ボタン followButton は宣言済みです。

① 押されるたびに active の class を切り替え、文字を「フォローする」と「フォロー中」で入れ替える関数を登録してください。

② クリックを 1 回起こし、タグも含めてボタンの HTML を表示してください。

③ もう一度クリックを起こし、ボタンの HTML を表示してください。

(正しく実行できれば解説が表示されます)

JavaScript / TypeScript エディタ

コードを実行してください

子のクリックを親で受ける — バブリングと委譲

注文一覧の行を押したときに、その行の注文番号を読みたいとします。querySelectorAll で集めた行に forEach で 1 件ずつ登録しても、登録されるのはその時点でページにある行だけなので、後から append で加えた行を押しても何も起きません。

バブリング(要素で起きたイベントが、親の要素、そのまた親へと順に伝わる仕組み)があるので、親の ul に登録したリスナーは li のクリックでも呼ばれます。ブラウザはリスナーにイベントオブジェクト(起きたイベントの情報を持つオブジェクト)を引数で渡し、下のコードでは event と名付けた引数の target で、クリックが起きた元の要素を読みます。

document.body.innerHTML = `<section id="order-panel"><ul id="order-list"><li data-id="A-101">注文 A-101</li><li data-id="A-102">注文 A-102</li></ul></section>`;
const orderList = document.querySelector("#order-list");
const orderPanel = document.querySelector("#order-panel");

// li ではなく、親の ul と、その外側の section に登録する
orderList.addEventListener("click", (event) => {
  console.log(`ul で受け取った: ${event.target.dataset.id}`);
});
orderPanel.addEventListener("click", (event) => {
  console.log(`section で受け取った: ${event.target.dataset.id}`);
});

// A-102 の li でクリックを起こす
document.querySelector('[data-id="A-102"]').click();
// ul で受け取った: A-102
// section で受け取った: A-102
A-102 のクリックが伝わる経路
section 要素 — 2 番目にリスナーが呼ばれる
  • event.target.dataset.id は A-102
ul 要素 — 1 番目にリスナーが呼ばれる
  • event.target.dataset.id は A-102
A-101 の li — 押していない
  • 兄弟の li にはクリックが伝わらない
A-102 の li — click() を呼んだ要素
  • event.target はこの li
  • ここから外側へ伝わり始める
li から ul、section の順に伝わり、兄弟の li は通りません。event.target は、どこでも A-102 の li です

親に 1 つだけ登録し、event.target で押された子を見分ける書き方をイベント委譲と呼びます。li のクリックは ul まで伝わってから受け取られるので、登録した後に append で加えた li を押しても、同じリスナーが呼ばれます。event.target は押された一番内側の要素なので、li の中に span があれば span を指します。

会議室の予約画面で、時間帯の一覧から押された枠を受け取ります。時間帯の一覧の ul 要素 slotList は宣言済みです。

① 一覧にリスナーを 1 つだけ登録し、押された枠の時刻を表示して selected の class を足してください。

② 10:00 の枠のクリックを起こしてください。

③ 16:00 の枠を作って一覧の末尾に加え、そのクリックを起こしてください。

④ ul 要素のタグも含めて、一覧の HTML を表示してください。

JavaScript / TypeScript エディタ

コードを実行してください

親へ伝わるのを止める — stopPropagation

注文の行を押すと詳細を開き、行の中の「削除」ボタンでは注文を削除したいとします。ボタンは行の子の要素なので、「削除」を押したクリックはバブリングで行にも伝わり、削除と一緒に、その注文の詳細まで開いてしまいます。

イベントが外側の要素へ伝わることを伝播と呼び、stopPropagation(伝播を、リスナーが呼ばれている要素で止めるメソッド)はボタンのリスナーの中で event.stopPropagation() と呼びます。止めると、行や一覧など外側の要素に登録したリスナーは呼ばれません。

document.body.innerHTML = `<ul><li id="order-row">注文 A-201 <button id="favorite">お気に入り</button><button id="delete">削除</button></li></ul>`;
const orderRow = document.querySelector("#order-row");
const favoriteButton = document.querySelector("#favorite");
const deleteButton = document.querySelector("#delete");

// 行を押したら詳細を開く
orderRow.addEventListener("click", () => console.log("詳細を開く"));

// お気に入り: 詳細も開いてよいので、止めずに行へ伝える
favoriteButton.addEventListener("click", () => console.log("お気に入りに追加"));
favoriteButton.click();   // お気に入りに追加、詳細を開く(2 行)

// 削除: stopPropagation() を呼んで、行へ伝わるのを止める
deleteButton.addEventListener("click", (event) => {
  event.stopPropagation();
  console.log("注文を削除");
});
deleteButton.click();     // 注文を削除
止めないクリックと止めたクリック
favoriteButton.click()お気に入りに追加と表示する止めないので行の li へ伝わる詳細を開く も表示されるdeleteButton.click()stopPropagation()の後に削除を表示行の li へは伝わらない詳細を開く は表示されない
お気に入りは詳細も開いてよいので止めず、行へ伝わります。stopPropagation() を呼ぶと、行のリスナーは呼ばれません

削除のリスナーでは、stopPropagation() の後に書いた「注文を削除」も表示されています。止まるのは外側の要素への伝わり方で、呼んだリスナーの残りの行ではありません。

止めたクリックは委譲の ul にも届かない

stopPropagation() は、すぐ外側の行だけでなく、その外側にあるすべての要素への伝わり方を止めます。一覧の ul に委譲で操作の記録を取るリスナーを登録していても、削除ボタンのクリックは記録されません。止めるのは、外側のどの要素でも受け取らなくてよいクリックだけにします。

社内チャットの通知一覧で、行を押すと通知を開き、行の中のミュートのボタンではミュートだけを行います。リスナーを登録した通知の行 noticeRow は宣言済みです。

① ミュートのボタンにリスナーを登録し、「ミュートにしました」と表示して、行へクリックが伝わらないようにしてください。

② ミュートのボタンのクリックを起こしてください。

③ li 要素のタグも含めて、行の HTML を表示してください。

④ 行そのもののクリックを起こしてください。

JavaScript / TypeScript エディタ

コードを実行してください

リスナーを外す — once と removeEventListener

「注文を確定する」ボタンを利用者が続けて 2 回押すと、登録したリスナーも 2 回呼ばれ、同じ注文が 2 回送られます。1 回目の送信を受け付けた後は、同じボタンを押されてもリスナーが呼ばれないようにする必要があります。

addEventListener の 3 つ目の引数に { once: true } を渡すと、リスナーは 1 回呼ばれた後に自動で外れます。好きな時点で外すときは、removeEventListener(イベント名と関数を受け取り、登録済みのリスナーから === で等しい関数を探して外すメソッド)を呼びます。

document.body.innerHTML = `<button id="confirm">注文を確定する</button>`;
const confirmButton = document.querySelector("#confirm");
const sendOrder = () => console.log("注文を送信");

// { once: true } — 1 回呼ばれた後に、自動で外れる
confirmButton.addEventListener("click", sendOrder, { once: true });
confirmButton.click();   // 注文を送信
confirmButton.click();   // (何も表示されない)

// 登録した sendOrder を渡すと、そのリスナーが外れる
confirmButton.addEventListener("click", sendOrder);
confirmButton.removeEventListener("click", sendOrder);
confirmButton.click();   // (何も表示されない)

// 中身が同じでも、新しく書いたアロー関数は別の関数なので外れない
confirmButton.addEventListener("click", () => console.log("注文を送信"));
confirmButton.removeEventListener("click", () => console.log("注文を送信"));
confirmButton.click();   // 注文を送信
外した後のクリックで呼ばれるか
confirmButton の外し方を 3 通り試す{ once: true }を付けて登録removeEventListener("click", sendOrder)removeEventListener("click", () => …)1 回呼ばれた後に自動で外れる登録した関数と同じなので外れる=== が false で外れない次のクリックでは呼ばれない次のクリックでは呼ばれない次のクリックでも注文を送信と表示
once と、登録と同じ関数を渡したときは外れます。新しく書いたアロー関数を渡しても、リスナーは外れません

() => … は書くたびに新しい関数が作られるので、中身が同じでも、登録した関数と === で比べると false です。後で外すリスナーは、sendOrder のように変数に入れてから登録します。下の表は、この記事で扱ったイベントの書き方をまとめたものです。

書き方何をするか気をつけること
addEventListener("click", 関数)クリックが起きるたびに関数を呼ぶ関数に () を付けずに渡す
event.targetクリックが起きた元の要素を表す親で受け取っても、押された一番内側の要素を指す
親の要素に 1 つ登録する(委譲)子のクリックをまとめて受け取る後から加えた子のクリックも受け取れる
event.stopPropagation()外側の要素へ伝わるのを止める委譲した親にも届かなくなる
{ once: true }1 回呼ばれた後にリスナーを外すaddEventListener の 3 つ目の引数に渡す
removeEventListener("click", 関数)登録したリスナーを外す登録したときと同じ関数を渡す

ウェビナーの申込画面です。registerButtonwaitButtonattendeeCountwaitingCount は宣言済みです。

① 参加登録に、1 回だけ登録者を 1 人増やすリスナーを登録してください。

② 参加登録のクリックを 2 回起こし、人数を表示してください。

③ キャンセル待ちを 1 人増やす関数を登録し、クリックを起こしてください。

④ ③ の関数を外して再びクリックを起こし、人数を表示してください。

JavaScript / TypeScript エディタ

コードを実行してください
QUIZ

理解度チェック

まずは1問ずつ答えてみましょう。

Q1saveButton.addEventListener("click", save()); で、save が呼ばれるのは?

Q2ul に登録したリスナーの中で、ul の中の li を click() したときの event.target は?

Q3() => send() を登録後、removeEventListener("click", () => send()) を呼ぶと?