8. HTML DOM APIs

JavaScript alone knows nothing about pages. Every time a script reads a form field, adds a list item, reacts to a click or measures an element, it goes through the Document Object Model: the tree of objects the browser builds from your HTML.This chapter starts with what the DOM is and what a change costs in rendering work, then tours the window object, node and element interfaces, HTML element objects, tree walkers and observers, and the event system.The final sections cover patterns that keep DOM code fast and safe from cross-site scripting, how React, Vue and Solid update the DOM for you, and where jQuery, Alpine.js and htmx still fit.What you will learn:
  • How the DOM tree, its interface hierarchy and the rendering pipeline relate

  • Using window, document, nodes, elements and HTML element objects

  • Traversing, observing and changing the tree efficiently

  • Handling, dispatching and customizing events

  • DOM performance patterns, XSS defense, jQuery and lightweight libraries


  • 8.1 The Document Object Model

    jQuery, React and plain document.querySelector() all end in calls on DOM objects. A clear model of those objects explains why childNodes holds "extra" text nodes, why window accepts event listeners like a <button>, and why reading one innocent property can freeze a page for a second.

    8.1.1 What the DOM Is

    The DOM is an in-memory tree of objects that represents a document, plus the API for reading and changing that tree. Each tag becomes an element node, each run of text a text node, and the document object sits at the root. The WHATWG DOM Standard defines the generic tree and events, the HTML Standard adds HTML element interfaces, and both describe their APIs in Web IDL, which browsers map to JavaScript objects.Four misconceptions cause most beginner bugs:
  • The DOM is not your HTML source. The parser repairs markup and keeps whitespace as text nodes; scripts change the tree later. View Source shows the file, the DevTools Elements panel the live DOM.

  • The DOM is not JavaScript. It is a Web API. Node.js has no document until you add an implementation such as jsdom (https://github.com/jsdom/jsdom) (npm install jsdom, MIT).

  • The DOM is not what you see. Rendering uses a separate tree of boxes (Section 8.1.3).

  • The DOM is not a snapshot. Collections such as childNodes and children are live and update as the tree changes.

  • <!doctype html>

    <table id="t"><tr><td>Cell</td></tr></table>

    <ul id="list">

    <li>One</li>

    <li>Two</li>

    </ul>

    <pre id="out"></pre>

    <script>

    const list = document.querySelector('#list');

    const kids = list.children; // a live collection

    list.append(document.createElement('li')); // this <li> is not in the source

    document.querySelector('#out').textContent = [

    `table: ${document.querySelector('#t').innerHTML}`,

    `ul.children: ${kids.length}, ul.childNodes: ${list.childNodes.length}`,

    ].join('\n');

    </script>

    Listing 8.1: The DOM is what the parser and scripts built, not what you typed Figure 8.1: Browser output of Listing 8.1The parser inserted a <tbody> the source never mentioned, the whitespace between the <li> tags became three text nodes, and kids, read before append(), already counts the new item. History: The W3C published DOM Level 1 as a Recommendation on October 1, 1998, so that "JavaScript scripts and Java programs" could be "portable among Web browsers"; it was strongly influenced by "Dynamic HTML". Today the WHATWG maintains the DOM as a Living Standard.

    8.1.2 The Interface Hierarchy

    DOM objects are organized as a class hierarchy, and in JavaScript it is a real prototype chain. An <input> object gets value from HTMLInputElement, hidden and focus() from HTMLElement, classList and querySelector() from Element, textContent and appendChild() from Node, and addEventListener() from EventTarget. Each method lives once on its prototype and is shared by every instance.Figure 8.2: The DOM interface hierarchy: every node is an EventTarget, but not every EventTarget is a nodeNode is abstract: every node is a Document, Element, Text, Comment or other subclass, identified by nodeType (1 for elements, 3 for text, 9 for the document). EventTarget is wider than the tree: Window, AbortSignal and even a plain new EventTarget() accept listeners. The listing below prints real prototype chains from Chrome.

    const chain = (obj) => {

    const p = Object.getPrototypeOf(obj);

    return p ? [p.constructor.name, ...chain(p)] : [];

    };

    console.log(chain(document.createElement('input')).join(' > '));

    console.log(chain(document.createTextNode('hi')).join(' > '));

    console.log(chain(document).join(' > '));

    console.log(chain(document.createElement('div').attachShadow({ mode: 'open' })).join(' > '));

    Listing 8.2: Walking an object's prototype chain

    HTMLInputElement > HTMLElement > Element > Node > EventTarget > Object

    Text > CharacterData > Node > EventTarget > Object

    HTMLDocument > Document > Node > EventTarget > Object

    ShadowRoot > DocumentFragment > Node > EventTarget > Object

    Output of Listing 8.2 Tip: The hierarchy explains two classic pitfalls. childNodes (from Node) includes text and comment nodes, while children (from Element's ParentNode mixin) holds only elements. And an SVG <circle> is not an HTMLElement, so check instanceof Element when code must handle both.

    8.1.3 How the Browser Builds and Renders the DOM

    Section 1.1.2 traced the pipeline from bytes to pixels. The HTML parser tokenizes bytes as they arrive and appends nodes at once, so the top of a long page can paint before the rest downloads. A classic <script> pauses tree construction while a preload scanner fetches upcoming stylesheets, scripts and fonts. When parsing ends and deferred and module scripts have run, document fires DOMContentLoaded; images may still be loading.Rendering does not draw the DOM directly. Style calculation matches the CSSOM against each element, and the engine builds a separate tree of boxes, the render (layout) tree, which differs in three ways:Figure 8.3: The same page as a DOM tree and as a render tree: hidden nodes get no box, pseudo-elements get boxes without nodes
  • Nodes without boxes. <head>, <script> and anything with display: none (how browsers implement the plain hidden attribute) stay in the DOM but produce no boxes. visibility: hidden keeps the box and its space.

  • Boxes without nodes. ::before and ::after generate boxes; neither querySelector() nor childNodes sees them.

  • One node, several boxes. A <span> wrapping over three lines has three border boxes, so getClientRects() returns three rectangles.

  • To ask "does this element have a box?", call element.checkVisibility() (Baseline since March 2024): it returns false for display: none subtrees and content-visibility: hidden, and accepts { opacityProperty: true, visibilityProperty: true } for stricter checks. jsdom has no render tree, so its geometry properties return zeros.

    8.1.4 Reflow, Repaint and Batching

    A reflow (layout) recomputes box sizes and positions; a repaint redraws pixels without moving anything (Section 4.1.4 maps properties to stages). Writes such as el.style.width = '50px' do not reflow on the spot: the engine marks boxes dirty and does the work once, before the next frame.Reads break that deal. Asking for offsetWidth, clientHeight, scrollTop, getBoundingClientRect() or a computed width while layout is dirty makes the browser run layout synchronously, inside your script, to return a correct number. One forced layout per loop iteration is layout thrashing, measured below in headless Chrome 152.

    const bars = Array.from({ length: 2000 }, () => {

    const bar = document.body.appendChild(document.createElement('div'));

    bar.style.cssText = 'height: 2px; width: 100px; background: teal';

    return bar;

    });

    function thrash() { // read, write, read, write: every read forces a layout

    for (const bar of bars) bar.style.width = `${bar.offsetWidth + 1}px`;

    }

    function batched() { // all reads first, then all writes

    const widths = bars.map((bar) => bar.offsetWidth);

    bars.forEach((bar, i) => { bar.style.width = `${widths[i] + 1}px`; });

    }

    for (const fn of [thrash, batched]) {

    const t0 = performance.now();

    fn();

    bars[0].offsetWidth; // include the final layout in the timing

    console.log(`${fn.name}: ${(performance.now() - t0).toFixed(1)} ms`);

    }

    Listing 8.3: Interleaved versus batched reads and writes on 2,000 elements

    thrash: 1140.5 ms

    batched: 6.1 ms

    Output of Listing 8.3 The same writes cost almost 190 times more when interleaved with reads. Swapping offsetWidth for getComputedStyle(bar).color in the thrashing loop took 8.7 ms in the same browser: color needs style, not layout.To keep DOM code fast:
  • Group reads before writes; defer writes from separate components with requestAnimationFrame() (Section 8.2.10). FastDOM (https://github.com/wilsonpage/fastdom) (npm install fastdom, MIT) automates this: fastdom.measure() and fastdom.mutate() queue callbacks and run reads, then writes, in the next frame.

  • Toggle one class instead of setting many inline styles, and build new subtrees in a DocumentFragment before inserting them once (Section 8.8.1).

  • Find offenders with the Forced reflow insight in the Chrome DevTools Performance panel.


  • 8.2 The Window Object

    Every tab, popup and <iframe> has its own window object. It is the global object for the document's scripts, so its members work without the window. prefix: alert() is window.alert(). It also describes the surroundings of the page: the address, the session history, the frame tree and the rendering loop. Members such as fetch(), crypto and navigator belong to the Web APIs of Chapter 9.

    8.2.1 Properties and Methods

    The hundreds of window members fall into a few groups.Figure 8.4: The main groups of window membersMembers in the dashed box also exist in Web Workers, which have no document; write globalThis for code that runs in both. Top-level function declarations become window properties, but let, const and class declarations do not. isSecureContext is true on HTTPS and localhost, which APIs such as the clipboard require. Beware that every id attribute also creates a window property: <div id="cart"> makes a global cart.

    8.2.2 Popping Up Dialog Boxes

    alert(msg) shows a message, confirm(msg) returns true or false, and prompt(msg, default) returns the typed string or null. The HTML Standard makes them pause the page: no timers, rendering or event handlers run until the user answers. They cannot be styled, and a sandboxed <iframe> without allow-modals suppresses them. Use <dialog> (Section 2.6.1, Baseline since March 2022); wrapped in a promise, it keeps the "ask and wait" flow without freezing the page.

    <dialog id="ask"><form method="dialog">Delete 3 files?

    <button value="no">Cancel</button> <button value="yes" autofocus>Delete</button></form></dialog>

    <p id="out">Waiting for an answer...</p>

    <script>

    const dlg = document.querySelector('#ask');

    const ask = () => new Promise((resolve) => {

    dlg.addEventListener('close', () => resolve(dlg.returnValue === 'yes'), { once: true });

    dlg.showModal();

    });

    ask().then((ok) => { document.querySelector('#out').textContent = `Confirmed: ${ok}`; });

    </script>

    Listing 8.4: A non-blocking replacement for confirm() Figure 8.5: Browser output of Listing 8.4A button in <form method="dialog"> closes the dialog and copies its value into returnValue; Esc leaves it empty, which counts as "no".

    8.2.3 Managing Windows and Frames

    open(url, target, features) loads a URL into a new tab, popup or named window and returns its WindowProxy: const win = open('/help.html', 'help', 'popup,width=480,height=600'). Poll win.closed to see when it closes. Popup blockers allow open() only in response to a user gesture.In the frame tree, self is the current window, parent the enclosing one, top the outermost, frames[i] a child <iframe> (length counts them) and opener the window that called open().

    <p id="out"></p>

    <iframe name="child" height="50" width="520" srcdoc="<body><script>

    document.body.textContent = `in frame: name=${name}, parent === top: ${parent === top},

    frameElement: ${frameElement.tagName}`;

    </script></body>"></iframe>

    <script>

    addEventListener('load', () => document.querySelector('#out').textContent =

    `in page: length=${length}, frames[0].name=${frames[0].name}, top === self: ${top === self}`);

    </script>

    Listing 8.5: Navigating the frame tree from both sides Figure 8.6: Browser output of Listing 8.5A cross-origin window exposes only a safelist of members, such as closed, parent, top and postMessage(). Links with target="_blank" imply rel="noopener", so the new page sees opener === null.

    8.2.4 Checking Visibility of Bars

    Six BarProp objects hang off window: locationbar, menubar, personalbar, scrollbars, statusbar and toolbar. Their read-only visible Boolean once reported whether that toolbar was showing. "For privacy and interoperability reasons", the HTML Standard now makes all six return false when the top-level window was opened as a popup and true otherwise, so the only remaining use is a popup check: if (!menubar.visible) document.body.classList.add('compact').

    8.2.5 Defining Custom HTML Elements

    window.customElements is the page's CustomElementRegistry. define(name, class) registers a hyphenated tag whose class extends HTMLElement, get(name) returns the class or undefined, and whenDefined(name) returns a promise that resolves when the tag is registered. Elements already in the page are upgraded when their definition arrives. Lifecycle callbacks and shadow DOM are covered in Section 2.7.1.

    <my-animal>Lucky</my-animal> is a nice dog. <span id="out"></span>

    <script>

    customElements.whenDefined('my-animal')

    .then((cls) => { document.querySelector('#out').textContent = `(${cls.name} is defined)`; });

    customElements.define('my-animal', class MyAnimal extends HTMLElement {

    connectedCallback() { this.style.cssText = 'color: brown; background: yellow'; }

    });

    </script>

    Listing 8.6: Registering a custom element and waiting for it Figure 8.7: Browser output of Listing 8.6

    8.2.6 Using Location

    window.location is a Location object that splits the current address into the same parts as a URL, including protocol, hostname and port. The listing ran at https://shop.example.com:8080/cart/view.html?id=42#total.

    for (const key of ['origin', 'host', 'pathname', 'search', 'hash']) {

    console.log(key.padEnd(9), location[key]);

    }

    Listing 8.7: Reading the parts of the current address

    origin https://shop.example.com:8080

    host shop.example.com:8080

    pathname /cart/view.html

    search ?id=42

    hash #total

    Output of Listing 8.7 Writing to location navigates. location.href = url and assign(url) add a history entry; replace(url) overwrites the current one, so Back skips it. Setting hash only scrolls and fires hashchange; setting search or pathname loads a new page. reload() takes no standard arguments (the reload(true) cache bypass works only in Firefox). Parse query strings with URLSearchParams (Section 8.6.5).

    8.2.7 Accessing History

    window.history exposes the tab's session history: length, back(), forward(), go(n), scrollRestoration and state. pushState(state, unused, url) adds an entry and changes the address bar without loading anything, and replaceState() edits the current entry; single-page routers are built on them. The middle argument is ignored, and a cross-origin URL throws a SecurityError. The listing ran at /product/7.

    addEventListener('popstate', (e) => console.log('popstate:', location.pathname, e.state));

    history.pushState({ tab: 'reviews' }, '', '/product/7/reviews'); // no request, no event

    console.log(JSON.stringify(history.state), location.pathname);

    history.back(); // same as history.go(-1)

    Listing 8.8: Pushing a state and returning to it

    {"tab":"reviews"} /product/7/reviews

    popstate: /product/7 null

    Output of Listing 8.8 popstate fires only when history is traversed, never on pushState(). States are structured-cloned and Firefox caps their size, so store bulky data in sessionStorage. The Navigation API, Baseline since January 2026, fixes most rough edges (Section 9.3.5).

    8.2.8 Working with CSS

    The CSS Object Model (CSSOM) is the DOM of stylesheets. getComputedStyle(el) returns read-only resolved values; el.style edits inline declarations; document.styleSheets[i].cssRules exposes rules from <style> and <link>; and new CSSStyleSheet() builds a constructable stylesheet that you attach through adoptedStyleSheets (Baseline since March 2023). The CSS Typed OM (attributeStyleMap, computedStyleMap()) returns numbers with units instead of strings; Chrome and Safari support it, Firefox does not.

    <style>p { color: red; }</style>

    <p style="background: orange">So a JavaScript function walks into a bar.</p>

    <pre id="out"></pre>

    <script>

    const p = document.querySelector('p');

    p.style.setProperty('color', 'blue', 'important'); // inline declarations

    const rule = document.styleSheets[0].cssRules[0]; // stylesheet rules

    const sheet = new CSSStyleSheet(); // constructable sheet

    sheet.replaceSync('pre { border-left: 4px solid green; padding-left: 8px; }');

    document.adoptedStyleSheets.push(sheet);

    const size = p.computedStyleMap().get('font-size'); // CSS Typed OM

    document.querySelector('#out').textContent = [`computed: ${getComputedStyle(p).fontSize}`,

    `inline: ${p.style.cssText}`, `${rule.constructor.name}: ${rule.cssText}`,

    `typed OM: value ${size.value}, unit '${size.unit}'`].join('\n');

    </script>

    Listing 8.9: Reading and writing CSS from JavaScript Figure 8.8: Browser output of Listing 8.9One constructable sheet can be shared by the document and many shadow roots, and editing it restyles them all; Lit styles its components this way. Rule type numbers are deprecated; check rule.constructor.name instead. Reading cssRules of a cross-origin sheet loaded without CORS throws a SecurityError.

    8.2.9 Matching Media Queries

    matchMedia(query) evaluates a CSS media query (Section 4.4.4) from JavaScript and returns a MediaQueryList with media (the normalized query), matches, and a change event that fires only when matches flips. Use it for behavior CSS cannot express, such as loading a chart only on wide screens.

    <p id="out"></p>

    <script>

    const wide = matchMedia('(min-width: 600px)');

    const out = document.querySelector('#out');

    const show = () => { out.textContent = `${wide.media}: ${wide.matches}`; };

    wide.addEventListener('change', show); // not the deprecated addListener()

    show();

    </script>

    Listing 8.10: Tracking a media query from a script Figure 8.9: Browser output of Listing 8.10The 480-pixel viewport fails the query; widen the window and the text updates. That is cheaper than a resize listener, which fires continuously while the user drags.

    8.2.10 Timing Animation (requestAnimationFrame)

    requestAnimationFrame(callback) runs callback once, just before the browser's next style, layout and paint (Section 8.1.3), and returns an ID for cancelAnimationFrame(). Callbacks follow the display's refresh rate and pause in background tabs and hidden iframes, unlike setInterval(). Compute progress from the timestamp argument, not a frame counter, or the animation runs twice as fast on a 120 Hz screen. The listing moves #box 300 pixels in one second (in headless Chrome).

    const box = document.querySelector('#box');

    let start, frames = 0;

    function step(now) { // same timestamp for every callback in this frame

    start ??= now;

    const progress = Math.min((now - start) / 1000, 1);

    box.style.transform = `translateX(${progress * 300}px)`;

    frames++;

    if (progress < 1) requestAnimationFrame(step); // one-shot: ask again for the next frame

    else console.log(`${frames} frames in ${Math.round(now - start)} ms`);

    }

    requestAnimationFrame(step);

    Listing 8.11: A one-second, frame-rate-independent animation 53 frames in 1012 ms Output of Listing 8.11 For simple transitions, CSS animations or el.animate() need less code; keep requestAnimationFrame for canvas drawing, physics and effects that need JavaScript every frame.

    8.2.11 Working on Idle Time

    requestIdleCallback(callback, { timeout }) runs low-priority work, such as analytics, when the main thread is idle, so input and animation stay smooth. The callback receives an IdleDeadline: timeRemaining() gives the milliseconds left in the idle period (at most 50), and didTimeout is true when timeout forced the call.

    let left = 200;

    let periods = 0;

    function work(deadline) {

    periods++;

    while (left > 0 && (deadline.timeRemaining() > 0 || deadline.didTimeout)) {

    left--;

    for (const end = performance.now() + 2; performance.now() < end;); // 2 ms of fake work

    }

    if (left > 0) requestIdleCallback(work, { timeout: 1000 });

    else console.log(`200 tasks done in ${periods} idle periods`);

    }

    requestIdleCallback(work, { timeout: 1000 });

    Listing 8.12: Draining a work queue in idle periods 200 tasks done in 10 idle periods Output of Listing 8.12 requestIdleCallback() is not Baseline because Safari lacks it, so feature-detect and fall back to setTimeout(). To split urgent long tasks, use scheduler.yield() (Chrome 129, Firefox 142, not Safari).

    8.2.12 Extracting Bitmaps

    createImageBitmap(source, [sx, sy, sw, sh], [options]) decodes an image, canvas, video frame, Blob or ImageData into an ImageBitmap, optionally cropped, and returns a promise. Decoding happens up front, so drawing the bitmap is fast, which suits sprites and games. Options include resizeWidth, resizeHeight and imageOrientation. It is Baseline since December 2023.

    <canvas id="sheet" width="240" height="60"></canvas>

    <canvas id="view" width="320" height="60"></canvas>

    <script>

    const sheet = document.querySelector('#sheet').getContext('2d');

    ['#1565c0', '#2e7d32', '#ef6c00', '#6a1b9a'].forEach((color, i) => { // a 4-frame sheet

    sheet.fillStyle = color;

    sheet.fillRect(i * 60 + 24 - i * 6, 24 - i * 6, 12 + i * 12, 12 + i * 12);

    });

    const crops = [0, 1, 2, 3].map((i) => createImageBitmap(sheet.canvas, i * 60, 0, 60, 60,

    { resizeWidth: 40, resizeHeight: 40 }));

    const view = document.querySelector('#view').getContext('2d');

    Promise.all(crops).then((frames) => frames.reverse()

    .forEach((bitmap, i) => view.drawImage(bitmap, 10 + i * 80, 10)));

    </script>

    Listing 8.13: Cutting frames out of a sprite sheet Figure 8.10: Browser output of Listing 8.13The right canvas shows the cropped, shrunken frames in reverse. ImageBitmap is transferable, so a Web Worker can decode images and post them back without copying; bitmap.close() frees its memory.

    8.2.13 Debugging with Console

    window.console writes to the DevTools Console, which Ctrl+Shift+J (Cmd+Option+J on a Mac) opens in Chrome. Section 1.3.2 tours the panel and the common methods (log(), warn(), table(), group(), time(), assert()). Wrap variables in braces so each value carries its name, and count with labels.

    const height = 168;

    const weight = 65;

    console.log(height, weight); // which number is which?

    console.log({ height, weight }); // shorthand for { height: height, weight: weight }

    console.count('retry');

    console.count('retry'); // counts per label; countReset('retry') restarts

    Listing 8.14: Self-describing logs and labeled counters

    168 65

    {height: 168, weight: 65}

    retry: 1

    retry: 2

    Output of Listing 8.14 console.dir(el) lists an element's properties where log() shows its markup, console.trace() prints the call stack, and console.timeLog(label) reads a timer without stopping it. Strip debug logging from production builds, for example with esbuild's --drop:console.

    8.2.14 Printing on Paper

    print() opens the print dialog for the current document, like Ctrl+P. Paper layout belongs in CSS: @media print (Section 4.4.4), printer selectors (Section 4.3.10) and @page (Section 4.4.7). JavaScript handles the rest through the beforeprint and afterprint events, Baseline since 2019.

    const expanded = [];

    addEventListener('beforeprint', () => document.querySelectorAll('details:not([open])')

    .forEach((d) => { d.open = true; expanded.push(d); }));

    addEventListener('afterprint', () => expanded.splice(0).forEach((d) => { d.open = false; }));

    document.querySelector('#print-btn').addEventListener('click', () => print());

    Listing 8.15: Expanding collapsed content for printing To print another document, such as an invoice, without showing it, load it into a hidden same-origin <iframe> and call frame.contentWindow.print() after it loads. For server-generated PDFs, Puppeteer or Playwright's page.pdf() applies the same print CSS.

    8.3 Data Type Objects

    Many DOM calls return small helper objects rather than plain JavaScript values: NodeList, DOMTokenList, TimeRanges, Range, DOMException. They are defined in Web IDL, so they look like arrays or errors but follow their own rules. Some update themselves as the page changes; some lack forEach().

    8.3.1 Interfaces, Properties and Methods

    The data types you meet most often are element collections (NodeList, HTMLCollection, Section 8.3.2), attribute maps (NamedNodeMap of Attr, 8.3.3), token lists (DOMTokenList from classList, 8.3.4), media TimeRanges (8.3.5), Range and Selection (8.3.6), DOMException (8.3.7), DOMImplementation (8.3.8) and DOMRect (8.4.7).None is a real Array. In Chrome, NodeList and DOMTokenList have forEach(), HTMLCollection and NamedNodeMap only support for...of, and TimeRanges supports neither. Use Array.from(list) for map() or filter(). Under the hood: Web IDL gives every interface with an indexed getter a Symbol.iterator borrowed from Array.prototype.values. Only interfaces also declared iterable<T>, such as NodeList, receive forEach(), keys(), values() and entries().

    8.3.2 Selecting Elements

    You can find elements by id, class, tag, name or any CSS selector (Section 4.3); getElementById() and getElementsByName() exist only on document. A live collection re-queries the tree whenever you read it; a static one is a snapshot. Method Returns Live? getElementById(id) Element or null n/a querySelector(sel) First match or null n/a querySelectorAll(sel) NodeList Static getElementsByClassName() HTMLCollection Live getElementsByName(name) NodeList Live Table 8.1: Element selection methods Removing a class while looping over a live collection shrinks it mid-loop and skips items:

    <pre id="out" style="font-size: 15px; margin: 0"></pre>

    <ul id="todo" style="display: flex; gap: 32px">

    <li class="open">Test</li><li class="open">Fix</li><li class="open">Ship</li>

    </ul>

    <script>

    const todo = document.querySelector('#todo');

    const live = todo.getElementsByClassName('open'); // HTMLCollection

    const snapshot = todo.querySelectorAll('.open'); // static NodeList

    for (const li of live) li.classList.remove('open'); // bug: skips items

    const out = [`after live loop: ${live.length} still open`];

    for (const li of snapshot) li.classList.remove('open'); // safe

    out.push(`after snapshot loop: live=${live.length} snapshot=${snapshot.length}`);

    document.querySelector('#out').textContent = out.join('\n');

    </script>

    Listing 8.16: Live HTMLCollection versus static NodeList Figure 8.11: Browser output of Listing 8.16closest(sel) tests the element, then its ancestors: event.target.closest('button') finds a button even when the click hit an icon inside it (Section 8.8.1). Warning: el.querySelectorAll('section p') matches against the whole document, so a <section> above el counts; write ':scope section p'.

    8.3.3 Processing Attributes

    An attribute is markup text (value="draft"); a property is a field on the element object. Many properties reflect attributes, but form controls split them: input.value is the current value, while the attribute (mirrored by defaultValue) keeps the original.

    const p = document.querySelector('p'); // <p>About attributes</p>

    console.log(p.hasAttributes(), p.toggleAttribute('hidden'), p.getAttribute('id'));

    p.setAttribute('data-state', 'new');

    console.log(JSON.stringify(p.getAttributeNames()), p.dataset.state);

    const input = document.querySelector('input'); // <input value="draft">

    input.value = 'edited';

    console.log(input.getAttribute('value'), input.value, input.defaultValue);

    Listing 8.17: Attributes versus properties

    false true null

    ["hidden","data-state"] new

    draft edited draft

    Output of Listing 8.17 toggleAttribute(name, force) returns whether the attribute is present afterward, ideal for hidden, disabled and inert. element.attributes is a live, unordered NamedNodeMap of Attr objects; getAttributeNames() is the lighter way to enumerate.

    8.3.4 Processing Classes

    element.classList is a DOMTokenList: a live, case-sensitive view of the class attribute that trims whitespace and ignores duplicates, so you never split className strings by hand. The same interface backs relList on links and sandbox on iframes.

    const list = document.querySelector('p').classList; // <p class="card featured">

    list.add('active', 'featured'); // duplicate ignored

    console.log(list.toggle('dark'), list.toggle('dark'), list.toggle('big', true));

    console.log(list.replace('card', 'panel'), list.value);

    Listing 8.18: Manipulating classes with classList

    true false true

    true panel featured active big

    Output of Listing 8.18 toggle() returns whether the token is present afterward; its second argument makes conditional styling one line: row.classList.toggle('selected', row.id === currentId). supports() works only on lists with a fixed vocabulary, such as iframe.sandbox.supports('allow-forms'); on classList it throws a TypeError, and a token containing a space throws an InvalidCharacterError.

    8.3.5 Timing Media Clips

    Media elements (Section 2.4.5) report buffered (downloaded), seekable and played spans as TimeRanges: length plus start(i) and end(i) in seconds. Ranges are ordered and merged, so a gap appears as a second entry.

    const video = Object.assign(document.createElement('video'),

    { muted: true, src: 'https://mdn.github.io/shared-assets/videos/flower.webm' });

    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    const show = (r) => Array.from({ length: r.length },

    (_, i) => `${r.start(i).toFixed(1)}-${r.end(i).toFixed(1)}`).join(', ');

    video.addEventListener('loadedmetadata', async () => {

    video.currentTime = 1; await video.play(); await sleep(1000); // play from 1.0 s

    video.currentTime = 3.5; await sleep(500); video.pause(); // skip ahead

    for (const name of ['buffered', 'seekable', 'played']) {

    console.log(`${name}: ${show(video[name])}`);

    }

    });

    Listing 8.19: Reading buffered, seekable and played ranges

    buffered: 0.0-5.1

    seekable: 0.0-5.1

    played: 1.0-2.0, 3.5-3.8

    Output of Listing 8.19 The small file downloaded completely, so buffered holds one range. Re-read it on each progress event. start() and end() throw an IndexSizeError for an out-of-range index, such as end(0) before any data arrives, so check length first.

    8.3.6 Selecting Text (Range and Selection)

    A Range spans two boundary points, each a node plus an offset (a character index in a Text node). The document's Selection wraps a range but adds direction: the anchor is where the user started, the focus where they stopped, while a range's start always precedes its end.The demo highlights every "DOM" with the CSS Custom Highlight API (Baseline 2025), which styles ranges via ::highlight() without inserting elements, then selects "API that" backward.

    <style>p { font: 18px Georgia, serif; } ::highlight(hit) { background: #ffe082; }</style>

    <p id="quote">The DOM is not JavaScript: the DOM is an API that JavaScript uses.</p>

    <pre id="out" style="font-size: 14px"></pre>

    <script>

    const text = document.querySelector('#quote').firstChild; // the Text node

    const ranges = [...text.data.matchAll(/DOM/g)].map((m) => {

    const range = new Range();

    range.setStart(text, m.index); range.setEnd(text, m.index + 3);

    return range;

    });

    CSS.highlights.set('hit', new Highlight(...ranges)); // DOM unchanged

    const sel = getSelection();

    sel.setBaseAndExtent(text, 49, text, 41); // anchor after focus

    const r = sel.getRangeAt(0);

    document.querySelector('#out').textContent = `"${sel}" ${sel.direction}: anchor ` +

    `${sel.anchorOffset}, focus ${sel.focusOffset}; range ${r.startOffset}-${r.endOffset}`;

    </script>

    Listing 8.20: Highlighting ranges and inspecting a backward selection Figure 8.12: Browser output of Listing 8.20Ranges also edit content (extractContents(), insertNode(), surroundContents()); listen for selectionchange on document to track the user.

    8.3.7 Handling Exceptions

    DOM methods report failures by throwing a DOMException, identified by its name. Each trigger below was tested in Chrome. name code Example trigger IndexSizeError 1 buffered.end(0) with no data HierarchyRequestError 3 Appending <html> to <body> InvalidCharacterError 5 createElement('1st') SyntaxError 12 querySelector('##') AbortError 20 signal.throwIfAborted() DataCloneError 25 structuredClone(() => {}) Table 8.2: Common DOMException names The numeric code is legacy (newer names such as NotAllowedError have 0), so branch on name. You can create your own, for example as an abort reason:

    const controller = new AbortController();

    controller.abort(new DOMException('Upload cancelled by user', 'AbortError'));

    try {

    controller.signal.throwIfAborted();

    } catch (err) {

    if (!(err instanceof DOMException)) throw err; // rethrow non-DOM errors

    console.log(err.name, err.code, err.message);

    }

    Listing 8.21: Creating and catching a DOMException AbortError 20 Upload cancelled by user Output of Listing 8.21 Chrome now throws storage quota failures as a QuotaExceededError subclass (code 22) with quota and requested properties.

    8.3.8 Implementing a New Document

    document.implementation.createHTMLDocument(title) builds a complete, empty HTML document (doctype, <head> with <title>, <body>) that has no window. It is inert (scripts do not run, images do not load), so it works as an off-screen scratch space. To bring nodes into the page, importNode(node, true) clones them (the deep argument defaults to false) and adoptNode(node) moves them.

    const doc = document.implementation.createHTMLDocument('Report');

    doc.body.innerHTML = '<h1>Q3 sales</h1><p>Up 12%</p><script>alert(1)<\/script>';

    console.log(doc.title, doc.body.children.length, doc.defaultView); // no window: inert

    const copy = document.importNode(doc.querySelector('h1'), true); // clone into this page

    document.body.append(copy, document.adoptNode(doc.querySelector('p'))); // move

    console.log(copy.ownerDocument === document, doc.body.children.length);

    Listing 8.22: Building content in an inert document

    Report 3 null

    true 2

    Output of Listing 8.22 Warning: Inert is not safe: in Chrome, an <img onerror="..."> from the scratch document fires its handler once adopted into the page. Sanitize untrusted HTML first (Section 8.8.4).

    8.4 Event Target Objects

    Everything on a page is a node, and every node is an EventTarget. Section 8.1.2 laid out that inheritance chain; this section puts it to work: parsing strings into nodes, walking and editing the tree, measuring boxes, hit-testing points, attaching shadow roots and loading fonts. Frameworks perform these same calls on every update (Section 8.8.3), so knowing them helps you both write small widgets without a framework and debug what a framework did.

    8.4.1 Interfaces, Properties and Methods

    Node knows about parents, children and text; Element adds tags, attributes, geometry and shadow roots; Document is the root and the factory for new nodes. The ParentNode and ChildNode mixins add the modern convenience methods. Interface Key properties Key methods Node nodeType, parentNode, childNodes, textContent appendChild(), cloneNode(), contains(), normalize() Element tagName, innerHTML, shadowRoot getBoundingClientRect(), attachShadow(), setHTML() Document documentElement, body, activeElement, fonts createElement(), importNode(), elementsFromPoint() Mixins children, firstElementChild, nextElementSibling append(), before(), remove(), moveBefore() Table 8.3: Where the main tree members are defined Classic Node methods take one node and need the parent; mixin methods take any number of nodes or strings and are called on the node you hold, so prefer them. Members named ...Element... and children skip text and comment nodes; childNodes and nextSibling do not.

    8.4.2 Parsing HTML Strings

    innerHTML is an element's inner markup, outerHTML adds its own tags, and textContent is plain text that never parses. Assigning innerHTML rebuilds every child, so innerHTML += wipes typed input and listeners. DOMParser parses into a separate, inert Document; XMLSerializer serializes nodes back. Two newer methods:
  • setHTMLUnsafe(html) is innerHTML plus declarative shadow roots (Section 2.2.5); Document.parseHTMLUnsafe() is its DOMParser twin. Baseline since September 2025.

  • setHTML(html, { sanitizer }), the Sanitizer API, always strips <script>, <iframe>, <object>, <embed> and event-handler attributes, whatever the configuration says. It ships in Chrome and Edge 146 and Firefox 148, not yet Safari, so feature-detect it.

  • <pre id="out" style="font-size:12px;margin:0"></pre>

    <script>

    const html = '<b>Hi</b><a href="#" onclick="steal()">Go</a><script>steal()<\/script>';

    const box = document.createElement('div');

    box.innerHTML = html;

    const lines = [`innerHTML: ${box.innerHTML}`];

    if ('setHTML' in box) lines.push(`setHTML: ${(box.setHTML(html), box.innerHTML)}`);

    const doc = new DOMParser().parseFromString(html, 'text/html');

    lines.push(`DOMParser: ${doc.body.childElementCount} elements in an inert document`,

    `serialized: ${new XMLSerializer().serializeToString(doc.body.firstChild)}`);

    document.querySelector('#out').textContent = lines.join('\n');

    </script>

    Listing 8.23: Parsing the same untrusted string four ways Figure 8.13: Browser output of Listing 8.23Inserted scripts never run, but the surviving onclick fires on click: a cross-site scripting hole. Treat innerHTML and ...Unsafe methods as injection sinks (Trusted Types, Section 2.8.7; sanitizers, Section 8.8.4).

    8.4.3 Traversing and Comparing Nodes

    parentNode, firstChild, lastChild, previousSibling and nextSibling link every node to its neighbors, text nodes included. Identify a node with nodeType (1 element, 3 text, 8 comment, 9 document), nodeName and nodeValue. a === b asks "same object?", isEqualNode() asks "same structure?", contains() tests ancestry, and compareDocumentPosition() returns a bitmask (2 preceding, 4 following, 8 contains, 16 contained by) to test with &.

    <div id="list" hidden><p><span>1a</span>1b<span>1c</span></p><p>2</p></div>

    <pre id="out" style="margin:0"></pre>

    <script>

    const list = document.querySelector('#list');

    const t = list.firstChild.firstChild.nextSibling; // the text node "1b"

    const copy = list.cloneNode(true);

    const [p1, p2] = list.children;

    document.querySelector('#out').textContent = [

    `type/name/value: ${t.nodeType} ${t.nodeName} ${t.nodeValue}`,

    `clone: equal ${copy.isEqualNode(list)}, same ${copy === list}, in DOM ${copy.isConnected}`,

    `p1 vs p2: ${p1.compareDocumentPosition(p2)}, contains: ${list.contains(t)}`,

    ].join('\n');

    </script>

    Listing 8.24: Walking and comparing nodes Figure 8.14: Browser output of Listing 8.24

    8.4.4 Adding and Removing Nodes

    Create nodes with document.createElement() and place them with the methods below; inserting a node that is already in the tree moves it. The classic appendChild(), insertBefore() and removeChild() still work but take a single node. Method Called on Effect append(), prepend() Parent Add after last / before first child before(), after() Child Add before / after itself replaceWith(), remove() Child Replace / remove itself insertAdjacentHTML(pos, html) Element Parse HTML at pos Table 8.4: Node insertion methods (all but the last accept nodes and strings)

    <pre id="out" style="margin:0"></pre>

    <script>

    const [box, p, i] = ['div', 'p', 'i'].map((tag) => document.createElement(tag));

    i.textContent = 'world';

    p.append('Hello ', i); // strings become text nodes

    box.append(p);

    p.insertAdjacentHTML('afterend', '<p id="p2">How do you do?</p>');

    i.replaceWith('Worldie'); // i is now detached

    p.after('!');

    p.prepend(i); // reuse the detached node

    box.querySelector('#p2').remove();

    document.querySelector('#out').textContent = box.innerHTML;

    </script>

    Listing 8.25: Building and editing a paragraph Figure 8.15: Browser output of Listing 8.25pos is beforebegin, afterbegin, beforeend or afterend. insertAdjacentHTML() keeps existing children, so it replaces innerHTML +=, though it is still an injection sink. For many nodes, fill a DocumentFragment and insert once (Section 8.8.1).

    8.4.5 Copying and Moving Nodes

    cloneNode(true) copies a subtree, cloneNode() just the node. Copies lose addEventListener() listeners and non-clonable shadow roots. For another document's nodes, such as an iframe's, document.importNode(node, true) copies and document.adoptNode(node) moves.Moving with append() or insertBefore() is really remove, then insert: iframes reload, CSS animations restart, and focus, open popovers and modal dialogs are lost. moveBefore(node, reference) moves atomically and keeps that state, within one connected document. It ships in Chrome and Edge 133 and Firefox 144, not yet Safari, so fall back to insertBefore().

    <div style="display:flex;gap:8px"><div id="a"><input id="field"></div><div id="b"></div></div>

    <pre id="out" style="margin:0"></pre>

    <script>

    const [a, b, field] = ['#a', '#b', '#field'].map((s) => document.querySelector(s));

    const focused = () => document.activeElement === field;

    field.focus({ preventScroll: true });

    b.append(field);

    const log = [`append(): still focused? ${focused()}`];

    field.focus({ preventScroll: true });

    if ('moveBefore' in a) a.moveBefore(field, null); else a.insertBefore(field, null);

    log.push(`moveBefore(): still focused? ${focused()}`);

    document.querySelector('#out').textContent = log.join('\n');

    </script>

    Listing 8.26: append() drops focus; moveBefore() keeps it Figure 8.16: Browser output of Listing 8.26Chrome's launch post names framework authors as the main audience, for reordering keyed lists (Section 8.8.3).

    8.4.6 Normalizing Nodes

    append('text'), splitText() and range edits can leave a string split across adjacent text nodes, some empty. The page looks the same, but firstChild.data and childNodes.length mislead. normalize() merges adjacent text nodes and deletes empty ones throughout a subtree; whitespace-only nodes between elements stay, because they are not empty.

    <p id="p" hidden>A</p><pre id="out" style="margin:0"></pre>

    <script>

    const p = document.querySelector('#p');

    p.append('B', '', 'C'); // three more text nodes, one empty

    const before = p.childNodes.length;

    p.normalize();

    document.querySelector('#out').textContent =

    `before: ${before} nodes, after: ${p.childNodes.length} node "${p.firstChild.data}"`;

    </script>

    Listing 8.27: Merging adjacent text nodes Figure 8.17: Browser output of Listing 8.27

    8.4.7 Knowing the Bounding Rectangle

    el.getBoundingClientRect() returns a DOMRect (top, left, right, bottom, width, height) for the border box, in fractional CSS pixels from the viewport's top-left corner, including CSS transforms. Add scrollX and scrollY for page coordinates. The integer offsetHeight (border box), clientHeight (padding box minus scrollbar) and scrollHeight (all scrollable content) ignore transforms.Figure 8.18: getBoundingClientRect() coordinates (left) and the three height properties of a scrolling box (right) Warning: Geometry reads force synchronous layout after style changes (Section 8.1.4). Batch reads before writes; prefer ResizeObserver and IntersectionObserver (Section 9.9) to polling.

    8.4.8 Getting Elements at a Point

    document.elementFromPoint(x, y) returns the topmost element at viewport coordinates, the same hit test a click performs; elementsFromPoint() returns the whole stack. Elements with pointer-events: none are skipped, so a dragged ghost can reveal the drop target beneath it. document.caretPositionFromPoint(x, y) returns the text node and character offset, as a custom editor needs; it is Baseline since December 2025 and replaces Chrome's non-standard caretRangeFromPoint().

    <p id="text" style="display:inline-block;padding:8px;background:#bbdefb">Hello world</p>

    <script>

    const r = document.querySelector('#text').getBoundingClientRect();

    const [x, y] = [r.left + 60, r.top + r.height / 2]; // in an event: clientX, clientY

    console.log('top:', document.elementFromPoint(x, y).id);

    const stack = document.elementsFromPoint(x, y).map((el) => el.id || el.localName);

    console.log('stack:', stack.join(' > '));

    const caret = document.caretPositionFromPoint(x, y);

    console.log('caret:', caret.offsetNode.data, caret.offset);

    </script>

    Listing 8.28: Hit-testing one point

    top: text

    stack: text > body > html

    caret: Hello world 7

    Output of Listing 8.28

    8.4.9 Attaching Shadow DOM

    el.attachShadow({ mode: 'open' }) gives an element a private subtree: page CSS stays out, inner styles stay in, and host children project through <slot> elements. Section 2.2.5 introduced slots and Section 2.7 builds web components. Hosts are custom elements or containers such as div, span and section.

    <style>p { color: red; margin: 4px 0 }</style>

    <p>Page paragraph</p>

    <div id="host"><b slot="name">Ada</b></div>

    <script>

    const host = document.querySelector('#host');

    const root = host.attachShadow({ mode: 'open' });

    root.innerHTML = `<style>p { color: #1565c0; border: 2px solid; padding: 6px }</style>

    <p>Hello, <slot name="name">stranger</slot>. Page CSS cannot reach me.</p>`;

    root.querySelector('p').append(` shadowRoot found: ${host.shadowRoot === root}`);

    </script>

    Listing 8.29: Styles stay inside the shadow root Figure 8.19: Browser output of Listing 8.29With mode: 'closed', host.shadowRoot is null. Further options: delegatesFocus forwards focus inside, serializable lets getHTML({ serializableShadowRoots: true }) output the root, clonable lets cloneNode() copy it, and slotAssignment: 'manual' enables slot.assign(). Inner events are retargeted to the host; composedPath() shows the origin.

    8.4.10 Loading a Font

    The CSS Font Loading API is the script side of @font-face (Section 4.4.1) and is Baseline widely available. new FontFace(family, source) describes a font, load() returns a promise, document.fonts.add() registers it, document.fonts.check() tests availability and document.fonts.ready resolves when pending fonts finish.

    <p id="quote" style="font-size:30px;margin:0">The quick brown fox</p>

    <pre id="out" style="margin:0"></pre>

    <script>

    const font = new FontFace('Lobster',

    'url(https://fonts.gstatic.com/s/lobster/v32/neILzCirqoswsqX9zoKmMw.woff2)');

    font.load().then((loaded) => {

    document.fonts.add(loaded);

    document.querySelector('#quote').style.fontFamily = 'Lobster, cursive';

    document.querySelector('#out').textContent =

    `status: ${loaded.status}, check(): ${document.fonts.check('30px Lobster')}`;

    });

    </script>

    Listing 8.30: Loading a web font on demand Figure 8.20: Browser output of Listing 8.30Load a font from script when only some views need it, such as a signature pad, so first paint never waits. Await document.fonts.ready before measuring text, because a late font swap changes widths.

    8.5 HTMLElement Objects

    HTMLElement and its subinterfaces add what makes each tag useful from script: tables insert rows, selects expose options, form controls validate and videos play. Prefer these typed members to raw attributes and innerHTML.

    8.5.1 Interfaces, Properties and Methods

    The parser picks an interface for each tag (the prototype chain is drawn in Section 8.1.2). Valid custom element names get plain HTMLElement until defined (Section 8.2.5); unknown names get HTMLUnknownElement.

    for (const tag of ['input', 'video', 'user-card', 'blink']) {

    console.log(`<${tag}>`.padEnd(12), document.createElement(tag).constructor.name);

    }

    Listing 8.31: The interface the parser chooses for each tag

    <input> HTMLInputElement

    <video> HTMLVideoElement

    <user-card> HTMLElement

    <blink> HTMLUnknownElement

    Output of Listing 8.31 Member of HTMLElement What it does hidden, inert Hide the element; block focus and clicks inside it dataset data-user-id becomes dataset.userId innerText Text as rendered, honoring CSS and hidden content focus(), blur(), click() Move focus; simulate activation (Section 8.7.10) showPopover(), togglePopover() Control a popover element (Section 9.5.11) Table 8.5: Members every HTML element inherits from HTMLElement innerText can force a reflow (Section 8.1.4), so prefer textContent. inert is Baseline widely available since April 2023. Most properties reflect an attribute; value is the exception, holding the live value while defaultValue maps to the attribute.

    8.5.2 Inserting Tabular Elements

    HTMLTableElement builds its own structure: createCaption(), createTHead() and createTFoot() return the existing element or make one, createTBody() adds one, insertRow(index) returns a new <tr> and a row's insertCell(index) a <td>. Index -1, the default, appends; deleteRow() and deleteCell() take the same.

    <style>

    table { border-collapse: collapse; } th, td { border: 1px solid #90a4ae; padding: 3px 10px; }

    </style>

    <script type="module">

    const sales = [['North', 1250, 1410], ['South', 980, 1130], ['West', 1520, 1490]];

    const table = document.createElement('table');

    table.createCaption().textContent = 'Quarterly sales (units)';

    table.createTHead().insertRow().innerHTML = '<th>Region<th>Q1<th>Q2<th>Change'; // static

    const body = table.createTBody();

    for (const [region, q1, q2] of sales) {

    const row = body.insertRow();

    for (const value of [region, q1, q2, q2 - q1]) row.insertCell().textContent = value;

    }

    body.insertRow(0).insertCell().textContent = 'East (new)'; // index 0: first row

    document.body.append(table); // one layout, not many

    </script>

    Listing 8.32: Building a table with insertRow() and insertCell() Figure 8.21: Browser output of Listing 8.32There is no insertHeaderCell(), so <th> cells come from markup or createElement(). Fill cells from user data with textContent, never innerHTML, and style with CSS (Section 4.7.2), not the obsolete border or bgColor.

    8.5.3 Getting Options

    A <select> exposes a live options collection (select[1] works too), namedItem(id), add(option, before), remove(index), selectedIndex, value and selectedOptions. new Option(text, value) creates an option.

    <body style="display: flex; gap: 16px; margin: 8px">

    <select id="size" multiple size="4">

    <option id="s" value="1">Small</option><option value="2" selected>Medium</option>

    <option value="3" selected>Large</option>

    </select>

    <pre id="log" style="margin: 0"></pre>

    <script type="module">

    const select = document.querySelector('#size');

    select.add(new Option('Extra large', '4')); // no second argument: append

    select.options[0].selected = true;

    document.querySelector('#log').textContent = [

    `namedItem("s"): ${select.namedItem('s').text}, select[1].value: ${select[1].value}`,

    `selectedIndex: ${select.selectedIndex}, value: ${select.value}, length: ${select.length}`,

    `selected: ${[...select.selectedOptions].map((o) => o.text).join(', ')}`,

    ].join('\n');

    </script>

    Listing 8.33: Reading, adding and selecting options Figure 8.22: Browser output of Listing 8.33With multiple, value returns only the first selected option, so read selectedOptions. Listen for change, which also fires for keyboard selection.

    8.5.4 Validating Form Input

    Beyond the attributes of Section 2.5.4, script adds cross-field rules and controls how a form is submitted.

    <form action="/age" style="font-family: system-ui, sans-serif">

    <label>Age <input name="age" type="number" min="0" required></label>

    <label><input name="consent" type="checkbox"> Parent agrees</label>

    <button name="go">Proceed</button> <button name="back" formaction="/" formnovalidate>Back</button>

    </form>

    <pre id="log"></pre>

    <script type="module">

    const form = document.querySelector('form');

    const { age, consent, go, back } = form.elements;

    const log = (text) => document.querySelector('#log').append(`${text}\n`);

    age.value = '9'; // '' below marks the field valid again

    age.setCustomValidity(age.value < 13 && !consent.checked ? 'Under-13s need a parent.' : '');

    form.addEventListener('invalid', (e) => { e.preventDefault(); log(e.target.validationMessage); },

    true); // invalid does not bubble: capture it

    form.addEventListener('submit', (e) => {

    e.preventDefault(); // demo: stay on the page

    log(`submitted by ${e.submitter.name}`);

    });

    form.requestSubmit(go); // blocked by the custom error

    form.requestSubmit(back); // formnovalidate skips validation

    </script>

    Listing 8.34: A cross-field rule, formnovalidate and requestSubmit(submitter) Figure 8.23: Browser output of Listing 8.34requestSubmit(submitter) (Baseline widely available since September 2022) acts like a click: it honors formnovalidate and formaction, validates, and fires submit with event.submitter; form.submit() does none of that. Always repeat the checks on the server.

    8.5.5 Playing Audio and Video

    <audio> and <video> (Section 2.4.5) share HTMLMediaElement; new Audio(url) needs no markup. play() returns a promise that rejects with NotAllowedError when the autoplay policy blocks sound before any user interaction, or NotSupportedError when no source is playable. Kind Most-used members Methods play(), pause(), load(), canPlayType(type) Position currentTime, duration, paused, ended, loop Sound and speed volume (0-1), muted, playbackRate Loading src, currentSrc, readyState, buffered (Section 8.3.5) Events loadedmetadata, playing, pause, timeupdate, ended, error Table 8.6: The HTMLMediaElement API at a glance

    <button id="toggle">Play</button><pre id="log"></pre>

    <script type="module">

    const audio = new Audio('alarm.mp3');

    const button = document.querySelector('#toggle');

    button.addEventListener('click', () => (audio.paused

    ? audio.play().catch((err) => { button.textContent = err.name; }) : audio.pause()));

    audio.addEventListener('play', () => { button.textContent = 'Pause'; });

    audio.addEventListener('pause', () => { button.textContent = 'Play'; });

    document.querySelector('#log').textContent = ['audio/mpeg', 'audio/flac', 'video/x-flv']

    .map((type) => `canPlayType('${type}'): "${audio.canPlayType(type)}"`).join('\n');

    </script>

    Listing 8.35: Checking formats and a promise-aware play button Figure 8.24: Browser output of Listing 8.35canPlayType() answers "probably", "maybe" or "". Update the UI from play and pause events, since media keys change state too. Autoplaying video must be muted. For per-frame work use requestVideoFrameCallback() (Baseline since October 2024); timeupdate fires only about 4 to 66 times a second.

    8.6 Processor Objects

    Processor objects do work on a page without being part of it: they traverse trees, report changes, parse query strings, convert text to bytes and hand out URLs for in-memory data.

    8.6.1 Interfaces, Properties and Methods

    Each processor keeps state of its own, such as a position in the tree or a queue of records. All are Baseline widely available. Interface Created with Key members NodeIterator (8.6.2) document.createNodeIterator() nextNode(), previousNode() TreeWalker (8.6.3) document.createTreeWalker() currentNode, parentNode(), nextNode() MutationObserver (8.6.4) new MutationObserver(fn) observe(), disconnect() URLSearchParams (8.6.5) url.searchParams get(), getAll(), set() TextEncoder (8.6.6) new TextEncoder() encode(), encodeInto() Blob URLs (8.6.7) URL.createObjectURL(blob) URL.revokeObjectURL() Table 8.7: Processor objects Both traversal objects take a root, a whatToShow bitmask such as NodeFilter.SHOW_ELEMENT and an optional filter returning NodeFilter.FILTER_ACCEPT, FILTER_SKIP or FILTER_REJECT. Other observers: Section 9.9.

    8.6.2 Iterating with NodeIterator

    A NodeIterator flattens the nodes under a root, root first, into depth-first document order; nextNode() and previousNode() move through that list and return null at the ends.

    <main hidden><p>A</p><span>Z <i>z</i></span><p>B<b>1</b></p><!-- hi --><p>C</p></main>

    <pre id="log"></pre>

    <script type="module">

    const out = [];

    const iterator = document.createNodeIterator(document.querySelector('main'),

    NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT,

    (node) => (node.nodeName === 'SPAN' ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT));

    for (let node; (node = iterator.nextNode()); ) {

    out.push(`${node.nodeName.padEnd(9)} ${JSON.stringify(node.textContent)}`);

    if (node.nodeName === 'B') node.parentNode.remove(); // removing nodes mid-walk is safe

    }

    out.push(`previousNode() at the end: ${JSON.stringify(iterator.previousNode().textContent)}`);

    document.querySelector('#log').textContent = out.join('\n');

    </script>

    Listing 8.36: A filtered NodeIterator that survives node removal Figure 8.25: Browser output of Listing 8.36For an iterator, FILTER_REJECT means the same as FILTER_SKIP: the <span> is dropped but its <i> is still visited. Removing nodes does not break the loop, because the iterator re-anchors its referenceNode. Stepping back at the end returns the last node again, since the pointer sits after it.

    8.6.3 Walking with TreeWalker

    A TreeWalker keeps a settable currentNode and moves in every direction: parentNode(), firstChild(), lastChild(), previousSibling(), nextSibling(), previousNode() and nextNode(). Its FILTER_REJECT drops a node and its descendants, which suits search-and-highlight that must skip code blocks.

    <p>The <b>DOM</b> is a tree. Walk the DOM with a TreeWalker.</p>

    <pre><code>const dom = document; // DOM in code stays untouched</code></pre>

    <script type="module">

    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ALL, (node) =>

    node.nodeName === 'PRE' || node.nodeName === 'SCRIPT' ? NodeFilter.FILTER_REJECT // subtree

    : node.data?.includes('DOM') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP);

    const hits = [];

    while (walker.nextNode()) hits.push(walker.currentNode); // collect first, then change

    for (const text of hits) {

    for (let i; (i = text.data.lastIndexOf('DOM')) >= 0; ) {

    text.splitText(i).splitText(3); // text | "DOM" | rest

    text.nextSibling.before(document.createElement('mark'));

    text.nextSibling.append(text.nextSibling.nextSibling); // move "DOM" into <mark>

    }

    }

    document.body.append(`${hits.length} text nodes highlighted; <pre> never visited.`);

    </script>

    Listing 8.37: Highlighting a word in text nodes, skipping code Figure 8.26: Browser output of Listing 8.37Collect matches before editing them. Wrapping text nodes, unlike rewriting innerHTML, preserves event listeners, form state and selection.

    8.6.4 Observing with MutationObserver

    A MutationObserver calls you back after the DOM under a node changes, whoever changed it: your code, a framework, an extension or the parser. observe(target, options) picks childList, attributes, characterData, subtree and the ...OldValue flags; disconnect() stops it.

    <ul id="list" style="margin: 0"><li class="todo">Milk</li></ul>

    <pre id="log"></pre>

    <script type="module">

    const list = document.querySelector('#list');

    const log = (text) => document.querySelector('#log').append(`${text}\n`);

    new MutationObserver((records) => {

    log(`callback with ${records.length} records:`);

    for (const { type, target: t, addedNodes: a, removedNodes: r, oldValue: o } of records) {

    log(` ${type.padEnd(13)} ${t.nodeName.padEnd(5)} +${a.length} -${r.length} old: ${o}`);

    }

    }).observe(list, { subtree: true, childList: true, attributes: true,

    attributeOldValue: true, characterData: true, characterDataOldValue: true });

    list.append(Object.assign(document.createElement('li'), { textContent: 'Eggs' }));

    list.firstChild.className = 'done';

    list.firstChild.firstChild.data = 'Oat milk';

    list.lastChild.remove();

    log('synchronous code finished');

    </script>

    Listing 8.38: Logging mutation records into the page Figure 8.27: Browser output of Listing 8.38All four changes arrive in one callback after the script finishes, because records are delivered in a microtask (Section 6.6.5). That batching replaced the synchronous mutation events Chrome removed in version 127 (July 2024). Use observers to translate, sanitize or enhance added nodes, keep callbacks cheap, and avoid re-triggering yourself.

    8.6.5 Getting Query String Values (URLSearchParams)

    URLSearchParams parses and builds query strings, so you never split on & by hand. Use new URLSearchParams(location.search) for the current page, or url.searchParams, a live view that rewrites url.href as you edit (encoding rules: Sections 2.9.6 and 6.12.5).

    const url = new URL('https://shop.example/search?q=rock+%26+roll&tag=cd&tag=vinyl&page=2');

    const params = url.searchParams;

    console.log(params.get('q'), JSON.stringify(params.getAll('tag')), params.get('x'), params.size);

    params.set('page', '3'); // replaces every "page"

    params.append('tag', 'lp & more');

    params.delete('tag', 'cd'); // name + value form

    console.log(url.href, params.has('tag', 'vinyl'));

    Listing 8.39: Reading, editing and serializing query parameters

    rock & roll ["cd","vinyl"] null 4

    https://shop.example/search?q=rock+%26+roll&tag=vinyl&page=3&tag=lp+%26+more true

    Output of Listing 8.39 get() returns a string or null, so convert numbers yourself. size (Baseline widely available since September 2023) and the value argument of has() and delete() (Chrome 117, Firefox 115, Safari 17) are the newest parts. Sync filters to the address bar with history.replaceState(null, '', url) (Section 8.2.7).

    8.6.6 Encoding Strings

    Base64 carries bytes through text-only channels such as JSON, data URLs and headers. It writes every 3 bytes as four 6-bit characters from A-Z a-z 0-9 + /, so output grows by a third, and = or == pads a short final group. btoa() and atob() accept only "binary strings" of single-byte characters, so convert text to UTF-8 bytes with TextEncoder first (Section 9.5.5).

    console.log(btoa('Hi!'), btoa('Hi'), btoa('H'), atob('SGkh')); // padding: none, =, ==

    try { btoa('€5'); } catch (err) { console.log(err.name); } // not a Latin-1 string

    const bytes = new TextEncoder().encode('€5'); // UTF-8: € takes 3 bytes

    console.log(`[${bytes}]`, bytes.toBase64(), btoa(String.fromCharCode(...bytes)));

    console.log(new TextDecoder().decode(new Uint8Array([0xe2, 0x82, 0x35]))); // cut-off €, then 5

    Listing 8.40: Base64, UTF-8 bytes and decoding errors

    SGkh SGk= SA== Hi!

    InvalidCharacterError

    [226,130,172,53] 4oKsNQ== 4oKsNQ==

    �5

    Output of Listing 8.40 toBase64() and Uint8Array.fromBase64() (Section 6.14.5) replace the String.fromCharCode(...bytes) workaround, which overflows for large arrays. TextDecoder swaps malformed bytes for U+FFFD; pass { fatal: true } to throw instead. Base64 is not encryption.

    8.6.7 Creating Object URLs

    URL.createObjectURL(blob) returns a short blob: URL for a Blob, File or MediaSource in memory, usable in <img src>, <a download>, <video src> or new Worker(). Unlike a data URL nothing is copied or encoded, but the data stays alive until URL.revokeObjectURL() or the document unloads.

    <body style="display: flex; gap: 16px; align-items: center; font: 14px system-ui, sans-serif">

    <img id="badge" width="90" height="90" alt="Generated badge">

    <div><a download="scores.csv">Download scores.csv</a><pre id="log"></pre></div>

    <script type="module">

    const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 90">

    <circle cx="45" cy="45" r="44" fill="#1565c0"/>

    <text x="45" y="55" font-size="28" text-anchor="middle" fill="#fff">A+</text></svg>`;

    const img = document.querySelector('#badge');

    img.src = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));

    img.addEventListener('load', () => URL.revokeObjectURL(img.src), { once: true }); // free it

    const csv = new Blob(['name,score\nAna,92\nBo,87\n'], { type: 'text/csv' });

    const link = document.querySelector('a');

    link.href = URL.createObjectURL(csv); // revoke when no longer offered

    document.querySelector('#log').textContent = `${link.href}\n${csv.size} bytes, ${csv.type}`;

    </script>

    Listing 8.41: Showing and downloading generated files through blob URLs Figure 8.28: Browser output of Listing 8.41The URL holds the page's origin (file:// in this demo) and a random UUID, and works only in the creating document or worker; service workers cannot create them. For user files, pass the File from <input type="file"> directly (Section 9.3.6).

    8.7 The Event Object

    When the user clicks, a resource loads or a socket receives a message, the browser creates an Event object, computes the propagation path from Window down to the target, and passes that one object to every listener on the path. Each listener can read what happened (type, target, coordinates, keys) and change what happens next.Dispatch runs in three phases: capture from Window down to the target's parent (listeners registered with capture: true), target, and bubble back up to Window (ordinary listeners), the last only when event.bubbles is true: click and input bubble; focus, load and mouseenter do not.Figure 8.29: The capture, target and bubble phases of a click on a button

    8.7.1 Interfaces, Properties and Methods

    Member of Event Meaning type, timeStamp Event name; creation time in ms target, currentTarget Origin element; element now handling it eventPhase 1 capture, 2 at target, 3 bubble bubbles, cancelable, composed Flags set when the event was created defaultPrevented, isTrusted Canceled? Fired by the browser? preventDefault(), stopPropagation() See Sections 8.7.13 and 8.7.14 Table 8.8: Core members of the Event interface Subclasses add data: MouseEvent has clientX/clientY and button, PointerEvent adds pointerType, KeyboardEvent has key and code, SubmitEvent has submitter, CustomEvent has detail. Avoid the legacy aliases srcElement, returnValue and cancelBubble. Under the hood: The browser computes the propagation path once, before the first listener runs, so moving an element inside a handler does not change which ancestors receive the current event.

    8.7.2 Binding Event Handlers

    Inline attributes (onclick="...") are blocked by a strict Content Security Policy, and a handler property (el.onclick = fn) holds only one function. addEventListener(type, listener, options) allows many listeners and takes these options: Option Effect when true or set capture Run in the capture phase, not the bubble phase once Remove the listener after its first call passive Never call preventDefault(), so scrolling need not wait* signal Remove the listener when its AbortController aborts Table 8.9: addEventListener options (*default true for wheel and touch events on window, document and body, except in Safari)

    <button>Buy</button> <output></output>

    <script>

    const [btn, out] = document.querySelectorAll("button, output");

    const ctrl = new AbortController();

    btn.addEventListener("click", () => out.append("once "), { once: true });

    btn.addEventListener("click", () => out.append("signal "), { signal: ctrl.signal });

    btn.click();

    ctrl.abort(); // removes every listener registered with this signal

    btn.click(); // adds nothing

    </script>

    Listing 8.42: once and signal remove listeners for you Figure 8.30: Browser output of Listing 8.42In a regular function handler this is the element; arrow functions do not bind it, so use event.currentTarget.

    8.7.3 Removing Event Handlers

    removeEventListener() succeeds only when the type, the same function reference and the capture flag all match, so an anonymous listener cannot be removed:

    btn.addEventListener("click", onTap, { capture: true });

    btn.removeEventListener("click", onTap); // no effect: capture flag differs

    btn.removeEventListener("click", onTap, true); // removed

    Listing 8.43: The capture flag must match No standard API lists or clears all listeners. el.replaceWith(el.cloneNode(true)) drops every listener added by script, but other code may still hold the detached original, so prefer once or a shared signal.

    8.7.4 Handling Input Events

    Category Event types Keyboard keydown, keyup (keypress deprecated) Mouse click, contextmenu, mouseenter, wheel Pointer, touch pointerdown ... (Sections 9.7.1, 9.7.7) Text entry beforeinput, input, change, compositionend Table 8.10: Input event types

    document.addEventListener("keydown", (e) => {

    if (e.key !== "s" || !(e.ctrlKey || e.metaKey) || e.isComposing) return;

    e.preventDefault(); // suppress the browser's Save Page dialog

    saveDraft();

    });

    Listing 8.44: A Ctrl+S (Cmd+S on macOS) shortcut key is the character produced (layout-aware); code is the physical key ("KeyS"), better for games. isComposing is true while a Chinese or Japanese input method is composing.

    8.7.5 Handling Document Events

    Category Event types Lifecycle readystatechange, DOMContentLoaded Focus focus, blur (no bubbling), focusin, focusout Forms, CSS submit, invalid, animationend, transitionend Drag and drop dragstart, dragover, drop, dragend Table 8.11: Document event types

    <output></output>

    <script>

    const log = (text) => document.querySelector("output").append(text, " > ");

    document.addEventListener("readystatechange", () => log(document.readyState));

    document.addEventListener("DOMContentLoaded", () => log("DOMContentLoaded"));

    addEventListener("load", () => log("load"));

    log(`script (${document.readyState})`);

    </script>

    Listing 8.45: The order of loading events Figure 8.31: Browser output of Listing 8.45A drop zone must cancel dragover, or the browser refuses the drop. Mutation events such as DOMNodeInserted no longer fire in Chrome 152; use MutationObserver (Section 8.6.4).

    8.7.6 Handling Resource Events

    Category Event types Loading load, error, abort Media play, pause, timeupdate, ended ... Storage storage (fires in other same-origin tabs) Table 8.12: Resource event types

    addEventListener("error", (e) => {

    const img = e.target;

    if (!(img instanceof HTMLImageElement) || img.dataset.fallback) return;

    img.dataset.fallback = "1"; // avoid a loop if the placeholder fails too

    img.src = "/img/placeholder.svg";

    }, { capture: true });

    Listing 8.46: One capturing listener replaces every broken image Element load and error events do not bubble, so a window listener sees them only while capturing. Images already in the markup may have loaded before your script ran; check img.complete.

    8.7.7 Handling Script Events

    WebSocket (Section 9.2.8) fires open, message, error and close; workers fire message and error; IndexedDB requests (Section 9.3.3) fire success, error and upgradeneeded; an AbortSignal fires abort. Uncaught exceptions and promise rejections reach window as error and unhandledrejection.

    <pre style="margin:0"></pre>

    <script>

    const log = (text) => document.querySelector("pre").append(text + "\n");

    const ctrl = new AbortController();

    ctrl.signal.onabort = (e) => log(`abort: ${e.target.reason}`);

    onunhandledrejection = (e) => { log(`rejection: ${e.reason.message}`); e.preventDefault(); };

    ctrl.abort("user left");

    Promise.reject(new Error("fetch failed")); // preventDefault() above keeps the console clean

    </script>

    Listing 8.47: abort and unhandledrejection events Figure 8.32: Browser output of Listing 8.47Forward both global events to your error tracker, or uncaught failures reach only the user's console.

    8.7.8 Handling Browser Events

    Category Event types Network, printing online, offline, beforeprint, afterprint History, lifecycle popstate, hashchange, pageshow, pagehide Viewport resize, scroll, scrollend, fullscreenchange Clipboard copy, cut, paste Table 8.13: Browser event types

    input.addEventListener("paste", (e) => {

    e.preventDefault();

    const text = e.clipboardData.getData("text").toUpperCase();

    input.setRangeText(text, input.selectionStart, input.selectionEnd, "end");

    });

    Listing 8.48: Uppercasing pasted text online only means a network link exists, so use it for hints. pageshow with persisted === true marks a return from the back/forward cache. Save state on visibilitychange or pagehide, because Chrome no longer fires the deprecated unload by default.

    8.7.9 Handling Smartphone Events

    Touch and pen input arrive as pointer and touch events (Sections 9.7.1 and 9.7.7), and deviceorientation and devicemotion report sensors over HTTPS (Section 9.7.4). For rotation, listen for change on screen.orientation, Baseline since March 2023, instead of the deprecated orientationchange:

    screen.orientation.addEventListener("change", () => {

    document.body.dataset.orientation = screen.orientation.type; // e.g. "landscape-primary"

    });

    Listing 8.49: Reacting to rotation Battery events (levelchange, chargingchange) are not Baseline, and the call, SMS and smart-card events of Firefox OS vanished with that platform.

    8.7.10 Simulating Clicks

    element.click() fires a bubbling click event and runs the element's default action (a checkbox toggles, a link navigates), but does nothing on a disabled element.

    document.querySelector("#accept").click(); // fires click, ticks the checkbox

    uploadButton.addEventListener("click", () => fileInput.click()); // styled file picker

    Listing 8.50: Clicking elements from script The event's isTrusted is false, so handlers can tell script clicks from real ones. Section 8.7.11 fires other event types.

    8.7.11 Dispatching Events

    Construct a built-in event and fire it with target.dispatchEvent(event). Listeners run synchronously, and the call returns false if the event is cancelable and a listener called preventDefault(). Constructor flags default to false, so pass bubbles: true when ancestors must hear the event.

    <input value="abc"> <output></output>

    <script>

    const [input, out] = document.querySelectorAll("input, output");

    input.addEventListener("keydown", (e) => { if (e.key === "x") e.preventDefault(); });

    for (const key of ["e", "x"]) {

    const ok = input.dispatchEvent(new KeyboardEvent("keydown", { key, cancelable: true }));

    out.append(`${key}: ${ok}; `);

    }

    </script>

    Listing 8.51: Dispatching synthetic keydown events Figure 8.33: Browser output of Listing 8.51Synthetic events mostly skip default actions: in Chrome a dispatched click still toggles a checkbox, but the keydown above typed nothing. For real input in tests, drive the browser with Playwright.

    8.7.12 Customizing Events

    new CustomEvent(type, { detail }) carries any data in event.detail, and since 2020 all major browsers let your classes extend EventTarget, so plain objects can emit events without a library.

    class Cart extends EventTarget {

    #items = [];

    add(item) {

    this.#items.push(item);

    this.dispatchEvent(new CustomEvent("cart:change", { detail: this.#items.length }));

    }

    }

    const cart = new Cart();

    cart.addEventListener("cart:change", (e) => console.log(`${e.detail} item(s)`));

    cart.add("pen");

    cart.add("ink");

    Listing 8.52: A cart that announces changes

    1 item(s)

    2 item(s)

    Output of Listing 8.52 Prefix custom names (cart:change) to avoid clashes with future built-in events. From inside a shadow root, add bubbles: true, composed: true.

    8.7.13 Preventing Default Behaviour

    event.preventDefault() cancels the default action of a cancelable event: following a link, toggling a checkbox, submitting a form, inserting text. return false does the same only in an on... attribute or property.

    link.addEventListener("click", (e) => e.preventDefault()); // stay on the page

    form.addEventListener("submit", (e) => { e.preventDefault(); save(new FormData(form)); });

    code.addEventListener("beforeinput", (e) => {

    if (e.data && /[^a-z]/.test(e.data)) e.preventDefault(); // lowercase letters only

    });

    Listing 8.53: Canceling default actions beforeinput cannot be canceled for autocomplete or input-method text, so validate again on submit. A passive listener's preventDefault() is ignored.

    8.7.14 Stopping Event Propagation

    stopPropagation() keeps the event from reaching further elements; stopImmediatePropagation() also skips the remaining listeners on the current element.

    <div><button>none</button> <button>stop</button> <button>stopImmediate</button></div>

    <pre style="margin:0"></pre>

    <script>

    const out = document.querySelector("pre");

    document.querySelector("div").addEventListener("click", () => out.append("div"));

    for (const btn of document.querySelectorAll("button")) {

    btn.addEventListener("click", (e) => e[`${btn.textContent}Propagation`]?.());

    btn.addEventListener("click", () => out.append("2nd listener, "));

    out.append(`${btn.textContent}: `); btn.click(); out.append("\n");

    }

    </script>

    Listing 8.54: Which listeners run after each call Figure 8.34: Browser output of Listing 8.54A stopped event never reaches delegated listeners, analytics or a "click outside to close" handler, so prefer checking event.target in the outer handler.

    8.7.15 Getting the id of a Clicked Element

    event.target.id works when the click lands on the element itself, but target may be an icon inside it. event.currentTarget.id (or this.id in a regular function) gives the element the listener is on; closest() finds the wanted ancestor.

    <nav><button id="bold"><b>B</b></button> <button id="italic"><i>I</i></button></nav>

    <output></output>

    <script>

    const log = (e) => (e.currentTarget.nextElementSibling.textContent +=

    `<${e.target.localName}> is in #${e.target.closest("button").id}; `);

    document.querySelector("nav").addEventListener("click", log);

    document.querySelector("#italic i").click();

    document.querySelector("#bold").click();

    </script>

    Listing 8.55: Finding the clicked button from one toolbar listener Figure 8.35: Browser output of Listing 8.55This one-listener approach, event delegation, is developed in Section 8.8.1.

    8.8 DOM Patterns and Performance

    DOM calls are cheap one at a time and slow by the thousand. Fast pages attach fewer listeners, insert in batches, rate-limit handlers and render only visible rows. Frameworks automate this, and all must keep injected markup out.

    8.8.1 Event Delegation, DocumentFragment and Batching

    Event delegation puts one listener on a container instead of every child. A click on an <li> bubbles to the <ul> (Section 8.7), where event.target.closest('li') finds the row. Rows added later need no listeners of their own.A DocumentFragment is a parentless node outside the document, so building a subtree in it never dirties layout; list.append(fragment) moves all its children in at once. The listing, run in headless Chrome 152, builds 2,000 items three ways.

    function build(list, useFragment, scrollEach) {

    const target = useFragment ? document.createDocumentFragment() : list;

    for (let i = 0; i < 2000; i++) {

    const li = target.appendChild(document.createElement('li'));

    li.textContent = `Item ${i}`, li.dataset.id = i;

    if (scrollEach) list.scrollTop = list.scrollHeight; // a read: forces layout if dirty

    }

    if (useFragment) list.append(target); // one insertion, one layout

    }

    let list;

    for (const [label, useFragment, scrollEach] of [['live list', false, false],

    ['live list + scroll', false, true], ['fragment + scroll', true, true]]) {

    list?.remove();

    list = document.body.appendChild(document.createElement('ul'));

    list.style.cssText = 'height: 200px; overflow: auto';

    const t0 = performance.now();

    build(list, useFragment, scrollEach);

    list.scrollTop = list.scrollHeight;

    console.log(`${label}: ${(performance.now() - t0).toFixed(1)} ms`);

    }

    list.addEventListener('click', (e) => { // one listener for every current and future <li>

    console.log(`clicked item ${e.target.closest('li')?.dataset.id}`);

    });

    list.children[42].click();

    Listing 8.56: Live inserts, forced layouts and a fragment, then one delegated listener

    live list: 37.5 ms

    live list + scroll: 1234.9 ms

    fragment + scroll: 30.2 ms

    clicked item 42

    Output of Listing 8.56 The fragment barely beats live inserts, because layout already waits until something reads it (Section 8.1.4). It wins 40-fold when a read such as "keep the newest row in view" sits in the loop. Chrome's metrics also showed 2,000 listeners and 65 KB more heap for per-item handlers.focus, blur and mouseenter do not bubble: delegate focusin, focusout and pointerover. With nested lists, check list.contains(li) before acting.

    8.8.2 Debouncing, Throttling and Virtualized Lists

    input, scroll, resize and pointermove fire faster than a search request should run. Two wrappers limit the rate:
  • Debounce runs once after calls stop for a quiet period: search-as-you-type, autosave, validation.

  • Throttle runs at most once per interval: scroll position, drag feedback. For painting, requestAnimationFrame() (Section 8.2.10) throttles to the display.

  • const debounce = (fn, wait) => { // run once calls stop for `wait` ms

    let timer;

    return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), wait); };

    };

    const throttle = (fn, wait) => { // at most once per `wait` ms, first and last call included

    let last = -Infinity, timer;

    return (...args) => {

    const run = () => { last = performance.now(); fn(...args); };

    clearTimeout(timer);

    const left = last + wait - performance.now();

    if (left <= 0) run(); else timer = setTimeout(run, left);

    };

    };

    const t0 = performance.now(), log = { debounced: [], throttled: [] };

    const rec = (name) => () => log[name].push(Math.round(performance.now() - t0));

    const d = debounce(rec('debounced'), 300), t = throttle(rec('throttled'), 300);

    for (let i = 0; i < 20; i++) { d(); t(); await new Promise((r) => setTimeout(r, 50)); }

    setTimeout(() => console.log(JSON.stringify(log)), 400);

    Listing 8.57: Debounce and throttle, fed 20 simulated keystrokes 50 ms apart (a module) {"debounced":[1394],"throttled":[0,300,613,917,1230]} Output of Listing 8.57 Twenty calls became one and five. Lodash's debounce adds leading, trailing and maxWait options; a debounced fetch() should also abort its predecessor (Section 6.6.10).Throttling cannot save 100,000 rows: the nodes themselves are the cost. A virtualized (windowed) list puts one empty spacer total × rowHeight pixels tall in a scroll box, and on each scroll renders only the visible rows plus a few of overscan, absolutely positioned at index × rowHeight. A 240-pixel box holds about 16 rows wherever you scroll. TanStack Virtual (https://github.com/TanStack/virtual) (MIT, npm install @tanstack/virtual-core, adapters for React, Vue, Solid, Svelte and Angular) adds measured row heights and grids. Windowing breaks find-in-page and hides off-screen rows from screen readers, so for a few thousand rows try content-visibility: auto (Section 4.6.3) first.

    8.8.3 How Virtual DOM Diffing and Signals Work

    Section 1.12.1 contrasted the two models; here is the machinery. A virtual DOM renderer re-runs the component to get a new tree of plain objects and diffs it against the old one. A general tree diff is O(n³); React's reconciler is O(n) thanks to two heuristics. An element whose type changed (<div> to <section>, or one component to another) is rebuilt, losing its state. Children are matched by key, or by position without keys, so inserting at the top of an unkeyed list rewrites every row: the classic answer to "why does React need keys?" Vue 3's compiler adds hints: static nodes are cached, and patch flags such as 2 /* CLASS */ tell the diff what may change.Signals skip the diff: a signal records which running computation reads it and re-runs only those.

    let running = null; // the effect that is executing right now

    function signal(value) {

    const subscribers = new Set();

    const read = () => { if (running) subscribers.add(running); return value; };

    const write = (v) => { if (v !== value) { value = v; subscribers.forEach((f) => f()); } };

    return [read, write];

    }

    function effect(fn) {

    const run = () => { const outer = running; running = run; fn(); running = outer; };

    run();

    }

    const [count, setCount] = signal(0), [user, setUser] = signal('Ada');

    effect(() => console.log(`text node = ${count()}`)); // roughly what Solid makes of {count()}

    effect(() => console.log(`title attribute = ${user()}`));

    setCount(1); setCount(1); setUser('Grace');

    Listing 8.58: A minimal signal and effect, the core of Solid-style reactivity

    text node = 0

    title attribute = Ada

    text node = 1

    title attribute = Grace

    Output of Listing 8.58 The repeated setCount(1) did nothing, and setUser never touched the count. A Solid component runs once; then effects like these patch single nodes. Built-in signals are a Stage 1 TC39 proposal (github.com/tc39/proposal-signals (https://github.com/tc39/proposal-signals)).

    8.8.4 Sanitization and XSS Defence

    When users supply markup (comments, rich-text editors), textContent is too strict and innerHTML is an XSS hole (Section 8.4.2). A sanitizer parses the markup with the browser's own parser and keeps only allowed elements, attributes and URL schemes. DOMPurify (https://github.com/cure53/DOMPurify) 3.4.15 (September 2026, MPL-2.0 or Apache-2.0, npm install dompurify) runs in all modern browsers and is tuned with ALLOWED_TAGS, FORBID_ATTR or addHook(); servers need a DOM such as jsdom. The built-in setHTML() can never keep scripts or handlers, but Safari lacks it. Trusted Types (Section 2.8.7) make sinks accept only policy output.

    <!doctype html>

    <meta http-equiv="Content-Security-Policy"

    content="require-trusted-types-for 'script'; trusted-types default dompurify">

    <script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.15/dist/purify.min.js"></script>

    <pre id="out" style="font: 13px/1.5 monospace; margin: 0"></pre>

    <script>

    const dirty = '<p onclick="steal()">Hi <img src=x onerror="steal()"><b>bold</b> '

    + '<a href="javascript:steal()">link</a><script>steal()<\/script></p>';

    trustedTypes.createPolicy('default', { // runs whenever a plain string reaches a sink

    createHTML: (html) => DOMPurify.sanitize(html),

    });

    const box = document.createElement('div');

    box.innerHTML = dirty; // allowed, but sanitized by the default policy first

    const lines = [`DOMPurify ${DOMPurify.version}: ${box.innerHTML}`];

    if ('setHTML' in box) lines.push(`setHTML(): ${(box.setHTML(dirty), box.innerHTML)}`);

    document.querySelector('#out').textContent = lines.join('\n');

    </script>

    Listing 8.59: A default Trusted Types policy backed by DOMPurify, beside setHTML() Figure 8.36: Browser output of Listing 8.59Both removed the handlers, the javascript: URL and the script; setHTML() also dropped the image. List dompurify in trusted-types: it is the policy DOMPurify uses for its own parsing, and with TRUSTED_TYPES_POLICY: null under enforcement Chrome 152 returned an empty string. Sanitize at insertion time, not when saving. Pin and update the library: DOMPurify published ten security advisories between May and August 2026.

    8.9 jQuery

    jQuery wraps the DOM APIs of Sections 8.2-8.7 in one chainable function, $(), that applies each method to every selected element. You will meet it in WordPress themes and older admin panels, so you need to read it even if you never start a project with it. Section 8.9.10 maps each idiom to vanilla JavaScript.

    8.9.1 Why jQuery Still Matters (jQuery 4)

    John Resig announced jQuery at BarCamp NYC in January 2006, when every browser had its own event model and querySelectorAll() did not exist. Browsers have since standardized what jQuery papered over, yet W3Techs still found it on 65.7% of all websites in September 2026, 78.6% of them on version 3.jQuery 4.0.0 (MIT licence, OpenJS Foundation) arrived on January 17, 2026, the first major release since 2016. It drops IE 10 and older, Edge Legacy and the old Android Browser (IE 11 goes in jQuery 5), moves the source from AMD to ES modules, accepts Trusted Types TrustedHTML values, and removes deprecated helpers:
  • $.isArray(), $.isFunction(), $.type(), $.isNumeric(), $.isWindow(): use Array.isArray(), typeof and Number.isFinite().

  • $.trim(), $.parseJSON(), $.now(), $.camelCase(): use str.trim(), JSON.parse() and Date.now().

  • $.cssNumber, $.cssProps, $.fx.interval, the array methods .push(), .sort() and .splice() on jQuery sets, the toggleClass(boolean) signature and automatic JSONP promotion.

  • <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>

    <!-- or the slim build: no AJAX, effects, Deferreds or Callbacks -->

    <script src="https://code.jquery.com/jquery-4.0.0.slim.min.js"></script>

    Listing 8.60: Loading jQuery 4 from code.jquery.com The full file is 27.4 KB gzipped and the slim build 19.4 KB (npm install jquery for bundlers). Event shorthands such as .click(fn) still work but are deprecated in favor of .on(). Tip: To upgrade an old site, load jQuery Migrate (https://github.com/jquery/jquery-migrate) 4.x after jQuery 4, fix each console warning it prints, then remove Migrate.

    8.9.2 Element Selection

    $(selector) returns an array-like jQuery set. It accepts CSS selectors plus jQuery extensions such as :header, :input, :checkbox, :visible and :contains(text). Expansion methods reach related elements: .find(), .children(), .parent(), .parents(), .closest(), .siblings(), .add(), .next(), .nextAll(), .nextUntil() and the .prev*() versions. Reduction methods narrow the set: .eq(i), .first(), .last(), .even(), .odd(), .slice(), .filter(), .not(), .has(). .end() returns to the previous set and .addBack() merges it in.

    <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>

    <pre id="log" style="margin:0"></pre>

    <div hidden><span>1</span><span>2</span><p>A</p><p>B</p><p>C</p></div>

    <script>

    for (const expr of ["$('div').children('p').add('span').length",

    "$('span').first().next().addBack().length", "$('p').eq(1).nextAll().text()",

    "$('p, span').not('p').last().text()", "$('p').index($('p:contains(C)'))"]) {

    $('#log').append(`${expr} -> ${new Function(`return ${expr}`)()}\n`);

    }

    </script>

    Listing 8.61: Expanding and reducing a jQuery set Figure 8.37: Browser output of Listing 8.61Extensions such as :first and :contains() bypass the native querySelectorAll() (Section 8.3.2), so prefer .first() in hot paths. An empty set never throws: $('#typo').hide() silently does nothing.

    8.9.3 DOM Manipulation

    Insertion methods accept HTML strings, elements or jQuery sets and apply to every match, cloning the content for all targets but the last. .append(), .prepend(), .after() and .before() have reversed forms (.appendTo(), .prependTo(), .insertAfter(), .insertBefore()) that take the target as the argument; .wrap(), .wrapAll() and .wrapInner() surround elements. .remove() discards elements with their data and handlers, .detach() keeps both for reinsertion, and .empty(), .unwrap(), .replaceWith() and .clone(true) do what their names say. .text(), .html(), .val() and .data() get or set values; .map() and .each() iterate.

    <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>

    <div id="box" hidden><i>A</i><i>B</i><i>C</i></div>

    <pre id="log" style="margin:0"></pre>

    <script>

    let log = '';

    const show = (step) => (log += `${step.padEnd(36)}${$('#box').html()}\n`);

    $('#box i').append('!'); show("$('#box i').append('!')");

    $('#box i').first().detach().appendTo('#box'); show("first().detach().appendTo('#box')");

    $('#box i').eq(0).wrap('<b></b>'); show("eq(0).wrap('<b></b>')");

    const letters = $('#box i').map((i, el) => el.textContent[0]).get();

    $('#log').text(`${log}map().get() -> ${letters}, is('i') -> ${$('#box i').is('i')}`);

    </script>

    Listing 8.62: Inserting, moving, wrapping and reading values Figure 8.38: Browser output of Listing 8.62Getters .html() and .val() read only the first element; .text() joins all. Never pass user input to .html(). .data() reads a data-* attribute once, then keeps changes in an internal cache that dataset never sees.

    8.9.4 Attributes, Properties, Dimensions and Position

    .attr() handles HTML attributes and .prop() live DOM properties: after a user ticks a box, only .prop('checked') reports it. .addClass(), .removeClass(), .toggleClass() and .hasClass() edit classes; .css() reads computed styles and writes inline ones. .width() measures the content box, .innerWidth() adds padding, .outerWidth() the border and .outerWidth(true) the margin (heights match). .offset() gives {top, left} in the document, .position() in the offset parent.

    <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>

    <div style="position:relative; height:50px"><input type="checkbox">

    <div id="d" style="position:absolute; top:0; left:40px; width:300px; height:10px;

    padding:10px; border:5px solid orange; margin:5px"></div></div>

    <pre id="log" style="margin:0"></pre>

    <script>

    const $c = $('input').prop('checked', true), $d = $('#d').css({ background: '#e3f2fd' });

    $('#log').text([`attr: ${$c.attr('checked')}, prop: ${$c.prop('checked')}`,

    `width ${$d.width()}, inner ${$d.innerWidth()}, outer ${$d.outerWidth()}`,

    `outer(true) ${$d.outerWidth(true)}, position ${JSON.stringify($d.position())}`].join('\n'));

    </script>

    Listing 8.63: Attributes versus properties, and box measurements Figure 8.39: Browser output of Listing 8.63Each getter forces layout: read all measurements before writing styles (Section 8.1.4).

    8.9.5 Animation Effects

    The effects module (absent from the slim build) writes inline styles on each requestAnimationFrame tick. .show(), .hide(), .toggle(), .fadeIn(), .fadeOut(), .fadeToggle(), .fadeTo(), .slideDown(), .slideUp() and .slideToggle() take a duration (ms, 'fast' = 200 or 'slow' = 600), an easing ('swing' or 'linear') and a callback, or an options object. .animate(props, options) tweens numeric CSS properties. Effects on one element wait in the fx queue: .delay() pauses it, .queue(fn) adds a step, .stop() halts the current effect, .finish() completes all, and .promise() resolves when the queue is empty.

    <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>

    <div id="box" style="width:50px; height:50px; background:orange"></div>

    <script>

    const t0 = performance.now(), $box = $('#box');

    const log = (m) => console.log(`${Math.round((performance.now() - t0) / 100) * 100} ms: ${m}`);

    $box.fadeOut(400, () => log('fadeOut done')).delay(200)

    .fadeIn('fast', () => log(`fadeIn done, opacity ${$box.css('opacity')}`))

    .animate({ width: 200, marginLeft: '+=20' }, 300, 'linear', () => log(`width ${$box.width()}`));

    log(`${$box.queue().length} steps queued`);

    $box.promise().then(() => log('promise resolved: queue empty'));

    </script>

    Listing 8.64: Chaining queued effects and timing their callbacks

    0 ms: 4 steps queued

    400 ms: fadeOut done

    800 ms: fadeIn done, opacity 1

    1100 ms: width 200

    1100 ms: promise resolved: queue empty

    Output of Listing 8.64 The chain returns at once; the running fadeOut counts as a queued step. A .queue(fn) step must call its next argument or the queue stalls. For new work, CSS transitions (Section 4.9) and the Web Animations API (Section 9.5.8) can animate opacity and transform off the main thread.

    8.9.6 Event Handlers and the Event Object

    .on(events, [selector], [data], handler) replaces .bind(), .delegate() and shorthands such as .click(fn). With a selector it delegates to present and future children (Section 8.8.1). .one() fires once, .off() removes handlers, and namespaces (click.menu) let you remove only your own. .trigger(type, args) runs handlers and the default action; .triggerHandler() skips the default action and bubbling. The jQuery event wraps the native one (e.originalEvent) and adds e.data, e.delegateTarget and e.result (the previous handler's return value). Returning false calls both preventDefault() and stopPropagation().

    <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>

    <ul id="menu"></ul>

    <pre id="log" style="margin:0"></pre>

    <script>

    const log = (msg) => $('#log').append(document.createTextNode(`${msg}\n`));

    $('#menu').on('click.menu', 'li', { shop: 'Lamps' }, function (e, via = 'user') {

    log(`${$(this).text()} in ${e.data.shop}, holder #${e.delegateTarget.id}, via ${via}`);

    return 42;

    }).on('click.menu', 'li', (e) => log(`previous handler returned ${e.result}`))

    .append('<li>Desk lamp</li>'); // added after binding: delegation still works

    $('#menu li').trigger('click', ['trigger()']);

    $('#menu').off('.menu').find('li').trigger('click'); // no handler runs

    </script>

    Listing 8.65: Delegation, namespaces, event data and trigger arguments Figure 8.40: Browser output of Listing 8.65jQuery 4 fires focus events in the standard order (blur, focusout, focus, focusin), which can break code tuned to the old sequence.

    8.9.7 Callbacks and Deferred Objects

    $.Callbacks(flags) is a list of functions fired together, the engine behind jQuery events and Deferreds. Flags combine: once, memory (late additions receive the last fired arguments), unique (no duplicates) and stopOnFalse. Lists offer add(), remove(), fire(), fireWith(), disable(), lock() and fired().$.Deferred() is jQuery's pre-ES2015 promise (Section 6.6). The owner calls resolve(), reject() or notify(); consumers attach done(), fail(), always(), progress() or then(). Hand out d.promise(), which cannot change state. $.when() works like Promise.all(), and $.ajax() returns a Deferred.

    <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>

    <pre id="log" style="margin:0"></pre>

    <script>

    const log = (msg) => $('#log').append(`${msg}\n`);

    $.Callbacks('memory').fire('Ann').add((who) => log(`late listener got ${who}`));

    const d = $.Deferred();

    d.progress((pct) => log(`progress ${pct}%`)).done((v) => log(`done(): ${v}`));

    d.then((v) => log(`then(): ${v}, asynchronously`));

    d.notify(50).resolve('data').reject('ignored');

    $.when(d, 2).done((a, b) => log(`state ${d.state()}, $.when(): ${a} and ${b}`));

    (async () => log(`await: ${await d.promise()}`))();

    </script>

    Listing 8.66: Memory callbacks, a Deferred with progress, $.when() and await Figure 8.41: Browser output of Listing 8.66done() runs synchronously, but since jQuery 3 then() follows Promises/A+: it runs asynchronously, returns a new promise and turns thrown errors into rejections. A Deferred is a thenable, so await accepts it. A settled Deferred ignores later resolve() and reject() calls. The slim build omits both modules; use native Promises.

    8.9.8 AJAX

    $.ajax(url, settings) wraps XMLHttpRequest (Section 9.2.1) and returns a jqXHR, a Deferred with done(), fail() and always(). Key settings are method, data (objects are encoded by $.param()), dataType (json, html, text, script), headers, timeout and statusCode; $.ajaxSetup() sets defaults. The shorthands $.get(), $.post(), $.getJSON(), $.getScript() and $(el).load('page.html #part') cover common cases. Handlers for ajaxStart, ajaxSend, ajaxSuccess, ajaxError, ajaxComplete and ajaxStop on document observe every request, which suits a global spinner. jQuery 4 sends FormData bodies without extra settings. The demo serves JSON from a Blob URL, so it needs no server:

    <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>

    <form hidden><input name="q" value="desk lamp"><input name="tag" value="led"></form>

    <pre id="log" style="margin:0"></pre>

    <script>

    const log = (msg) => $('#log').append(document.createTextNode(`${msg}\n`));

    const url = URL.createObjectURL(new Blob([JSON.stringify([{ name: 'Desk lamp' }])]));

    $(document).on('ajaxStart', () => log('ajaxStart: show spinner'))

    .on('ajaxStop', () => log('ajaxStop: hide spinner'));

    log(`serialize(): ${$('form').serialize()}; $.param(): ${$.param({ ids: [1, 2] })}`);

    $.ajax(url, { dataType: 'json', timeout: 5000 })

    .done((items, status, xhr) => log(`done: ${items[0].name}, ${status}, HTTP ${xhr.status}`));

    $.get(`${url}-missing`).fail((xhr, status) => log(`fail: ${status}, HTTP ${xhr.status}`));

    </script>

    Listing 8.67: A JSON request, a failing request, global handlers and serialization Figure 8.42: Browser output of Listing 8.67.load() without a selector runs the fragment's scripts, so load only trusted pages.

    8.9.9 Global $ Functions

    Utilities hang directly off $ (an alias of jQuery): $.extend([deep], target, ...sources), $.each(), $.map(), $.grep(), $.merge(), $.inArray(), $.uniqueSort(), $.isPlainObject(), $.isEmptyObject(), $.contains(), $.parseHTML(), $.parseXML(), $.escapeSelector(), $.data(el, key, value), $.fn.extend() for plugins and $.noConflict() to release the $ name.

    <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>

    <pre id="log" style="margin:0"></pre>

    <script>

    const jq = $.noConflict(); // $ is free for another library; jq and jQuery still work

    jq.fn.extend({ highlight(color = 'yellow') { return this.css('background', color); } });

    const log = (msg) => jq('#log').highlight('#e3f2fd').append(document.createTextNode(`${msg}\n`));

    const opts = jq.extend(true, {}, { ui: { theme: 'light', size: 2 } }, { ui: { theme: 'dark' } });

    log(`deep extend: ${JSON.stringify(opts)}`);

    log(`grep: ${jq.grep([5, 12, 8], (n) => n > 6)}, map: ${jq.map([1, [2, 3]], (n) => n)}`);

    const nodes = jq.parseHTML('<b>Hi</b> there<script>alert(1)<\/script>');

    log(`parseHTML: ${nodes.length} nodes (script dropped), escaped: ${jq.escapeSelector('#a.b')}`);

    </script>

    Listing 8.68: Plugins, deep extend, noConflict and parsing helpers Figure 8.43: Browser output of Listing 8.68Native replacements exist for most of them: structuredClone(), Array.prototype methods, Node.contains(), and Function.prototype.bind() for the deprecated $.proxy().

    8.9.10 jQuery-to-Vanilla-JS Equivalents

    Every jQuery idiom has a native counterpart. When porting, remember that querySelectorAll() returns a static NodeList whose methods do not apply to every element (loop with forEach), and querySelector() returns null rather than an empty set, so guard with optional chaining: el?.remove(). jQuery Vanilla JavaScript $('.a'), $el.find('.a') document.querySelectorAll('.a'), el.querySelectorAll('.a') $el.closest('li'), $el.parent() el.closest('li'), el.parentElement $el.next(), $el.is('.on') el.nextElementSibling, el.matches('.on') $el.each(fn) list.forEach(fn) $el.append(html) el.insertAdjacentHTML('beforeend', html) .before(), .after(), .remove() Same names on Element (Section 8.4.4) $el.empty(), $el.clone() el.replaceChildren(), el.cloneNode(true) $el.text(), $el.html(), $el.val() el.textContent, el.innerHTML, el.value $el.attr(n, v), $el.data('k') el.setAttribute(n, v), el.dataset.k $el.addClass('a'), .toggleClass('a') el.classList.add('a'), .toggle('a') $el.css('color', 'red'), .css('color') el.style.color = 'red', getComputedStyle(el).color $el.outerWidth(), $el.offset() el.offsetWidth, el.getBoundingClientRect() $el.hide(), $el.fadeIn(400) el.hidden = true, el.animate([{ opacity: 0 }, { opacity: 1 }], 400) $el.on('click', fn), .one('click', fn) el.addEventListener('click', fn), plus { once: true } $ul.on('click', 'li', fn) Listener on ul checking e.target.closest('li') $el.trigger('saved', data) el.dispatchEvent(new CustomEvent('saved', { detail, bubbles: true })) $(fn) <script defer> or DOMContentLoaded $.getJSON(url) fetch(url).then((r) => r.json()) (Section 9.2.2) $form.serialize(), $.param(obj) new URLSearchParams(new FormData(form)), new URLSearchParams(obj) $.Deferred(), $.when(a, b) Promise.withResolvers(), Promise.all([a, b]) $.extend(true, {}, obj) structuredClone(obj) Table 8.14: jQuery idioms and their native replacements Tip: Port a large codebase file by file. The ESLint plugin eslint-plugin-no-jquery (https://github.com/wiki/shared/books/front-end/eslint-plugin-no-jquery) (MIT) flags each remaining call, and You Might Not Need jQuery (https://youmightnotneedjquery.com/) shows more side-by-side snippets.

    8.10 Lightweight DOM Libraries

    Between hand-written DOM code (Sections 8.2-8.8) and a full framework (Section 1.12) sits a group of small libraries that each solve one problem well, all usable from a plain <script> tag. The sizes below were measured from each project's minified CDN build with gzip -9 in September 2026. Library Model Gzipped Licence Repository jQuery 4.0.0 Imperative wrapper 27.4 KB MIT github.com/jquery/jquery Cash 8.1.5 jQuery-style subset 6.3 KB MIT github.com/fabiospampinato/cash Alpine.js 3.17.3 Reactive attributes 19.9 KB MIT github.com/alpinejs/alpine htmx 2.0.10 HTML from the server 16.5 KB 0BSD github.com/bigskysoftware/htmx Lit 3.3.3 (core) Web components 6.1 KB BSD-3-Clause github.com/lit/lit Preact 10.29.8 Virtual DOM, React API 4.8 KB MIT github.com/preactjs/preact Table 8.15: Lightweight DOM libraries compared with jQuery Cash keeps jQuery's chainable $() API for selection, classes, attributes, CSS, events (with delegation) and .serialize(), but its build contains no effects, AJAX or Deferreds (its README says full parity is not a goal): use CSS transitions, fetch() and Promises instead. To shrink a legacy site, swap the script tag, run the pages, and replace the missing calls with the native equivalents from Section 8.9.10. Install with npm install cash-dom; the latest npm release, 8.1.5, dates from April 2023.

    <script src="https://cdn.jsdelivr.net/npm/cash-dom@8.1.5/dist/cash.min.js"></script>

    <ul id="list" style="font:16px sans-serif; margin:0"><li>Desk lamp</li><li>Clip light</li></ul>

    <pre id="log" style="margin:0"></pre>

    <script>

    $('#list').on('click', 'li', (e) => $(e.target).toggleClass('picked').css('color', '#2e7d32'))

    .append('<li>Floor lamp</li>');

    $('#list li').last().trigger('click');

    $('#log').text(`picked: ${$('.picked').text()}, items: ${$('#list li').length}\n` +

    `fadeIn: ${typeof $.fn.fadeIn}, ajax: ${typeof $.ajax}, Deferred: ${typeof $.Deferred}`);

    </script>

    Listing 8.69: jQuery-style delegation and chaining with Cash Figure 8.44: Browser output of Listing 8.69Both put behavior in HTML attributes; Section 2.14 builds a complete example with each. Alpine.js keeps state on the client: x-data objects become reactive proxies built on @vue/reactivity, its only dependency, and a MutationObserver initializes components added later. htmx keeps state on the server: attributes such as hx-get and hx-swap fetch HTML fragments and swap them into the page. They combine well, htmx for data and Alpine.js for local UI state such as open menus.Both render components (Section 1.12 compares them with larger frameworks). Lit builds standard custom elements (Section 2.7): an ` html... tagged template is parsed once into a <template>, and later renders update only the ${} holes, with no virtual DOM. Preact implements React's component and hooks API over a small virtual DOM; preact/compat` lets most React libraries run unchanged.Pick htmx or Alpine.js to enhance server-rendered pages, Cash to slim down jQuery code, Lit for widgets that must work inside any framework, and Preact when you want React's model and ecosystem at a fraction of the size.