This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.

Môi trường trình duyệt web vận hành dựa trên các sự kiện (Event-driven). Khi người dùng cuộn chuột, nhấp nút, hay nhập văn bản, trình duyệt sẽ kích hoạt các sự kiện tương ứng trong cây DOM. Hãy cùng tìm hiểu cơ chế quản lý sự kiện nâng cao, cách tối ưu hiệu năng bằng Debounce/Throttle thông qua lăng kính Event Loop, và cách thiết lập Reactive Programming (Lập trình phản ứng) tự xây dựng một State Store đồng bộ trực tiếp lên giao diện.

1. Cơ chế lan truyền sự kiện DOM & Event Delegation

Mỗi khi một sự kiện xảy ra trên một phần tử DOM, nó không chỉ kích hoạt đơn lẻ trên phần tử đó mà lan truyền theo 3 giai đoạn tiêu chuẩn qua các nút cha/con:

             [1] Capturing Phase (Đi xuống)
                  |    Window -> Document -> Body -> ...
                  v
             [2] Target Phase (Phần tử mục tiêu)
                  |    Kích hoạt trực tiếp tại button/input mục tiêu
                  v
             [3] Bubbling Phase (Nổi bọt đi lên)
                       ... -> Body -> Document -> Window
  • Capturing Phase (Giai đoạn đánh bẫy): Sự kiện đi từ gốc cây DOM (Window, Document, HTML) xuống qua các nút cha đến khi chạm nút mục tiêu kích hoạt (Target). Theo mặc định, các sự kiện không lắng nghe ở pha này trừ khi bạn truyền tham số true hoặc { capture: true } vào addEventListener.
  • Target Phase (Giai đoạn mục tiêu): Sự kiện được kích hoạt trực tiếp tại phần tử đích nơi hành động diễn ra (ví dụ: thẻ <button> được click).
  • Bubbling Phase (Giai đoạn nổi bọt): Sự kiện dội ngược từ phần tử đích đi lên qua các nút cha về lại gốc. Mặc định các sự kiện đăng ký thông thường đều lắng nghe ở giai đoạn nổi bọt này.
  • Kiểm soát lan truyền: event.stopPropagation() ngăn không cho sự kiện nổi bọt tiếp lên các nút cha. Trong khi đó, event.stopImmediatePropagation() không những chặn nổi bọt mà còn chặn các listener khác cùng gán trên chính phần tử đó thực thi.

Event Delegation (Ủy quyền sự kiện): Thay vì gán hàng trăm trình lắng nghe sự kiện lên từng thẻ <li> hay nút bấm con trong một danh sách lớn (gây tốn bộ nhớ vì V8 phải cấp phát hàng trăm closures lưu trữ ngữ cảnh), chúng ta chỉ cần gán duy nhất 1 trình lắng nghe sự kiện lên thẻ cha (Container). Nhờ cơ chế Bubbling, sự kiện click ở nút con dội lên cha, cha chỉ cần dùng event.target.closest('.item-class') để xác định chính xác nút con nào được click và thực thi logic.

Các Lựa Chọn Nâng Cao: addEventListener Options

Phương thức addEventListener hỗ trợ tham số thứ ba dạng object cấu hình:

  • capture (boolean): Nếu true, đăng ký listener chạy trong giai đoạn Capturing thay vì mặc định Bubbling.
  • once (boolean): Nếu true, listener tự động bị xóa bỏ (removeEventListener) ngay sau lần kích hoạt đầu tiên. Rất hữu ích cho các tác vụ khởi tạo một lần.
  • passive (boolean): Nếu true, hứa với trình duyệt rằng listener sẽ không bao giờ gọi event.preventDefault(). Điều này giúp tối ưu hóa hiệu năng cuộn chuột cực kỳ mạnh mẽ trên thiết bị di động (đạt 60fps mượt mà), vì trình duyệt không cần chờ JS thực thi xong để xác định có ngăn cuộn hay không.
  • signal (AbortSignal): Nhận vào một AbortSignal từ AbortController. Cho phép hủy hàng loạt trình lắng nghe sự kiện chỉ bằng một cú gọi controller.abort(), loại bỏ nguy cơ rò rỉ bộ nhớ.
addeventlistener_options.js
// 1. Tối ưu passive scroll listener
window.addEventListener('touchmove', (e) => {
  // Trình duyệt di động không cần block scroll chờ JS chạy xong
}, { passive: true });

// 2. Tự hủy listener bằng AbortController
const controller = new AbortController();

window.addEventListener('resize', () => {
  console.log("Cửa sổ thay đổi kích thước!");
}, { signal: controller.signal });

// Hủy tất cả listeners dùng chung signal này
controller.abort();

2. Tối ưu hiệu năng: Debounce vs Throttle dưới lăng kính Timers

Các sự kiện như scroll, resize, hoặc keyup kích hoạt liên tục với tần suất rất cao (lên tới 60-120 lần/giây). Nếu chạy các tác vụ nặng (gọi API tìm kiếm, tính toán vị trí DOM) ở mỗi lần kích hoạt sẽ làm nghẽn Call Stack và gây sụt giảm khung hình nghiêm trọng. Giải pháp:

  • Debounce (Gom nhóm sự kiện): Trì hoãn thực thi hàm cho tới khi hành động ngừng kích hoạt trong một khoảng thời gian cụ thể.
    • Cơ chế V8: Khi sự kiện kích hoạt, ta hủy bỏ (clearTimeout) tác vụ cũ trong Macrotask Queue và tạo mới một Timer. Chỉ khi người dùng dừng hành động đủ lâu, callback mới thực sự được Event Loop đẩy vào Call Stack để chạy.
    • Ứng dụng: Ô tìm kiếm Auto-complete (chỉ gọi API khi người dùng dừng gõ 300ms).
  • Throttle (Giới hạn tần suất): Đảm bảo hàm chỉ thực thi tối đa một lần trong một khoảng thời gian cố định dù sự kiện kích hoạt liên tục.
    • Cơ chế V8: Lưu mốc thời gian chạy gần nhất (lastRun). Khi sự kiện kích hoạt, so sánh thời gian hiện tại Date.now() - lastRun. Nếu lớn hơn chu kỳ định sẵn, thực thi hàm lập tức và cập nhật lastRun; ngược lại, bỏ qua hoặc hẹn giờ chạy bù.
    • Ứng dụng: Tối ưu hóa sự kiện cuộn trang (scroll) hoặc kéo thả (drag and drop).

3. Tự thiết kế một Reactive State Store gắn liền với DOM

Trong kiến trúc Frontend hiện đại, Lập trình phản ứng (Reactive Programming) giúp tách biệt hoàn toàn Logic dữ liệu và Giao diện hiển thị. Chúng ta có thể kết hợp Observer PatternProxy API để tự tạo ra một State Store tự động cập nhật DOM:

  • Khi một thuộc tính của State được gán giá trị mới, trap set() của Proxy phát hiện sự thay đổi.
  • Proxy tự động kích hoạt (notify) toàn bộ danh sách các hàm đăng ký render (Subscribers).
  • Các subscribers thực hiện thay đổi giá trị thuộc tính DOM tương ứng bất đồng bộ (DOM Mutation).
reactive_store.js
// Vue 3 Reactivity Mock: Dùng WeakMap để lưu trữ các proxy của đối tượng (tránh tạo lại Proxy nhiều lần)
const reactiveRegistry = new WeakMap();

function reactive(target, callback) {
  if (typeof target !== 'object' || target === null) return target;
  
  // Trả về proxy cũ nếu đối tượng đã được bọc Proxy rồi
  if (reactiveRegistry.has(target)) return reactiveRegistry.get(target);

  const proxy = new Proxy(target, {
    get(obj, prop, receiver) {
      const res = Reflect.get(obj, prop, receiver);
      // Đệ quy bọc Proxy cho nested objects (lazy reactive)
      return typeof res === 'object' && res !== null ? reactive(res, callback) : res;
    },
    set(obj, prop, value, receiver) {
      const oldValue = obj[prop];
      if (oldValue !== value) {
        const success = Reflect.set(obj, prop, value, receiver);
        if (success) callback(prop, value, oldValue);
        return success;
      }
      return true;
    }
  });

  reactiveRegistry.set(target, proxy);
  return proxy;
}

class ReactiveStore {
  constructor(initialState) {
    this.subscribers = new Set();
    this.state = reactive(initialState, (prop, val, old) => this.notify(prop, val, old));
  }
  subscribe(cb) { this.subscribers.add(cb); return () => this.subscribers.delete(cb); }
  notify(prop, val, old) { this.subscribers.forEach(cb => cb({ prop, newValue: val, oldValue: old, state: this.state })); }
}
▶ Thử chạy: Nested Reactive Store

4. Playground: Event Delegation — Quản Lý 1000 Items Với 1 Listener

Event Delegation không chỉ tốt hơn về memory — nó còn hoạt động tự động với các element được thêm vào DOM sau (dynamic items):

▶ Thử chạy: Event Delegation vs Per-element Listeners

5. Playground: Debounce vs Throttle — Implement Từ Đầu

Hiểu rõ bên trong của Debounce và Throttle giúp bạn chọn đúng pattern và tune parameters:

▶ Thử chạy: Debounce & Throttle Implementation

6. MutationObserver & CustomEvent — API DOM Nâng Cao

MutationObserver cho phép quan sát mọi thay đổi DOM (thêm node, xóa node, thay đổi attribute) mà không cần polling. CustomEvent cho phép tạo sự kiện tùy chỉnh với payload data riêng:

MutationObserver & CustomEvent
// 1. MutationObserver: quan sát thay đổi DOM
const observer = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach(node => {
        console.log('Node added:', node.nodeName);
      });
    }
    if (mutation.type === 'attributes') {
      console.log(`Attribute changed: ${mutation.attributeName}`);
    }
  });
});

// Cấu hình: theo dõi gì
observer.observe(document.body, {
  childList: true,     // theo dõi thêm/xóa con trực tiếp
  subtree: true,       // theo dõi cả cây con
  attributes: true,    // theo dõi thay đổi attributes
  attributeFilter: ['class', 'data-state']  // chỉ theo dõi các attr này
});

// Cleanup: quan trọng để tránh memory leak!
observer.disconnect();

// 2. CustomEvent: tạo sự kiện tùy chỉnh với data payload
const cartUpdated = new CustomEvent('cart:updated', {
  bubbles: true,        // bubble lên cha để delegation hoạt động
  cancelable: true,
  detail: {             // payload data tùy chỉnh
    items: 3,
    total: 150000,
    currency: 'VND'
  }
});

// Dispatch event từ bất kỳ element nào
document.querySelector('#cart-icon').dispatchEvent(cartUpdated);

// Lắng nghe ở container (delegation pattern)
document.addEventListener('cart:updated', (e) => {
  const { items, total } = e.detail;
  console.log(`Cart: ${items} items, ${total.toLocaleString()} VND`);
  // Cập nhật UI không cần prop drilling!
});

7. Câu hỏi trắc nghiệm ôn tập

Trắc nghiệm 1: stopPropagation vs stopImmediatePropagation

Sự khác biệt chính giữa event.stopPropagation()event.stopImmediatePropagation() là gì?

Trắc nghiệm 2: Debounce vs Throttle

Bạn muốn gọi API search khi user gõ, nhưng chỉ sau khi họ dừng gõ 500ms. Pattern nào phù hợp?

Tải file code thực hành minh họa bài học

File đầy đủ: Event Delegation, Debounce/Throttle implementations, MutationObserver, CustomEvent, ReactiveStore (Proxy-based):

Tải về reactive_store.js

Related Articles

Bài viết liên quan trong series

Lesson 12: Event Loop Interactive Visualizer & Simulator Bài 12: Trình mô phỏng Event Loop trực quan hóa tương tác Lesson 10: JS Modules: Scope Isolation, ESM vs CommonJS Bài 10: Hệ thống Module — Scope Isolation, ES Modules vs CommonJS Back to JavaScript Series Overview Quay lại Lộ trình JavaScript Series