jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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)
PhaseDirectionDefault listener?
CaptureRoot → targetNo (capture: true required)
TargetOn the elementBoth capture and bubble listeners on target run in registration order
BubbleTarget → rootYes (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

ApproachListeners for N rowsNew row via innerHTML / template
Per-row listenerNMust rebind or use event delegation anyway
Delegated on parent1Works 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: focusblur 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.

TechniqueBest forWatch out
createElement + APIsDynamic, untrusted textVerbose
DocumentFragmentMany nodes, one commitStill build with safe APIs
<template> + cloneNodeRepeated structureFill text via textContent
innerHTMLTrusted static blobsXSS 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ế:

  1. < ~500 node tương tác, list ổn định hoặc tăng chậm → delegation + fragment batching.
  2. 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.
  3. Hàng nghìn row, scroll nặng → virtualize; giữ một delegated listener trên viewport container.

Checklist production

CheckAction
List / table clicksOne delegated listener + closest('[data-…]')
Focus trackingfocusin / focusout, not focus / blur
Cleanup on unmountAbortController.signal on all listeners
Bulk DOM insertDocumentFragment or replaceChildren, not N appends
Measure loopsSeparate geometry reads from style/DOM writes
Untrusted HTMLNo raw innerHTML; sanitize or use text nodes
Huge listsVirtual 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