MENU
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>
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.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(' > '));
HTMLInputElement > HTMLElement > Element > Node > EventTarget > Object
Text > CharacterData > Node > EventTarget > Object
HTMLDocument > Document > Node > EventTarget > Object
ShadowRoot > DocumentFragment > Node > EventTarget > Object
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 nodesNodes 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.
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`);
}
thrash: 1140.5 ms
batched: 6.1 ms
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>
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>
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>
Figure 8.7: Browser output of Listing 8.68.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]);
}
origin https://shop.example.com:8080
host shop.example.com:8080
pathname /cart/view.html
search ?id=42
hash #total
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)
{"tab":"reviews"} /product/7/reviews
popstate: /product/7 null
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>
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>
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);
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 });
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>
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
168 65
{height: 168, weight: 65}
retry: 1
retry: 2
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());
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().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.<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>
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).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);
false true null
["hidden","data-state"] new
draft edited draft
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);
true false true
true panel featured active big
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])}`);
}
});
buffered: 0.0-5.1
seekable: 0.0-5.1
played: 1.0-2.0, 3.5-3.8
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>
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.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);
}
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);
Report 3 null
true 2
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.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>
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>
Figure 8.14: Browser output of Listing 8.248.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.<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>
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>
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>
Figure 8.17: Browser output of Listing 8.278.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)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>
top: text
stack: text > body > html
caret: Hello world 7
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>
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>
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);
}
<input> HTMLInputElement
<video> HTMLVideoElement
<user-card> HTMLElement
<blink> HTMLUnknownElement
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>
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>
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>
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.<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>
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.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>
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>
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>
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'));
rock & roll ["cd","vinyl"] null 4
https://shop.example/search?q=rock+%26+roll&tag=vinyl&page=3&tag=lp+%26+more true
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
SGkh SGk= SA== Hi!
InvalidCharacterError
[226,130,172,53] 4oKsNQ== 4oKsNQ==
�5
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>
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
button8.7.1 Interfaces, Properties and Methods
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:<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>
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
8.7.4 Handling Input Events
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();
});
8.7.5 Handling Document Events
<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>
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
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 });
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>
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
input.addEventListener("paste", (e) => {
e.preventDefault();
const text = e.clipboardData.getData("text").toUpperCase();
input.setRangeText(text, input.selectionStart, input.selectionEnd, "end");
});
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"
});
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
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>
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");
1 item(s)
2 item(s)
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
});
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>
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>
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();
live list: 37.5 ms
live list + scroll: 1234.9 ms
fragment + scroll: 30.2 ms
clicked item 42
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);
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');
text node = 0
title attribute = Ada
text node = 1
title attribute = Grace
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>
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>
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>
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>
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>
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>
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
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>
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>
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>
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>
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().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.<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>
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.