Bạn có một danh sách 100 sản phẩm, mỗi sản phẩm có nút “Thêm vào giỏ hàng”. Nếu gán event listener cho từng nút, bạn vừa tạo ra 100 listeners - tốn thêm bộ nhớ và code quản lý ngày càng rối. Khi load thêm sản phẩm (infinite scroll), lại phải gán thêm listeners mới.
JavaScript có Event Delegation - kỹ thuật giúp bạn chỉ cần 1 listener duy nhất cho cả 100 (hay 1000) phần tử. Bài viết này hướng dẫn cách áp dụng và giải thích tại sao nó hoạt động nhờ Event Bubbling.
1. Event Delegation - Giải pháp cho vấn đề quá nhiều listener
1.1. Vấn đề với cách truyền thống
Hãy xem cách nhiều developer thường làm khi xử lý nhiều phần tử:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<body>
<ul id="productList">
<li><button class="add-to-cart" data-id="1">Sản phẩm 1</button></li>
<li><button class="add-to-cart" data-id="2">Sản phẩm 2</button></li>
<li><button class="add-to-cart" data-id="3">Sản phẩm 3</button></li>
<!-- ... 97 items nữa -->
</ul>
<script>
// ❌ Cách truyền thống: Gán listener cho TỪNG nút
const buttons = document.querySelectorAll(".add-to-cart");
buttons.forEach((button) => {
button.addEventListener("click", (e) => {
const productId = e.target.dataset.id;
console.log("Added product:", productId);
});
});
// Tạo ra 100 listeners! Mỗi lần load thêm sản phẩm lại phải gán lại
</script>
</body>
</html>Vấn đề:
- 100 buttons = 100 event listeners trong bộ nhớ
- Khi thêm sản phẩm mới (infinite scroll), phải gán listener lại
- Phải tự quản lý việc gán/gỡ listener cho từng phần tử — dễ sót, dễ trùng lặp
- Danh sách càng lớn, chi phí bộ nhớ và việc quản lý listener càng tăng
1.2. Giải pháp: Event Delegation
Thay vì gán listener cho từng button, chỉ cần gán 1 listener duy nhất ở phần tử cha:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html>
<body>
<ul id="productList">
<li><button class="add-to-cart" data-id="1">Sản phẩm 1</button></li>
<li><button class="add-to-cart" data-id="2">Sản phẩm 2</button></li>
<li><button class="add-to-cart" data-id="3">Sản phẩm 3</button></li>
<!-- ... 97 items nữa -->
</ul>
<script>
// ✅ Event Delegation: Chỉ 1 listener duy nhất!
const productList = document.getElementById("productList");
productList.addEventListener("click", (e) => {
// Kiểm tra xem phần tử được click có phải button không
if (e.target.classList.contains("add-to-cart")) {
const productId = e.target.dataset.id;
console.log("Added product:", productId);
// Xử lý thêm vào giỏ hàng...
}
});
// Chỉ 1 listener cho 100 buttons!
// Thêm sản phẩm mới không cần gán listener
</script>
</body>
</html>Giải thích:
- Gán listener ở
ul#productList(phần tử cha), không phải từng button - Khi click vào button, sự kiện “nổi lên” (bubble) đến
ul - Dùng
e.targetđể xác định chính xác button nào được click - Kiểm tra
classList.contains('add-to-cart')để đảm bảo đúng phần tử
Ưu điểm:
- Phần tử động: Thêm/xóa sản phẩm không cần gán lại listener
- Bộ nhớ: 1 listener thay vì 100 — ít listener và closure hơn, tiết kiệm RAM với danh sách lớn
- Code gọn: Dễ bảo trì, chỉ cần sửa 1 chỗ
1.3. So sánh Traditional vs Delegation
View Mermaid diagram code
flowchart LR
A[100 Buttons] --> B{Cách truyền thống}
A --> C{Event Delegation}
B --> D[100 Listeners<br/>trong bộ nhớ]
C --> E[1 Listener<br/>duy nhất]
D --> F[Thêm sản phẩm:<br/>Gán listener lại]
E --> G[Thêm sản phẩm:<br/>Không cần làm gì]
style D fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
style F fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
style E fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style G fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50Trong thực tế, gán 1000 listeners trong trình duyệt hiện đại cũng chỉ mất vài mili giây — lợi ích thật sự của Event Delegation không phải là “hết lag”. Cái bạn nhận được là: ít bộ nhớ hơn (1 listener thay vì N), chỉ gán một lần thay vì gán lại mỗi lần infinite scroll load thêm, và code đơn giản hơn hẳn với danh sách động.
2. Event Bubbling - Cơ chế đằng sau Event Delegation
Một listener ở <ul> bắt được click của mọi button con là nhờ Event Bubbling.
2.1. Event Bubbling là gì?
Event Bubbling là cơ chế lan truyền sự kiện của DOM: sau khi kích hoạt ở event.target, sự kiện truyền tuần tự lên từng phần tử tổ tiên tới document, chạy handler ở mỗi cấp có đăng ký listener.
Với một click vào button trong danh sách, đường truyền là: <button> → <li> → <ul> → <body> → <html> → document. Listener đặt ở <ul> nằm trên đường này nên vẫn nhận được sự kiện.
2.2. Ví dụ minh họa Event Bubbling
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html>
<body>
<div id="parent" style="padding: 20px; border: 2px solid blue;">
Parent DIV
<button id="child" style="margin-left: 20px;">Child Button</button>
</div>
<script>
const parent = document.getElementById("parent");
const child = document.getElementById("child");
parent.addEventListener("click", () => {
console.log("2. Parent clicked");
});
child.addEventListener("click", () => {
console.log("1. Child clicked");
});
document.body.addEventListener("click", () => {
console.log("3. Body clicked");
});
</script>
</body>
</html>Kết quả khi click vào button:
1
2
3
1. Child clicked
2. Parent clicked
3. Body clickedClick vào button kích hoạt cả ba listener, theo thứ tự từ trong ra ngoài: button → div#parent → body. Sự kiện nổi dần lên trên — đó là Event Bubbling.
2.3. Cách hoạt động trong Event Delegation
View Mermaid diagram code
sequenceDiagram
participant User
participant Button
participant Li
participant Ul
participant Body
User->>Button: Click button
Note over Button: Event bắt đầu
Button->>Li: Event bubble lên
Li->>Ul: Event bubble lên
Note over Ul: ✅ Listener ở đây<br/>bắt được event!
Ul->>Body: Event tiếp tục bubble
Note over Body: Event kết thúcListener ở <ul> bắt được mọi click nổi lên từ button con. Mấu chốt: event.target là phần tử được click (button), còn event.currentTarget mới là phần tử gắn listener (ul) — dùng event.target để xác định chính xác button nào vừa nhấn.
3. Ba giai đoạn xử lý sự kiện: Capturing, Target, Bubbling
Bubbling ở phần 2 mới là nửa sau câu chuyện. Thực ra mỗi sự kiện DOM đi qua 3 giai đoạn theo thứ tự: capturing (đi xuống), target (tại phần tử được click), rồi bubbling (nổi lên). Nắm được điều này, bạn chủ động chọn thời điểm handler chạy và biết cách delegate cả những sự kiện không bubble.
3.1. Tổng quan ba giai đoạn
View Mermaid diagram code
flowchart TD
Start([User click button]) --> Phase1[1. CAPTURING PHASE<br/>Document → Parent → Child]
Phase1 --> Phase2[2. TARGET PHASE<br/>Sự kiện xảy ra tại button]
Phase2 --> Phase3[3. BUBBLING PHASE<br/>Child → Parent → Document]
Phase3 --> End([Event hoàn tất])
style Phase1 fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style Phase2 fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style Phase3 fill:#241832,stroke:#c08cff,stroke-width:1.5px,color:#d4b3ff3.2. Giai đoạn 1: Capturing (đi xuống)
- Sự kiện truyền từ
documentxuống phần tử mục tiêu — ngược hướng với bubbling - Mặc định
addEventListenerkhông nghe ở giai đoạn này; phải bật bằng tham số thứ 3:addEventListener(event, handler, true) - Ít dùng, nhưng hữu ích khi cần chặn sự kiện sớm từ gốc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html>
<body>
<div id="parent" style="padding: 20px; border: 2px solid blue;">
Parent DIV
<button id="child" style="margin-left: 20px;">Child Button</button>
</div>
<script>
const parent = document.getElementById("parent");
const child = document.getElementById("child");
// Tham số thứ 3 = true → nghe ở giai đoạn capturing
parent.addEventListener("click", () => console.log("1. Parent (capturing)"), true);
child.addEventListener("click", () => console.log("2. Child (target)"), true);
</script>
</body>
</html>Kết quả khi click vào button:
1
2
1. Parent (capturing)
2. Child (target)Sự kiện đi xuống nên parent nhận trước, rồi mới tới child — chính là phần tử mục tiêu. So sánh với ví dụ bubbling ở mục 2.2 (child → parent), thứ tự bị đảo ngược hoàn toàn.
3.3. Giai đoạn 2: Target
- Sự kiện chạm đúng phần tử được tương tác —
event.targettrỏ tới nó - Tại đây phase không còn ý nghĩa: listener capturing hay bubbling trên chính phần tử này đều chạy theo thứ tự đăng ký
3.4. Giai đoạn 3: Bubbling (nổi lên)
- Sự kiện nổi từ phần tử mục tiêu ngược lên
document - Là giai đoạn mặc định của
addEventListener(tham số thứ 3 làfalse) - Event Delegation dựa vào giai đoạn này
Bảng so sánh Capturing vs Bubbling:
| Thuộc tính | Capturing | Bubbling |
|---|---|---|
| Hướng lan truyền | Document → Target (đi xuống) | Target → Document (nổi lên) |
| Cú pháp | addEventListener(event, handler, true) | addEventListener(event, handler) (mặc định) |
| Khi nào dùng | Cần chặn sự kiện sớm, từ gốc | Xử lý sau khi đã biết phần tử được click |
| Event Delegation | Cần cho sự kiện không bubble (focus) | ✅ Lựa chọn mặc định |
4. Các kỹ thuật tối ưu và lưu ý
4.1. Kiểm tra phần tử chính xác với closest()
Với Event Delegation, event.target là phần tử được click trực tiếp — thường là <span> hay icon bên trong button chứ không phải button. Dùng closest() để leo lên đúng button gần nhất:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<ul id="productList">
<li>
<button class="add-to-cart" data-id="1">
<span class="icon">🛒</span>
<span class="text">Thêm vào giỏ</span>
</button>
</li>
</ul>
<script>
const productList = document.getElementById("productList");
productList.addEventListener("click", (e) => {
// ❌ Sai: Click vào icon sẽ không hoạt động
// if (e.target.classList.contains('add-to-cart')) { ... }
// ✅ Đúng: Tìm button cha gần nhất
const button = e.target.closest(".add-to-cart");
if (button) {
const productId = button.dataset.id;
console.log("Added product:", productId);
}
});
</script>Giải thích:
e.targettrả về phần tử được click trực tiếp (có thể là<span>)closest('.add-to-cart')tìm phần tử cha gần nhất có class này- Nếu không tìm thấy, trả về
null(an toàn)
4.2. Dừng sự kiện sớm (stopPropagation)
event.stopPropagation() chặn sự kiện tiếp tục lan truyền qua các phần tử còn lại trên đường đi — hữu ích khi bạn không muốn listener ở phần tử cha cũng bị kích hoạt.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<body>
<div id="container" style="padding: 20px; border: 2px solid gray;">
Container
<button id="stopBtn">Click to Stop</button>
</div>
<script>
const container = document.getElementById("container");
const stopBtn = document.getElementById("stopBtn");
container.addEventListener("click", () => {
console.log("Container clicked!");
});
stopBtn.addEventListener("click", (event) => {
console.log("Button clicked, stopping propagation...");
event.stopPropagation();
});
</script>
</body>
</html>Kết quả
Click vào stopBtn chỉ in "Button clicked, stopping propagation..."; listener của container không chạy vì sự kiện đã bị chặn trước khi nổi lên tới nó.
stopPropagation() chặn theo hướng sự kiện đang đi: ở giai đoạn bubbling, nó cắt đường lên các phần tử cha; ở giai đoạn capturing, nó cắt đường xuống các phần tử con chưa nhận sự kiện. Lưu ý listener khác trên chính phần tử hiện tại vẫn chạy — muốn chặn luôn cả chúng thì dùng stopImmediatePropagation().
Ví dụ dưới đây gắn ba listener lên cùng một nút. Listener thứ hai gọi stopImmediatePropagation(), nên listener thứ ba trên chính nút đó sẽ không chạy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!DOCTYPE html>
<html>
<body>
<div id="container" style="padding: 20px; border: 2px solid gray;">
Container
<button id="saveBtn">Save</button>
</div>
<script>
const container = document.getElementById("container");
const saveBtn = document.getElementById("saveBtn");
container.addEventListener("click", () => {
console.log("Container clicked!");
});
saveBtn.addEventListener("click", () => {
console.log("Listener 1: validate form");
});
saveBtn.addEventListener("click", (event) => {
console.log("Listener 2: stop everything else");
event.stopImmediatePropagation();
});
saveBtn.addEventListener("click", () => {
console.log("Listener 3: never runs");
});
</script>
</body>
</html>Kết quả
Click vào saveBtn chỉ in:
1
2
Listener 1: validate form
Listener 2: stop everything elseListener 3 bị bỏ qua vì nằm sau lời gọi stopImmediatePropagation(), và listener của container cũng không chạy vì sự kiện đã ngừng lan truyền. So sánh nhanh: stopPropagation() vẫn cho các listener còn lại trên cùng phần tử chạy, còn stopImmediatePropagation() cắt luôn cả chúng — thứ tự đăng ký listener vì thế rất quan trọng.
4.3. Chặn hành vi mặc định (preventDefault)
event.preventDefault() hủy hành vi mặc định mà trình duyệt gắn sẵn cho sự kiện — ví dụ link <a> điều hướng sang trang khác, hay nút submit làm reload form. Nó không liên quan tới việc lan truyền sự kiện.
Ví dụ dưới đây chặn điều hướng khi click vào thẻ <a>:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<body>
<a href="https://www.google.com" id="blockLink">Click Me</a>
<script>
const blockLink = document.getElementById("blockLink");
blockLink.addEventListener("click", (event) => {
event.preventDefault();
console.log("Link was clicked, but navigation is prevented.");
});
</script>
</body>
</html>Kết quả
Click vào Click Me in ra "Link was clicked, but navigation is prevented." và trình duyệt không chuyển sang trang Google.
Phân biệt hai phương thức — chúng giải quyết hai vấn đề khác nhau và có thể dùng chung trong một handler:
stopPropagation(): chặn sự kiện lan truyền tiếp sang phần tử khác (cha/con), nhưng hành vi mặc định vẫn xảy ra.preventDefault(): hủy hành vi mặc định (chuyển trang, submit…), nhưng sự kiện vẫn tiếp tục lan truyền bình thường.
4.4. Khi nào KHÔNG nên dùng Event Delegation
Delegation không phải lúc nào cũng đáng dùng.
Nên dùng khi
- Nhiều phần tử giống nhau: danh sách sản phẩm, menu, tabs
- Phần tử động: thêm/xóa liên tục (infinite scroll, filter, search)
- Danh sách lớn: trên 50 item, tiết kiệm bộ nhớ rõ rệt, nhất là trên mobile
Không cần thiết khi
- Ít phần tử: chỉ 2-3 button — gán trực tiếp đơn giản hơn
- Sự kiện không bubble:
focus,blur,scroll… không nổi lên; nếu cần delegate thì dùngfocusin/focusouthoặc capturing - Mỗi phần tử một logic riêng: gộp vào một handler chỉ khiến code rối hơn
Vậy là bạn đã có cách xử lý gọn gàng cho vấn đề quá nhiều event listener. Điểm cần nhớ: đặt 1 listener ở phần tử cha, dùng event.target + closest() để xác định đúng phần tử, và kiểm tra sự kiện có bubble hay không trước khi delegate. Thử refactor một danh sách động trong dự án của bạn để cảm nhận sự khác biệt nhé!