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ốtruehoặc{ capture: true }vàoaddEventListener. -
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ếutrue, đăng ký listener chạy trong giai đoạn Capturing thay vì mặc định Bubbling. -
once(boolean): Nếutrue, 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ếutrue, hứa với trình duyệt rằng listener sẽ không bao giờ gọievent.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ộtAbortSignaltừ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ọicontroller.abort(), loại bỏ nguy cơ rò rỉ bộ nhớ.
// 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).
-
Cơ chế V8: Khi sự kiện kích hoạt, ta hủy bỏ (
-
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ạiDate.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ậtlastRun; 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).
-
Cơ chế V8: Lưu mốc thời gian chạy gần nhất (
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 Pattern và Proxy 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).
// 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 })); }
}
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):
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:
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:
// 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() và
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
Comments
Bình luận