Quá nhiều Event Listener làm chậm web - Tối ưu với Event Delegation

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 - website bắt đầu lag, đặc biệt trên mobile. 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 đề hiệu năng

#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
  • Khi xóa sản phẩm, phải nhớ remove listener (tránh memory leak)
  • Performance giảm đáng kể với danh sách lớn

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

  • Hiệu năng: 1 listener thay vì 100 listeners
  • Phần tử động: Thêm/xóa sản phẩm không cần gán lại listener
  • Bộ nhớ: Tiết kiệm RAM, không lo memory leak
  • Code gọn: Dễ bảo trì, chỉ cần sửa 1 chỗ

#1.3. So sánh hiệu năng: 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:#ffcccc
    style F fill:#ffcccc
    style E fill:#ccffcc
    style G fill:#ccffcc

Trong thực tế, với danh sách 1000 phần tử trên mobile, Event Delegation giúp giảm thời gian khởi tạo từ 500ms xuống còn 50ms.

#2. Tại sao Event Delegation hoạt động? Event Bubbling

#2.1. Event Bubbling là gì?

Event Bubbling (sự kiện nổi bọt) là cơ chế JavaScript tự động lan truyền sự kiện từ phần tử con lên các phần tử cha. Đây chính là lý do Event Delegation hoạt động.

Khi bạn click vào một button bên trong <ul>, sự kiện không chỉ xảy ra ở button, mà còn “nổi lên” qua tất cả các phần tử cha: <li><ul><body><html>document.

#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

Sự kiện bắt đầu từ button, sau đó “nổi lên” div#parent, rồi tiếp tục lên body. Đây chính 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

Khi bạn đặt listener ở <ul>, mọi sự kiện click từ các button con đều “nổi lên” và bị bắt tại đây. Nhờ event.target, bạn biết chính xác button nào được click.

#3. Ba giai đoạn xử lý sự kiện: Capturing, Target, Bubbling

JavaScript xử lý sự kiện qua 3 giai đoạn. Hiểu rõ điều này giúp bạn kiểm soát sự kiện tốt hơn.

#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:#e3f2fd
    style Phase2 fill:#fff9c4
    style Phase3 fill:#f3e5f5

#3.2. Giai đoạn 1: Capturing (Bắt sự kiện)

  • Sự kiện bắt đầu từ document và “đi xuống” đến phần tử mục tiêu
  • Ít được sử dụng trong thực tế
  • Để lắng nghe ở giai đoạn này, dùng addEventListener(event, handler, true)
1
2
3
4
5
6
7
8
9
10
// Capturing phase (tham số thứ 3 = true)
parent.addEventListener('click', () => {
  console.log('Parent capturing');
}, true);

child.addEventListener('click', () => {
  console.log('Child capturing');
}, true);

// Khi click vào child, thứ tự: "Parent capturing" → "Child capturing"

#3.3. Giai đoạn 2: Target

  • Sự kiện xảy ra trực tiếp tại phần tử được tương tác
  • event.target chính là phần tử này

#3.4. Giai đoạn 3: Bubbling (Nổi bọt)

  • Sự kiện “nổi lên” từ phần tử mục tiêu quay về document
  • Đây là giai đoạn mặc định khi dùng addEventListener
  • Event Delegation hoạt động nhờ giai đoạn này

Bảng so sánh Capturing vs Bubbling:

Thuộc tínhCapturingBubbling
Hướng lan truyềnDocument → Child (xuống)Child → Document (lên)
Cú phápaddEventListener(event, handler, true)addEventListener(event, handler) hoặc false
Khi nào dùngCần can thiệp sớm, ngăn chặn sự kiện từ gốcXử lý sự kiện sau khi biết chính xác phần tử được click (phổ biến)
Event DelegationKhông phù hợp✅ Phù hợp

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

Khi sử dụng Event Delegation, đôi khi bạn click vào phần tử con bên trong button (như <span> hay <icon>). Dùng closest() để đảm bảo luôn lấy đúng button:

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)

Đôi khi bạn không muốn sự kiện tiếp tục lan tỏa sang các phần tử cha hay con khác. Khi đó, hãy sử dụng event.stopPropagation() để dừng sự kiện ngay tại chỗ.

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ả:

  • Khi bạn bấm vào stopBtn, chỉ có "Button clicked, stopping propagation..." được in ra, container sẽ không in "Container clicked!".

Giải thích sâu hơn về “dừng lan tỏa đến phần tử cha hoặc con”

  • Trong giai đoạn Capturing (true), sự kiện di chuyển từ document xuống các phần tử con (cha → con). Nếu bạn gọi stopPropagation() trong lúc sự kiện còn “đi xuống”, các phần tử con nằm sâu hơn trong cây DOM sẽ không nhận được sự kiện nữa.
  • Trong giai đoạn Bubbling (false), sự kiện di chuyển từ phần tử mục tiêu quay ngược lên cha (con → cha). Nếu bạn gọi stopPropagation() lúc sự kiện đang “nổi lên”, các phần tử cha bên trên sẽ không nhận được sự kiện nữa.

Vì vậy, “con” ở đây chỉ các phần tử DOM chưa được lắng nghe sự kiện ở giai đoạn Capturing (nếu sự kiện bị dừng trước khi đến chúng). “Cha” là các phần tử bọc ngoài, nằm trên đường “nổi lên” sự kiện ở giai đoạn Bubbling.

#4.3. Chặn hành vi mặc định (preventDefault)

Ngoài việc dừng sự lan tỏa của sự kiện, đôi khi bạn cũng cần ngăn chặn hành vi mặc định của trình duyệt. Phương thức event.preventDefault() sẽ chặn hành vi mặc định, chẳng hạn như:

  • Nhấp vào đường dẫn (link) sẽ điều hướng sang trang khác.
  • Nhấn nút Submit trong Form sẽ tải lại trang.

Ví dụ bên dưới minh họa việc ngăn cản điều hướng khi nhấp vào một 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ả:

  • Khi bạn nhấp vào Click Me, console in ra “Link was clicked, but navigation is prevented.” và trình duyệt không chuyển đến trang Google.

Phân biệt stopPropagation()preventDefault():

  • stopPropagation(): Dừng việc sự kiện lan tỏa đến các phần tử cha hoặc con chưa đến lượt nhận sự kiện (tùy theo giai đoạn capturing hay bubbling).
  • preventDefault(): Ngăn chặn hành vi mặc định của sự kiện (như chuyển trang, submit form), nhưng không dừng lan tỏa sự kiện.

Bạn có thể sử dụng cả hai trong cùng một hàm xử lý sự kiện nếu muốn vừa chặn hành vi mặc định, vừa dừng sự kiện lan tỏa.

#4.4. Khi nào KHÔNG nên dùng Event Delegation

Event Delegation không phải lúc nào cũng là lựa chọn tốt nhất:

#Không dùng khi:

  • Ít phần tử: Chỉ có 2-3 button, gán trực tiếp đơn giản hơn
  • Sự kiện không bubble: Một số sự kiện như focus, blur, scroll không bubble (dùng focusin, focusout thay thế nếu cần delegation)
  • Logic phức tạp: Mỗi phần tử có xử lý hoàn toàn khác nhau

#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 phần tử thường xuyên (infinite scroll, filter, search)
  • Tối ưu hiệu năng: Mobile, danh sách lớn (>50 items)

Vậy là bạn đã hiểu cách giải quyết vấn đề quá nhiều event listeners bằng Event Delegation. Trong thực tế, kỹ thuật này giúp giảm lag đáng kể, đặc biệt trên mobile và các danh sách động. Hãy áp dụng closest() để xử lý chính xác và nhớ kiểm tra xem sự kiện có bubble hay không. Thử refactor danh sách sản phẩm trong dự án của bạn nhé!