Home / Fundamentals / Quá nhiều Event Listener? Tối ưu với Event Delegation
Fundamentals

Quá nhiều Event Listener? Tối ưu với Event Delegation

Tối ưu event listener với Event Delegation: 1 listener cho cả danh sách động, hiểu Event Bubbling trong JavaScript.

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:#c7ff50

Trong 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 clicked

Click vào button kích hoạt cả ba listener, theo thứ tự từ trong ra ngoài: buttondiv#parentbody. 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úc

Listener ở <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:#d4b3ff

3.2. Giai đoạn 1: Capturing (đi xuống)

  • Sự kiện truyền từ document xuống phần tử mục tiêu — ngược hướng với bubbling
  • Mặc định addEventListener khô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 (childparent), 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.target trỏ 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ínhCapturingBubbling
Hướng lan truyềnDocument → Target (đi xuống)Target → Document (nổi lên)
Cú phápaddEventListener(event, handler, true)addEventListener(event, handler) (mặc định)
Khi nào dùngCần chặn sự kiện sớm, từ gốcXử lý sau khi đã biết phần tử được click
Event DelegationCầ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.target trả 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 else

Listener 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ùng focusin/focusout hoặ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é!

← Previous
Arrow Function trong JavaScript - 3 Lưu Ý Quan Trọng Khi Chuyển Từ ES5
Table of Contents
  1. 1. Event Delegation - Giải pháp cho vấn đề quá nhiều listener
    1. 1.1. Vấn đề với cách truyền thống
    2. 1.2. Giải pháp: Event Delegation
    3. 1.3. So sánh Traditional vs Delegation
  2. 2. Event Bubbling - Cơ chế đằng sau Event Delegation
    1. 2.1. Event Bubbling là gì?
    2. 2.2. Ví dụ minh họa Event Bubbling
    3. 2.3. Cách hoạt động trong Event Delegation
  3. 3. Ba giai đoạn xử lý sự kiện: Capturing, Target, Bubbling
    1. 3.1. Tổng quan ba giai đoạn
    2. 3.2. Giai đoạn 1: Capturing (đi xuống)
    3. 3.3. Giai đoạn 2: Target
    4. 3.4. Giai đoạn 3: Bubbling (nổi lên)
  4. 4. Các kỹ thuật tối ưu và lưu ý
    1. 4.1. Kiểm tra phần tử chính xác với closest()
    2. 4.2. Dừng sự kiện sớm (stopPropagation)
    3. 4.3. Chặn hành vi mặc định (preventDefault)
    4. 4.4. Khi nào KHÔNG nên dùng Event Delegation