Học bằng cách đọc theo thứ tự

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
Những dòng làm addToCart chạy
addEventListener("click", addToCart)cartButton.click()lần 1cartButton.click()lần 2Chỉ đăng ký,con số vẫn là 0addToCart chạy,con số thành 1Được gọi lần nữa,con số thành 2
Dòng đăng ký không gọi addToCart, nên con số vẫn là 0. Thân hàm chạy ở những dòng tạo ra click.

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 ().

Trên trang tác giả, bạn đổi nội dung nút theo dõi mỗi lần nút được nhấn. Nút followButton đã được khai báo sẵn.

① Hãy đăng ký một hàm mà mỗi lần nút được nhấn sẽ bật/tắt class active và đổi chữ qua lại giữa "Theo dõi" và "Đang theo dõi".

② Hãy tạo một click, rồi in ra HTML của nút, gồm cả thẻ của nó.

③ Hãy tạo thêm một click, rồi in ra HTML của nút.

(Nếu chạy đúng, phần giải thích sẽ xuất hiện.)

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

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
Đường đi của click trên A-102
Phần tử section — listener được gọi thứ hai
  • event.target.dataset.id là A-102
Phần tử ul — listener được gọi đầu tiên
  • event.target.dataset.id là A-102
li của A-101 — không được nhấn
  • Click không đi sang các li anh em
li của A-102 — nơi gọi click()
  • event.target là li này
  • Click bắt đầu đi ra ngoài từ đây
Click đi từ li lên ul, rồi đến section, và không đi qua li anh em. Ở đâu thì event.target cũng là li của A-102.

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.

Trên màn hình đặt phòng họp, bạn nhận khung giờ được nhấn trong danh sách. Phần tử ul của danh sách khung giờ slotList đã được khai báo sẵn.

① Hãy đăng ký đúng một listener cho danh sách, in ra giờ của khung được nhấn và thêm class selected cho khung đó.

② Hãy tạo click trên khung 10:00.

③ Hãy tạo khung 16:00, thêm vào cuối danh sách, rồi tạo click trên khung đó.

④ Hãy in ra HTML của danh sách, gồm cả thẻ ul.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

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
Click không bị chặn và click bị chặn
favoriteButton.click()In ra "Đã thêmvào yêu thích"Không bị chặn,đi lên li của dòng"Mở chi tiết"cũng được in radeleteButton.click()stopPropagation(),rồi báo đã xóa đơnKhông lên tớili của dòng"Mở chi tiết"không được in ra
Nút yêu thích không chặn click vì mở chi tiết cũng không sao, nên click lên tới dòng. Khi gọi stopPropagation(), listener của dòng không được gọi.

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ý.

Trong danh sách thông báo của app chat nội bộ, nhấn vào một dòng sẽ mở thông báo, còn nút tắt tiếng trong dòng thì chỉ tắt tiếng. Dòng thông báo noticeRow đã được khai báo sẵn và đã có listener.

① Hãy đăng ký listener cho nút tắt tiếng, in ra "Đã tắt tiếng" và không để click lan lên dòng.

② Hãy tạo click trên nút tắt tiếng.

③ Hãy in ra HTML của dòng, gồm cả thẻ li.

④ Hãy tạo click trên chính dòng đó.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

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
Click tiếp theo sau khi gỡ có gọi listener không?
confirmButton:3 cách gỡĐăng ký kèm{ once: true }removeEventListener("click", sendOrder)removeEventListener("click", () => …)Tự động bị gỡsau 1 lần gọiCùng một hàmnên bị gỡ=== là falsenên không bị gỡClick sau khônggọi listenerClick sau khônggọi listenerClick sau vẫn in"Đã gửi đơn hàng"
Dùng once, hoặc truyền đúng hàm đã đăng ký, thì listener bị gỡ. Truyền một arrow function viết mới thì listener không bị gỡ.

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ếtTác dụngLưu ý
addEventListener("click", fn)Gọi fn sau mỗi clickTruyền fn, không kèm ()
event.targetPhần tử được clickPhần tử trong cùng, cả khi ở cha
Một listener ở cha (ủy quyền)Nhận click của mọi conNhận cả con thêm vào sau
event.stopPropagation()Chặn lan ra ngoàiCha ủy quyền cũng không nhận
{ once: true }Gỡ sau 1 lần gọiTruyền ở tham số thứ 3
removeEventListener("click", fn)Gỡ listenerTruyền đúng hàm đã đăng ký

Đây là màn hình đăng ký tham gia webinar. registerButton, waitButton, attendeeCountwaitingCount đã được khai báo sẵn.

① Hãy đăng ký cho nút "Đăng ký" một listener tăng số người đăng ký thêm 1, và chỉ chạy đúng một lần.

② Hãy tạo 2 click trên nút đăng ký, rồi in ra số người.

③ Hãy đăng ký cho nút danh sách chờ một hàm thêm 1 người vào danh sách chờ, rồi tạo một click.

④ Hãy gỡ hàm ở ③, tạo thêm một click, rồi in ra số người.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra
QUIZ

Kiểm tra kiến thức

Hãy trả lời từng câu hỏi một.

Câu 1Với saveButton.addEventListener("click", save());, khi nào save được gọi?

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?