Q1saveButton.addEventListener("click", save()); で、save が呼ばれるのは?
イベント — 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
実際のページでは click() を書かなくても、利用者がボタンを押した時点でブラウザが addToCart を呼びます。呼ばれるのは登録した後に起きたクリックだけで、登録の行より前に押された分は点数に数えられません。
() を付けると登録の行で 1 回だけ動く
addEventListener("click", addToCart()) と書くと、登録の行で addToCart が 1 回呼ばれ、戻り値の undefined が渡されます。エラーは出ないため、押しても点数が増えない原因に気づきにくくなります。登録するときは、() を付けずに関数の名前だけを渡します。
子のクリックを親で受ける — バブリングと委譲
注文一覧の行を押したときに、その行の注文番号を読みたいとします。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
event.target.dataset.idは A-102
event.target.dataset.idは A-102
- 兄弟の li にはクリックが伝わらない
event.targetはこの li- ここから外側へ伝わり始める
親に 1 つだけ登録し、event.target で押された子を見分ける書き方をイベント委譲と呼びます。li のクリックは ul まで伝わってから受け取られるので、登録した後に append で加えた li を押しても、同じリスナーが呼ばれます。event.target は押された一番内側の要素なので、li の中に span があれば span を指します。
親へ伝わるのを止める — 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(); // 注文を削除
削除のリスナーでは、stopPropagation() の後に書いた「注文を削除」も表示されています。止まるのは外側の要素への伝わり方で、呼んだリスナーの残りの行ではありません。
止めたクリックは委譲の ul にも届かない
stopPropagation() は、すぐ外側の行だけでなく、その外側にあるすべての要素への伝わり方を止めます。一覧の ul に委譲で操作の記録を取るリスナーを登録していても、削除ボタンのクリックは記録されません。止めるのは、外側のどの要素でも受け取らなくてよいクリックだけにします。
リスナーを外す — 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(); // 注文を送信
() => … は書くたびに新しい関数が作られるので、中身が同じでも、登録した関数と === で比べると false です。後で外すリスナーは、sendOrder のように変数に入れてから登録します。下の表は、この記事で扱ったイベントの書き方をまとめたものです。
| 書き方 | 何をするか | 気をつけること |
|---|---|---|
| addEventListener("click", 関数) | クリックが起きるたびに関数を呼ぶ | 関数に () を付けずに渡す |
| event.target | クリックが起きた元の要素を表す | 親で受け取っても、押された一番内側の要素を指す |
| 親の要素に 1 つ登録する(委譲) | 子のクリックをまとめて受け取る | 後から加えた子のクリックも受け取れる |
| event.stopPropagation() | 外側の要素へ伝わるのを止める | 委譲した親にも届かなくなる |
| { once: true } | 1 回呼ばれた後にリスナーを外す | addEventListener の 3 つ目の引数に渡す |
| removeEventListener("click", 関数) | 登録したリスナーを外す | 登録したときと同じ関数を渡す |
理解度チェック
まずは1問ずつ答えてみましょう。
Q2ul に登録したリスナーの中で、ul の中の li を click() したときの event.target は?
Q3() => send() を登録後、removeEventListener("click", () => send()) を呼ぶと?