Câu 1Với saveButton.addEventListener("click", save());, khi nào save được gọi?
Sự kiện — addEventListener và cơ chế lan truyền
Nhận click bằng addEventListener và event.target, ủy quyền sự kiện nhờ bubbling, dùng stopPropagation, once và removeEventListener.
Gán cho textContent chỉ thay đổi phần tử một lần, đúng lúc dòng đó chạy. Không thể viết code tăng số đếm mỗi khi nút được nhấn thành các dòng chạy lần lượt từ trên xuống, vì code không biết trước lúc nào người dùng sẽ nhấn.
Bài này nói về addEventListener, phương thức gọi một hàm khi phần tử được nhấn, và cách nhận click của phần tử con ngay tại phần tử cha.
Gọi hàm khi có click — addEventListener
Giả sử mỗi lần nút "Thêm vào giỏ" trên trang sản phẩm được nhấn, bạn muốn số sản phẩm trong giỏ tăng thêm 1 và hiển thị lại. Nếu chỉ viết dòng cập nhật con số như các dòng bình thường, dòng đó chỉ chạy một lần, khi trang mở và code được thực thi; sau đó dù bạn nhấn nút bao nhiêu lần, con số cũng không đổi.
Để một hàm được gọi khi sự kiện (event — điều xảy ra trên trang, như click hay nhập liệu) xảy ra, bạn đăng ký nó bằng cách truyền tên sự kiện và hàm, như addEventListener("click", hàm). Hàm đã đăng ký gọi là event listener (từ đây gọi tắt là listener). Trong console không thể nhấn bằng chuột, nên bạn tạo click bằng phương thức click, như cartButton.click().
document.body.innerHTML = `<button id="add-to-cart">Thêm vào giỏ</button><p>Giỏ hàng: <span id="cart-count">0</span> sản phẩm</p>`;
const cartButton = document.querySelector("#add-to-cart");
const cartCount = document.querySelector("#cart-count");
// Hàm được gọi khi nút được nhấn (ở đây chỉ định nghĩa)
function addToCart() {
cartCount.textContent = Number(cartCount.textContent) + 1;
}
// Truyền chính hàm để đăng ký (dòng này không gọi addToCart)
cartButton.addEventListener("click", addToCart);
console.log(cartCount.textContent); // 0
// Mỗi lần tạo click, addToCart đã đăng ký sẽ được gọi
cartButton.click();
cartButton.click();
console.log(cartCount.textContent); // 2
Trên trang thật, bạn không cần viết click(): người dùng vừa nhấn nút là trình duyệt gọi addToCart. Hàm chỉ được gọi cho những click xảy ra sau khi đăng ký; các lần nhấn trước dòng đăng ký không được tính.
Thêm () thì hàm chỉ chạy một lần, ngay ở dòng đăng ký
Nếu viết addEventListener("click", addToCart()), addToCart được gọi một lần ngay ở dòng đăng ký, và addEventListener nhận giá trị trả về undefined thay vì hàm. Vì không có lỗi nào hiện ra, bạn sẽ khó tìm ra lý do nhấn nút mà con số không tăng. Khi đăng ký, chỉ truyền tên hàm, không kèm ().
Nhận click của phần tử con tại phần tử cha — bubbling và ủy quyền sự kiện
Tiếp theo, khi một dòng trong danh sách đơn hàng được nhấn, bạn muốn đọc mã đơn của dòng đó. Nếu gom các dòng bằng querySelectorAll rồi đăng ký listener cho từng dòng bằng forEach, chỉ những dòng có trên trang lúc đó mới có listener, nên nhấn vào dòng được thêm sau bằng append sẽ không có gì xảy ra.
Nhờ bubbling (cơ chế sự kiện xảy ra ở một phần tử được truyền lần lượt lên phần tử cha, rồi lên cha của phần tử cha, cứ thế tiếp tục), listener đăng ký ở ul cha cũng được gọi khi li được click. Trình duyệt truyền cho listener một event object (object chứa thông tin về sự kiện vừa xảy ra) làm tham số. Trong code bên dưới, tham số được đặt tên là event, và target của nó cho biết phần tử nơi click xảy ra ban đầu.
document.body.innerHTML = `<section id="order-panel"><ul id="order-list"><li data-id="A-101">Đơn A-101</li><li data-id="A-102">Đơn A-102</li></ul></section>`;
const orderList = document.querySelector("#order-list");
const orderPanel = document.querySelector("#order-panel");
// Đăng ký ở ul cha và section bao bên ngoài, không đăng ký ở các li
orderList.addEventListener("click", (event) => {
console.log(`Nhận ở ul: ${event.target.dataset.id}`);
});
orderPanel.addEventListener("click", (event) => {
console.log(`Nhận ở section: ${event.target.dataset.id}`);
});
// Tạo click trên li của A-102
document.querySelector('[data-id="A-102"]').click();
// Nhận ở ul: A-102
// Nhận ở section: A-102
event.target.dataset.idlà A-102
event.target.dataset.idlà A-102
- Click không đi sang các li anh em
event.targetlà li này- Click bắt đầu đi ra ngoài từ đây
Cách chỉ đăng ký một listener ở phần tử cha rồi dùng event.target để biết phần tử con nào được nhấn gọi là event delegation (ủy quyền sự kiện). Click trên li được xử lý sau khi đã lan lên tới ul, nên kể cả li được thêm bằng append sau khi đăng ký cũng gọi đúng listener đó. event.target là phần tử nằm trong cùng được nhấn, nên nếu trong li có span thì nó trỏ tới span.
Chặn click lan lên phần tử cha — stopPropagation
Giả sử nhấn vào một dòng đơn hàng thì mở chi tiết, còn nút "Xóa" trong dòng thì xóa đơn. Nút là con của dòng, nên click trên "Xóa" cũng lan lên dòng, và chi tiết của đơn bị mở ra ngay trong lúc đơn đang bị xóa.
Việc sự kiện truyền ra các phần tử bên ngoài gọi là lan truyền (propagation). Bạn gọi stopPropagation (phương thức dừng lan truyền tại phần tử có listener đang chạy) bên trong listener của nút, như event.stopPropagation(). Khi đã dừng, các listener đăng ký ở phần tử bên ngoài, như dòng hay danh sách, sẽ không được gọi.
document.body.innerHTML = `<ul><li id="order-row">Đơn A-201 <button id="favorite">Yêu thích</button><button id="delete">Xóa</button></li></ul>`;
const orderRow = document.querySelector("#order-row");
const favoriteButton = document.querySelector("#favorite");
const deleteButton = document.querySelector("#delete");
// Nhấn vào dòng thì mở chi tiết
orderRow.addEventListener("click", () => console.log("Mở chi tiết"));
// Yêu thích: mở chi tiết cũng không sao, nên để click đi tiếp lên dòng
favoriteButton.addEventListener("click", () => console.log("Đã thêm vào yêu thích"));
favoriteButton.click(); // Đã thêm vào yêu thích, Mở chi tiết (2 dòng)
// Xóa: gọi stopPropagation() để click không lan lên dòng
deleteButton.addEventListener("click", (event) => {
event.stopPropagation();
console.log("Đã xóa đơn");
});
deleteButton.click(); // Đã xóa đơn
Trong listener của nút xóa, "Đã xóa đơn" vẫn được in ra dù nằm sau stopPropagation(). stopPropagation() chỉ dừng việc sự kiện truyền ra các phần tử bên ngoài, chứ không dừng các dòng còn lại của listener đang chạy.
Click đã bị chặn cũng không tới ul dùng ủy quyền
stopPropagation() không chỉ chặn sự kiện tới dòng bao ngay bên ngoài nút, mà chặn cả mọi phần tử nằm xa hơn. Kể cả khi ul của danh sách có listener ủy quyền để ghi lại thao tác của người dùng, click trên nút xóa cũng không được ghi. Vì vậy, chỉ nên chặn những click mà không phần tử bên ngoài nào cần xử lý.
Gỡ listener — once và removeEventListener
Nếu người dùng nhấn nút "Đặt hàng" hai lần liên tiếp, listener đã đăng ký cũng bị gọi hai lần và cùng một đơn hàng được gửi hai lần. Sau khi lần gửi đầu tiên đã được tiếp nhận, bạn cần làm cho listener không được gọi nữa khi nút đó bị nhấn lại.
Nếu truyền { once: true } làm tham số thứ ba của addEventListener, listener sẽ tự động bị gỡ sau khi được gọi một lần. Để gỡ vào thời điểm tùy ý, bạn gọi removeEventListener (phương thức nhận tên sự kiện và một hàm, tìm trong các listener đã đăng ký hàm === với hàm đó rồi gỡ nó ra).
document.body.innerHTML = `<button id="confirm">Đặt hàng</button>`;
const confirmButton = document.querySelector("#confirm");
const sendOrder = () => console.log("Đã gửi đơn hàng");
// { once: true } — tự động bị gỡ sau khi được gọi một lần
confirmButton.addEventListener("click", sendOrder, { once: true });
confirmButton.click(); // Đã gửi đơn hàng
confirmButton.click(); // (không in ra gì)
// Truyền đúng sendOrder đã đăng ký thì listener đó bị gỡ
confirmButton.addEventListener("click", sendOrder);
confirmButton.removeEventListener("click", sendOrder);
confirmButton.click(); // (không in ra gì)
// Arrow function viết mới là một hàm khác, dù thân hàm giống hệt, nên không gỡ được
confirmButton.addEventListener("click", () => console.log("Đã gửi đơn hàng"));
confirmButton.removeEventListener("click", () => console.log("Đã gửi đơn hàng"));
confirmButton.click(); // Đã gửi đơn hàng
Mỗi lần bạn viết () => … là một hàm mới được tạo ra, nên dù thân hàm giống nhau, so sánh với hàm đã đăng ký bằng === vẫn cho false. Nếu sau này cần gỡ listener, hãy gán hàm vào biến trước, như sendOrder, rồi mới đăng ký. Bảng dưới đây tóm tắt các cách viết cho sự kiện trong bài này.
| Cách viết | Tác dụng | Lưu ý |
|---|---|---|
| addEventListener("click", fn) | Gọi fn sau mỗi click | Truyền fn, không kèm () |
| event.target | Phần tử được click | Phần tử trong cùng, cả khi ở cha |
| Một listener ở cha (ủy quyền) | Nhận click của mọi con | Nhận cả con thêm vào sau |
| event.stopPropagation() | Chặn lan ra ngoài | Cha ủy quyền cũng không nhận |
| { once: true } | Gỡ sau 1 lần gọi | Truyền ở tham số thứ 3 |
| removeEventListener("click", fn) | Gỡ listener | Truyền đúng hàm đã đăng ký |
Kiểm tra kiến thức
Hãy trả lời từng câu hỏi một.
Câu 2Trong listener đăng ký ở ul, khi gọi click() trên một li bên trong ul đó, event.target là gì?
Câu 3Sau khi đăng ký () => send(), gọi removeEventListener("click", () => send()) thì sao?