Event Delegation & DOM Performance — Propagation, Batching, and Layout Thrashing
Senior guide to event delegation with closest(), addEventListener options, DocumentFragment batching, read/write separation, and when virtualization wins.
Vì sao điều này quan trọng trên project thật
Một bảng 2.000 dòng, sidebar lồng nhau, hoặc feed tăng khi scroll — mỗi dòng cần click, focus bàn phím, hoặc drag handle. Gắn một listener mỗi cell và bạn trả bộ nhớ, mount chậm, và chi phí rebind mỗi lần list re-render. Append 500 node trong vòng lặp chặt và main thread dành thời gian cho layout thay vì phản hồi input.
Bài này cover event propagation và delegation cùng pattern cập nhật DOM giữ UI lớn nhanh. Với pipeline render đầy đủ (style, layout, paint, composite), xem rendering pipeline deep dive — ở đây chỉ chạm reflow khi ảnh hưởng chiến lược update. Với list virtualization quy mô lớn, xem infinite scroll & virtual scroll.
Mô hình tư duy: Event đi qua cây DOM; listener rẻ mỗi lần đăng ký nhưng đắt khi scale; ghi DOM batch tốt nhất khi tách read và write.
Demo tương tác
So sánh 500 listener trực tiếp vs một handler delegated, rồi benchmark append naive vs DocumentFragment + requestAnimationFrame.
Mở demo đầy đủ:
Event propagation: capture → target → bubble
Khi user click button trong card trong page, browser không gọi một handler rồi dừng. Nó dispatch event đi xuống (capture phase), chạm target, rồi đi lên (bubble phase).
window → document → html → body → … → parent → TARGET → … → body → window
[ capture: outer → inner ] [ bubble: inner → outer ]
Hầu hết handler chạy ở phase bubble (mặc định). Capture hữu ích khi parent phải chặn trước children — overlay modal, shortcut global, hoặc analytics cần thấy event dù child gọi stopPropagation() sớm ở bubble.
parent.addEventListener('click', onParentCapture, { capture: true });
child.addEventListener('click', onChildBubble); // default capture: false
// Order on click(child): onParentCapture → onChildBubble → (bubble listeners on ancestors)
| Phase | Direction | Default listener? |
|---|---|---|
| Capture | Root → target | No (capture: true required) |
| Target | On the element | Both capture and bubble listeners on target run in registration order |
| Bubble | Target → root | Yes (default) |
Các option addEventListener quan trọng trên production
Tham số thứ ba không chỉ useCapture — đó là object options.
const controller = new AbortController();
element.addEventListener('click', handler, {
capture: false, // bubble phase (default)
once: true, // auto-remove after first invoke
passive: true, // will never call preventDefault() — critical for scroll/touch
signal: controller.signal, // remove all listeners tied to this signal
});
// Later: tear down every listener registered with this signal
controller.abort();
once: lý tưởng cho “click outside để đóng” hoặc setup một lần không cần removeEventListener thủ công.
passive: true: báo browser handler không gọi preventDefault(). Listener scroll và touch passive cho phép compositor scroll ngay — wheel handler non-passive là nguồn jank phổ biến. Pattern timing liên quan: debounce, throttle, and rAF.
signal (AbortController): khi component React/Vue unmount hoặc route đổi, abort một signal thay vì theo dõi từng reference listener.
function mountWidget(root, signal) {
root.addEventListener('keydown', onKey, { signal });
root.addEventListener('click', onClick, { signal });
// Both removed when signal aborts — no leaked listeners
}
Pattern event delegation
Delegation: gắn một listener trên ancestor ổn định; mỗi event, quyết child nào được tương tác.
const list = document.querySelector('[data-task-list]');
list.addEventListener('click', (event) => {
const row = event.target.closest('[data-task-id]');
if (!row || !list.contains(row)) return;
const id = row.dataset.taskId;
if (event.target.closest('[data-action="delete"]')) {
deleteTask(id);
return;
}
if (event.target.closest('[data-action="toggle"]')) {
toggleTask(id);
}
});
Element.closest(selector) đi lên từ event.target (node sâu nhất bị hit) đến khi khớp selector hoặc ra khỏi cây. Dùng thay vì giả định event.target là row — user click icon, text node, hoặc SVG path trong row.
Element.matches(selector) test chính element; kết hợp với closest khi handler trên cùng element bạn quan tâm.
list.addEventListener('click', (event) => {
const btn = event.target.closest('button');
if (!btn?.matches('[data-action]')) return;
// ...
});
Vì sao delegation scale
| Approach | Listeners for N rows | New row via innerHTML / template |
|---|---|---|
| Per-row listener | N | Must rebind or use event delegation anyway |
| Delegated on parent | 1 | Works immediately — no extra registration |
Bộ nhớ: mỗi listener là closure + bookkeeping của host. Ở hàng nghìn node, DevTools thấy graph handler retained; delegation giữ phẳng.
Content động (infinite scroll, SPA re-render, HTML server push) là use case then chốt: parent tồn tại lúc bind; children có thể đến và đi.
Caveat engineer hay bỏ sót
Event không bubble: focus và blur không bubble — delegate trên parent sẽ fail. Dùng focusin / focusout (có bubble) hoặc gắn trực tiếp lên element focusable.
// ❌ focus does not bubble — parent never sees child focus
container.addEventListener('focus', onFocus);
// ✅ focusin bubbles
container.addEventListener('focusin', (e) => {
const field = e.target.closest('input, textarea, select');
if (field) highlight(field);
});
Pitfall stopPropagation(): child stop propagation làm hỏng delegated handler trên ancestor cùng phase. Chỉ dùng stopImmediatePropagation() khi thật sự sở hữu subtree; không thì dùng data attribute và return sớm trong một delegated handler.
Shadow DOM: event.target có thể retarget về host; composedPath() cho path đầy đủ gồm shadow root khi cần targeting chính xác.
Hiệu năng cập nhật DOM: giảm công việc reflow
Mỗi thay đổi DOM có thể invalidate layout. Browser batch thông minh, nhưng JavaScript đồng bộ xen kẽ read (geometry) và write (style, cấu trúc DOM) gây layout thrashing — layout sync lặp trong một frame.
// ❌ Layout thrash — read/write interleaved
for (const el of items) {
const h = el.getBoundingClientRect().height; // READ → may force layout
el.style.height = h + 10 + 'px'; // WRITE → invalidates layout
}
// ✅ Batch reads, then batch writes
const heights = items.map((el) => el.getBoundingClientRect().height);
items.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px';
});
Không cần micro-optimize mọi toggle; cần tách read và write trong vòng lặp nhiều element, scroll handler, và resize observer.
Batch insert với DocumentFragment
Append từng node vào container live có thể kích hoạt layout tăng dần. Build offline, commit một lần:
function renderRows(data) {
const fragment = document.createDocumentFragment();
for (const row of data) {
const li = document.createElement('li');
li.textContent = row.label;
li.dataset.id = row.id;
fragment.appendChild(li);
}
list.replaceChildren(fragment);
}
DocumentFragment giữ node trong memory; một appendChild(fragment) chuyển hết children vào cây — một cập nhật cấu trúc từ góc nhìn parent.
Kết hợp requestAnimationFrame khi công việc nhiều bước logic nhưng chỉ paint một lần:
function scheduleHeavyRender(items) {
requestAnimationFrame(() => {
const fragment = document.createDocumentFragment();
for (const item of items) {
fragment.appendChild(createRow(item));
}
container.replaceChildren(fragment);
});
}
Căn mutation DOM với paint kế tiếp và tránh handler input block giữa lúc dựng.
cloneNode, chuỗi HTML, và ranh giới XSS
Template + cloneNode(true): định nghĩa markup một lần trong <template>, clone mỗi instance — tốt cho card lặp không cần innerHTML trong vòng lặp.
<template id="row-tpl">
<tr><td class="name"></td><td><button type="button">Edit</button></td></tr>
</template>
const tpl = document.getElementById('row-tpl');
function createRow(label) {
const row = tpl.content.firstElementChild.cloneNode(true);
row.querySelector('.name').textContent = label;
return row;
}
Chuỗi HTML + innerHTML: nhanh cho markup tĩnh bulk, nguy hiểm với chuỗi do user kiểm soát. Luôn sanitize hoặc dùng textContent / createElement cho data không tin cậy. Ngữ cảnh bảo mật: frontend security architecture.
| Technique | Best for | Watch out |
|---|---|---|
createElement + APIs | Dynamic, untrusted text | Verbose |
DocumentFragment | Many nodes, one commit | Still build with safe APIs |
<template> + cloneNode | Repeated structure | Fill text via textContent |
innerHTML | Trusted static blobs | XSS if interpolated with user input |
Khi delegation và batching chưa đủ
Delegation sửa số listener, không sửa số node. Mười nghìn node DOM vẫn tốn memory, style recalc, và accessibility tree dù chỉ một click handler. Đó là lúc virtualization (chỉ render row visible) thắng — chi tiết trong virtual scroll deep dive.
Cây quyết định thực tế:
- < ~500 node tương tác, list ổn định hoặc tăng chậm → delegation + fragment batching.
- Re-render full thường xuyên (SPA) → delegation trên root sống sót re-render, hoặc event delegation của framework.
- Hàng nghìn row, scroll nặng → virtualize; giữ một delegated listener trên viewport container.
Checklist production
| Check | Action |
|---|---|
| List / table clicks | One delegated listener + closest('[data-…]') |
| Focus tracking | focusin / focusout, not focus / blur |
| Cleanup on unmount | AbortController.signal on all listeners |
| Bulk DOM insert | DocumentFragment or replaceChildren, not N appends |
| Measure loops | Separate geometry reads from style/DOM writes |
| Untrusted HTML | No raw innerHTML; sanitize or use text nodes |
| Huge lists | Virtual scroll + delegation on scroll container |
Pattern thực tế
Bảng data có action: delegate trên <tbody>; dùng data-action trên button để một handler route delete, edit, select.
tbody.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]');
if (!action) return;
const tr = action.closest('tr');
if (!tr) return;
const id = tr.dataset.id;
switch (action.dataset.action) {
case 'delete': return removeRow(id);
case 'edit': return openEditor(id);
default: break;
}
});
Keyboard shortcut không làm bẩn mọi input: listener capture phase trên document cho Escape / Cmd+K; bỏ qua khi event.target là field editable trừ khi cố ý.
Hydrate list dần: first paint với skeleton row từ fragment; thay row thật ở requestAnimationFrame kế tiếp để LCP không bị block bởi 2.000 lần createElement trong một task.
Kết luận: Coi DOM như database bạn query theo batch — một listener trên container, nhiều node trong fragment, read trước write — và chỉ virtualize khi chính số node là nút thắt.
Đọc thêm
- MDN — Event delegation
- MDN —
addEventListeneroptions - Google Web Fundamentals — Avoid large, complex layouts and layout thrashing
- Infinite scroll & virtual scroll deep dive — when the DOM itself must shrink
- Browser rendering pipeline deep dive — where reflow fits in the full pipeline