19 — DOM Internals: Selection, Mutation & Live Collections
DOM Selection Methods
javascript
// ── Selection methods (ranked by specificity and performance) ──
// getElementById: fastest, returns a single element (or null)
const el = document.getElementById("header"); // O(1) — hashed lookup by ID
// Only works for IDs. Returns the first match (IDs should be unique).
// querySelector: CSS selector, returns first match (or null)
const first = document.querySelector(".item"); // first .item
const nested = document.querySelector(".container > .item:first-child");
const complex = document.querySelector('[data-role="admin"]:not(.disabled)');
// querySelectorAll: returns a STATIC NodeList (not live — doesn't update)
const items = document.querySelectorAll(".item"); // NodeList (array-like, has forEach)
items.forEach(item => console.log(item.textContent));
// ⚠️ This is a SNAPSHOT — if you add a new .item later, this NodeList doesn't update.
// getElementsByClassName / getElementsByTagName: return LIVE HTMLCollection
const liveItems = document.getElementsByClassName("item"); // HTMLCollection (LIVE)
const allDivs = document.getElementsByTagName("div"); // HTMLCollection (LIVE)
// ⚠️ LIVE collection: if you add/remove elements, the collection updates automatically.
// This can cause bugs in loops (adding elements extends the loop → infinite loop).
// ── Live collection loop trap ──
const divs = document.getElementsByTagName("div");
// ❌ NAIVE — modifying the DOM while iterating a live collection
// for (let i = 0; i < divs.length; i++) {
// document.body.appendChild(document.createElement("div")); // adds to DOM → divs.length grows
// } // infinite loop! (divs.length keeps increasing)
// ✅ FIX: cache the length first, or use a static copy
for (let i = 0, len = divs.length; i < len; i++) { ... }
// Or: const staticCopy = [...document.getElementsByTagName("div")];
Anti-Pattern: Repeated Layout Thrashing (Reflow)
javascript
// ❌ NAIVE — reading and writing layout alternately (forces reflow each time)
const items = document.querySelectorAll(".item");
items.forEach(item => {
const width = item.offsetWidth; // READ: forces layout calculation (reflow)
item.style.width = width + 10 + "px"; // WRITE: invalidates layout
// Next iteration: offsetWidth reads again → another reflow!
});
// N elements → N reflows (very expensive — each reflow recalculates the whole page layout)
// ✅ CORRECT — batch reads first, then batch writes (minimize reflows)
const items = document.querySelectorAll(".item");
// Phase 1: read all layout values
const widths = [...items].map(item => item.offsetWidth); // all reads at once (1 reflow)
// Phase 2: write all styles
items.forEach((item, i) => {
item.style.width = widths[i] + 10 + "px"; // all writes at once (1 reflow)
});
// Total: 2 reflows (instead of 2*N) — much faster.
// ✅ BEST — use requestAnimationFrame for DOM writes
function batchDOMUpdates(updates) {
// Collect all updates, apply them in a single frame
requestAnimationFrame(() => {
for (const [el, style] of updates) {
Object.assign(el.style, style);
}
});
}
DocumentFragment: Batch DOM Insertion
javascript
// ❌ NAIVE — appending elements one at a time (N reflows for N elements)
for (let i = 0; i < 1000; i++) {
const li = document.createElement("li");
li.textContent = `Item ${i}`;
document.querySelector("ul").appendChild(li); // each append triggers a reflow
}
// 1000 appends → 1000 reflows (very slow)
// ✅ CORRECT — use DocumentFragment (batch all insertions, 1 reflow)
const fragment = document.createDocumentFragment(); // lightweight container (no DOM node)
for (let i = 0; i < 1000; i++) {
const li = document.createElement("li");
li.textContent = `Item ${i}`;
fragment.appendChild(li); // append to fragment (no reflow — fragment isn't in the DOM)
}
document.querySelector("ul").appendChild(fragment); // 1 append → 1 reflow (all 1000 items inserted at once)
// ✅ ALSO CORRECT — build a string and use innerHTML (1 reflow, but no event listeners on innerHTML)
const html = Array.from({ length: 1000 }, (_, i) => `<li>Item ${i}</li>`).join("");
document.querySelector("ul").innerHTML = html; // 1 reflow (but XSS risk if data is untrusted!)
// ⚠️ innerHTML with user data = XSS vulnerability. Sanitize or use textContent / DOM methods.
DOM Traversal
javascript
const el = document.querySelector(".container");
// ── Tree traversal ──
el.parentNode; // direct parent (or null)
el.parentElement; // parent if it's an element (or null if parent is not element)
el.childNodes; // NodeList of ALL children (including text nodes, comments)
el.children; // HTMLCollection of ELEMENT children only (no text/comment nodes)
el.firstChild; // first child (including text nodes — might be a whitespace text node)
el.firstElementChild; // first ELEMENT child (skips text/comment nodes)
el.lastElementChild; // last element child
el.nextElementSibling; // next sibling element
el.previousElementSibling; // previous sibling element
// ── closest: traverse UP the tree (find nearest ancestor matching selector) ──
const button = document.querySelector("button");
const row = button.closest("tr"); // nearest ancestor <tr> (or null)
const form = button.closest("form");
// ── matches: check if element matches a CSS selector ──
if (el.matches(".active")) { ... }
if (el.matches("[data-action='delete']")) { ... }
// ── contains: check if an element is a descendant ──
if (parentEl.contains(childEl)) { ... } // true if childEl is inside parentEl (or is parentEl)
// Useful for click-outside detection:
document.addEventListener("click", (e) => {
if (!dropdown.contains(e.target)) dropdown.close();
});
MutationObserver: Watching DOM Changes
javascript
// ── MutationObserver: watch for DOM mutations (efficient, async) ──
const observer = new MutationObserver((mutations, obs) => {
// mutations: array of MutationRecord (what changed)
for (const mutation of mutations) {
if (mutation.type === "childList") {
console.log("children changed");
console.log("added:", mutation.addedNodes); // NodeList of added nodes
console.log("removed:", mutation.removedNodes); // NodeList of removed nodes
}
if (mutation.type === "attributes") {
console.log("attribute changed:", mutation.attributeName);
console.log("old value:", mutation.oldValue);
}
if (mutation.type === "characterData") {
console.log("text content changed");
}
}
});
// Start observing (with options)
observer.observe(document.body, {
childList: true, // watch for added/removed children
subtree: true, // watch all descendants (not just direct children)
attributes: true, // watch attribute changes
attributeOldValue: true, // record old attribute values
characterData: true, // watch text content changes
characterDataOldValue: true,
});
// Stop observing (when done — prevents memory leaks)
// observer.disconnect();
// ── Use case: infinite scroll (observe last element visibility) ──
const sentinel = document.querySelector(".scroll-sentinel");
const scrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreContent(); // load next page when sentinel is visible
}
}, { rootMargin: "100px" }); // trigger 100px before sentinel enters viewport
scrollObserver.observe(sentinel);
💡 Tips & Tricks
javascript
// ── data-* attributes for metadata ──
const el = document.querySelector("[data-user-id]");
el.dataset.userId; // "123" (camelCase: data-user-id → dataset.userId)
el.dataset.role; // "admin"
el.dataset.customKey = "value"; // sets data-custom-key
// ── classList API (no manual string manipulation) ──
el.classList.add("active", "visible"); // add multiple classes
el.classList.remove("hidden"); // remove
el.classList.toggle("active"); // toggle (add if absent, remove if present)
el.classList.toggle("active", condition); // toggle based on condition
el.classList.replace("old", "new"); // replace
el.classList.contains("active"); // check
// ── Create and configure elements in one step ──
function createElement(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (key === "class") el.className = value;
else if (key === "style") Object.assign(el.style, value);
else if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else if (key in el) el[key] = value;
else el.setAttribute(key, value);
}
for (const child of children) {
el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
}
return el;
}
const div = createElement("div", { class: "card", onClick: handler }, "Hello");
// ── Avoid layout thrashing with getBoundingClientRect ──
const rect = el.getBoundingClientRect(); // READ (1 reflow)
// Now you have: rect.top, rect.left, rect.width, rect.height, rect.bottom, rect.right
// Use these cached values — don't read them again after DOM modifications.
// ── Shadow DOM for style encapsulation ──
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>:host { display: block; padding: 10px; } p { color: red; }</style>
<p>Shadow content</p>
`;
// Styles in shadow DOM don't leak out, page styles don't leak in (encapsulation)
⚠️ Edge Cases & Gotchas
javascript
// ── Live collections update automatically (can cause loop bugs) ──
const divs = document.getElementsByTagName("div");
for (let i = 0; i < divs.length; i++) {
document.body.appendChild(document.createElement("div")); // divs.length grows → infinite loop
}
// Fix: cache length, or use a static copy ([...divs] or querySelectorAll).
// ── querySelectorAll returns a STATIC NodeList (not live) ──
const items = document.querySelectorAll(".item");
document.body.appendChild(document.createElement("div")).className = "item";
items.length; // unchanged (static snapshot — doesn't reflect new .item)
// ── firstChild includes text nodes (whitespace) ──
// <div>\n <span>text</span>\n</div>
// div.firstChild is a text node (the "\n " whitespace), NOT the span
// Use firstElementChild for elements only.
// ── innerHTML is XSS-prone with user data ──
// ❌ el.innerHTML = `<div>${userInput}</div>`; // XSS if userInput has <script>
// ✅ el.textContent = userInput; // safe (escaped automatically)
// ✅ el.innerHTML = `<div>${escapeHtml(userInput)}</div>`; // escape manually
// ── offsetWidth/offsetHeight force reflow (layout calculation) ──
// Reading offsetWidth after a style change forces the browser to recalculate layout.
// Batch reads: const w = el.offsetWidth (read once, use multiple times).
// ── textContent vs innerHTML ──
el.textContent = "<b>not bold</b>"; // displays literally (escaped)
el.innerHTML = "<b>bold</b>"; // parses HTML (renders as bold)
// Use textContent for plain text (safe). Use innerHTML only for trusted HTML.
🧠 Quick Quiz
What's wrong with this code?
javascript
const items = document.getElementsByClassName("item");
for (let i = 0; i < items.length; i++) {
if (items[i].dataset.active === "true") {
items[i].remove();
}
}
Answer
getElementsByClassName returns a live HTMLCollection. When you remove() an element, the collection shrinks immediately. This causes the loop to skip elements:
- Iteration 0:
i=0, removeitems[0]→ collection shrinks,items[0]is now the formeritems[1] - Iteration 1:
i=1, butitems[1]is now the formeritems[2](formeritems[1]moved to index 0) - The former
items[1]is never checked — skipped!
Fix: iterate backwards (removing from the end doesn't shift indices):
const items = document.getElementsByClassName("item");
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].dataset.active === "true") {
items[i].remove();
}
}
Or use a static copy:
const items = [...document.getElementsByClassName("item")]; // static array
items.forEach(item => { if (item.dataset.active === "true") item.remove(); });
The lesson: live collections (getElementsBy*) update in real-time. Removing elements during forward iteration causes index shifting and skipped elements. Iterate backwards or use a static copy.