Skip to content
academia.sh

Lesson 01 / 24

The DOM API

The interface that reads and modifies the document tree; the node-versus-element distinction, the difference between a live collection and a static list, the attribute-property duality, and the cost of batch changes.

Contents

The Web Fundamentals and HTML course built a document: the North Slope Measurement Station page, a sectioned structure, a measurement table, and an accessible submission form. The Visual Presentation with CSS course gave that document style. Across both courses, the page was static: it stood exactly as it arrived from the server, and nothing the user did changed it.

This course adds behavior to the page. Behavior’s first condition is being able to read and modify the document from inside a program. The Markup Language Concept lesson showed how the tree is built from the source text; in this lesson, the structure built there becomes a data structure with an interface.

The Tree Is an Object Graph

Every node the parser produces is an object the program can reach. Objects are linked to each other through parent, child, and sibling bonds; every point in the tree can be reached through these bonds.

The first trap is hidden in what these bonds carry. An element’s children are not only elements: the whitespace between tags also enters the tree as a text node. For this reason, the API offers two separate ways to walk it — one that sees every node, another that sees only element nodes.

// nodes.mjs — the difference between child nodes and child elements
const text = (value) => ({ type: "text", value, children: [] });
const element = (name, attrs = {}, ...content) => {
  const node = { type: "element", name, attrs, children: [], parent: null };
  for (const part of content) {
    const child = typeof part === "string" ? text(part) : part;
    child.parent = node;
    node.children.push(child);
  }
  return node;
};

// tree equivalent of the measurement list from K01:
// <ul id="measurements">
//   <li class="measurement">Temperature</li>
//   <li class="measurement">Relative humidity</li>
// </ul>
const list = element("ul", { id: "measurements" },
  "\n  ",
  element("li", { class: "measurement" }, "Temperature"),
  "\n  ",
  element("li", { class: "measurement" }, "Relative humidity"),
  "\n");

const childNodes = (d) => d.children;
const childElements = (d) => d.children.filter((c) => c.type === "element");

console.log("child node count   :", childNodes(list).length);
console.log("child element count:", childElements(list).length);
console.log("first child node   :", JSON.stringify(childNodes(list)[0].value));
console.log("first child element:", childElements(list)[0].name);
child node count   : 5
child element count: 2
first child node   : "\n  "
first child element: li

There are three text nodes between the two li elements, and all three consist only of a line break and indentation. In the browser API, this distinction is met by the pairs childNodes versus children, firstChild versus firstElementChild, nextSibling versus nextElementSibling. The first column of each pair sees every node, the second sees only elements.

Code that walks a document starting from the first node can break when indentation in the markup changes. This is the source of the fragility, and the reason for the selector-based access covered in the next section.

Selecting a Node

Instead of walking the tree by hand, the node being looked for is described with a selector. The syntax defined in the Selectors lesson of the Visual Presentation with CSS course applies here exactly as it is: the language that determines which elements style rules apply to also determines which elements a program can reach.

There are two basic operations. One returns the first matching element, the other all of them. Both start from a root: a search can be run over the whole document, or over a specific element’s subtree. Searching a subtree excludes the same selector’s matches elsewhere on the page, which is why it is the form preferred by code that runs inside a component’s boundaries.

There is also access by id. Because an id is unique within a document, this access is read directly from an index rather than scanning the tree; but it rests on the uniqueness assumption. If the same id is written twice, the first is returned and no error is raised.

Live Collection and Static List

What a selection operation returns can be one of two different kinds, and the difference determines what the code sees after the tree changes.

// collection.mjs — the difference between a live collection and a static list
const element = (name, attrs = {}, ...content) => {
  const node = { type: "element", name, attrs, children: [], parent: null };
  for (const part of content) {
    const child = typeof part === "string" ? { type: "text", value: part, children: [] } : part;
    child.parent = node;
    node.children.push(child);
  }
  return node;
};

// Walks every element in the tree in pre-order.
function* elements(root) {
  if (root.type === "element") yield root;
  for (const child of root.children) yield* elements(child);
}

const matches = (o, selector) => {
  if (selector.startsWith(".")) return (o.attrs.class ?? "").split(/\s+/).includes(selector.slice(1));
  if (selector.startsWith("#")) return o.attrs.id === selector.slice(1);
  return o.name === selector;
};

// Static list: copies the matches at call time into an array.
const selectAll = (root, selector) => [...elements(root)].filter((o) => matches(o, selector));

// Live collection: rescans the tree on every read.
const liveCollection = (root, selector) => ({
  get length() { return selectAll(root, selector).length; },
});

const list = element("ul", { id: "measurements" },
  element("li", { class: "measurement" }, "Temperature"),
  element("li", { class: "measurement" }, "Relative humidity"));

const staticList = selectAll(list, "li");
const live = liveCollection(list, "li");
console.log("before insertion — static:", staticList.length, "live:", live.length);

const item = element("li", { class: "measurement" }, "Wind speed");
item.parent = list;
list.children.push(item);

console.log("after insertion  — static:", staticList.length, "live:", live.length);
before insertion — static: 2 live: 2
after insertion  — static: 2 live: 3

A static list is a copy of the matches at the moment it was called; even if the tree changes afterward, the list does not change. A live collection, on the other hand, is the query itself: every time it is read, it gives the matches at that moment.

In the browser API, querySelectorAll returns a static result, getElementsByTagName and getElementsByClassName a live one. The distinction matters in two situations. First, adding or removing an element inside the body of a loop iterating over a live collection changes the collection’s length while the loop is running; an infinite loop originates here. Second, holding onto a static list for a long time keeps nodes removed from the tree in memory — this is this course’s counterpart to the accessibility problem defined in the Memory Leak Diagnosis lesson of the Asynchronous JavaScript and Runtime course.

Attribute and Property

An element’s href="..." declaration written in the markup and its href value on the program side are not the same thing. The first is called the attribute, the second the property. The attribute is the string the parser reads from the source text; the property is a value derived from that string, one that can change at runtime.

// attribute.mjs — the distinction between the attribute string and the property value
const BASE = "https://data.example.test/station/north-slope/";

// Attributes are always strings; properties are resolved, live values.
const PROPERTY = {
  a: (o) => ({ href: new URL(o.attrs.href, BASE).href }),
  "input[text]": (o) => ({
    defaultValue: o.attrs.value,           // read from the attribute
    value: o.enteredValue ?? o.attrs.value, // the value the user has entered
  }),
  "input[checkbox]": (o) => ({
    defaultChecked: "checked" in o.attrs,
    checked: o.marked ?? "checked" in o.attrs,
  }),
};

const elements = [
  ["a", { attrs: { href: "../log?type=temperature" } }],
  ["input[text]", { attrs: { name: "value", value: "-4.2" }, enteredValue: "-6.8" }],
  ["input[checkbox]", { attrs: { name: "verified", checked: "" }, marked: false }],
];

for (const [type, o] of elements) {
  console.log(type);
  console.log("  attribute:", JSON.stringify(o.attrs));
  console.log("  property :", JSON.stringify(PROPERTY[type](o)));
}
a
  attribute: {"href":"../log?type=temperature"}
  property : {"href":"https://data.example.test/station/log?type=temperature"}
input[text]
  attribute: {"name":"value","value":"-4.2"}
  property : {"defaultValue":"-4.2","value":"-6.8"}
input[checkbox]
  attribute: {"name":"verified","checked":""}
  property : {"defaultChecked":true,"checked":false}

Three forms of divergence are visible. On the link, the attribute is a relative address, while the property is the absolute address resolved against the base address defined in the Links lesson. On the text field, the attribute carries the starting value and does not change as the user types; the current value lives only in the property. On the checkbox, the attribute only announces the checked state at page load; when the user unchecks the box, the attribute stays as it was, the property becomes false.

A practical rule follows from this: state the user can change is read from the property, the starting state the document declares from the attribute. Code that reads a form field’s current value from the attribute will never see what the user typed.

The data- attributes defined in the Data Attributes lesson sit outside this duality: because they have no defined property counterpart, they stay strings in both directions.

Creating and Placing Nodes

New content can be produced two ways. The first is building the node piece by piece: an element is created, its attributes are written, a text node is added, then it is placed into its spot in the tree. The second is giving a markup string and having the parser build the tree.

The second way is shorter to write and has a cost: if a value coming from the user gets mixed into the string, the < character in that value is interpreted as a tag. When the escaping rule defined in the Tags, Attributes, and Entities lesson is not enforced here, data turns into code. For this reason, content carrying user data is added as a text node; a < character written into a text node never becomes a tag under any condition.

Placement operations share a common property: a node can have only one place in the tree. When a node that is already connected is added somewhere else, it is not copied, it is moved; it comes out of its old place on its own.

The Cost of Batch Changes

Changing a node connected to the tree invalidates the relevant part of the style and layout calculation. Adding a hundred-item list one at a time triggers this invalidation a hundred times. The solution is preparing the nodes in a container not connected to the document and connecting them all at once. This container is called a document fragment.

// fragment.mjs — the difference between inserting one by one and inserting via a document fragment
const element = (name, attrs = {}) => ({ type: "element", name, attrs, children: [], parent: null });

let invalidations = 0; // every change made in a tree attached to the document increments this

function isConnected(node) {
  let ancestor = node;
  while (ancestor.parent) ancestor = ancestor.parent;
  return ancestor.type === "document";
}

// One call = one tree change.
function append(target, ...nodes) {
  for (const d of nodes) { d.parent = target; target.children.push(d); }
  if (isConnected(target)) invalidations += 1;
}

const document_ = { type: "document", name: "#document", children: [], parent: null };
const list = element("ul", { id: "measurements" });
append(document_, list);

const records = ["Temperature", "Relative humidity", "Wind speed", "Precipitation", "Pressure"];

// First way: every item is added to the connected tree with a separate call.
invalidations = 0;
for (const name of records) append(list, element("li", { "data-measurement": name }));
console.log("direct insertion:", invalidations, "invalidations,", list.children.length, "children");

// Second way: items are first collected in an unconnected fragment, then added with one call.
list.children.length = 0;
invalidations = 0;
const fragment = { type: "fragment", name: "#fragment", children: [], parent: null };
for (const name of records) append(fragment, element("li", { "data-measurement": name }));
append(list, ...fragment.children.splice(0));
console.log("fragment insertion:", invalidations, "invalidations,", list.children.length, "children");
console.log("remaining in fragment:", fragment.children.length, "children");
direct insertion: 5 invalidations, 5 children
fragment insertion: 1 invalidations, 5 children
remaining in fragment: 0 children

Two properties of the document fragment appear in the output. First, because additions made to the fragment are not connected to the document, they produce no invalidation. Second, when the fragment is added to the tree, it does not enter the tree itself; it only hands off its children and empties out. The zero on the last line shows this.

The number here is not a performance measurement, it is a call count. How large the real cost is depends on the document and the style; measuring it is the subject of the Performance Logging lesson. What is constant is that the number of computations triggered drops from five to one.

Summary

  • The document tree is an object graph linked by parent-child-sibling bonds; part of these bonds sees every node, part sees only elements.
  • Indentation and line breaks between tags enter the tree as text nodes; code that walks node bonds is sensitive to how the markup is formatted.
  • A selection operation returns either a static list or a live collection; a live collection re-queries the tree every time it is read.
  • The attribute is the string in the source text, the property is a live value derived from it; user input only ever appears in the property.
  • Every change to a connected node invalidates the style and layout calculation; batch insertion is prepared in an unconnected document fragment.

Next Step

This lesson established reading and modifying the document, but left open what triggers a change. A page does not change on its own; the moment the user clicks, types, or scrolls produces a notification, and the program responds to that notification. The path these notifications follow on the tree is defined and is not a single node: an event passes through its ancestors before reaching its target, and returns by the same path after the target. The next lesson examines this three-phase journey and the order listeners are called in.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close