The DOM: What the Browser Builds From Your Markup

Your file is text. The DOM is the live tree the browser built from it, and they are not the same thing. Knowing the difference is what makes debugging possible.

Concept

The Document Object Model is the browser representation of your page: a tree of objects, one per element, that JavaScript can read and change.

The single most useful thing to understand about it is that the DOM is not your file. The file is text on disk. The DOM is what the parser built from it - after repairing errors, after inserting implied elements, and after every script has finished modifying it.

The tree

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Courses</title>
</head>
<body>
  <h1>Courses</h1>
  <p>We offer <strong>three</strong> courses.</p>
</body>
</html>
document
└── html                     (lang="en")
    ├── head
    │   └── title
    │       └── "Courses"
    └── body
        ├── h1
        │   └── "Courses"
        └── p
            ├── "We offer "
            ├── strong
            │   └── "three"
            └── " courses."

Every element is a node. So is every run of text, and so is every comment. Which is why childNodes often returns more than you expect - the whitespace between tags is text nodes too.

document.body.childNodes.length;   // includes whitespace text nodes
document.body.children.length;     // elements only - usually what you want

Where the DOM differs from your file

The parser repairs errors

<p>Total: <div>1,200</div></p>

A paragraph cannot contain a div, so the parser closes the paragraph first, leaves the div as a sibling, and discards the orphaned closing tag. Three nodes, none of them what you wrote.

Implied elements are inserted

Loose table rows get a tbody around them. A missing head or body is invented. These appear in the DOM without appearing in your file.

Scripts change it

Anything a script adds, removes or edits is in the DOM and not in the file.

The practical consequence: View source shows the file the server sent. The Elements panel shows the live DOM. When they disagree, the Elements panel is the truth, and debugging against view source is why some bugs seem impossible.

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

p.parentElement;          // the containing element
p.children;               // element children only
p.firstElementChild;
p.lastElementChild;
p.nextElementSibling;
p.previousElementSibling;
p.closest("article");     // nearest ancestor matching a selector
p.matches(".lead");       // does this element match?
p.contains(other);        // is other inside p?

closest is the one to remember. It walks up from an element until it finds a match, and it is what makes event delegation clean.

Reading and writing content

el.textContent      // all text, including hidden elements. Safe.
el.innerText        // rendered text only, respects display:none. Triggers layout.
el.innerHTML        // markup as a string. Parses anything you assign.
el.outerHTML        // including the element itself

The distinction matters for security:

const name = getUserInput();

el.textContent = name;   // inserted as text, always safe
el.innerHTML   = name;   // parsed as markup - an injection point

Assigning untrusted input to innerHTML is the standard cross site scripting vector. textContent is both safer and faster; use it unless you are deliberately inserting markup you control.

Attributes and properties

// attributes: what is in the markup
el.getAttribute("value");
el.setAttribute("value", "abc");
el.hasAttribute("required");
el.removeAttribute("disabled");

// properties: the live state of the object
input.value;        // what the user has typed now
input.checked;
input.disabled;

// classes
el.classList.add("is-open");
el.classList.remove("is-open");
el.classList.toggle("is-open");
el.classList.contains("is-open");

// data attributes
el.dataset.status;

The difference bites on form fields. getAttribute("value") returns the value written in the markup - the initial value. input.value returns what the reader has typed. They diverge the moment anyone types anything.

Creating and inserting

const item = document.createElement("li");
item.textContent = "Design";
item.className = "course";

list.append(item);            // at the end
list.prepend(item);           // at the start
item.before(other);           // as a preceding sibling
item.after(other);            // as a following sibling
item.replaceWith(other);
item.remove();

list.replaceChildren(a, b, c);  // replace all children at once

Building many nodes into a fragment first means one insertion and one layout pass rather than one per node:

const fragment = document.createDocumentFragment();
for (const course of courses) {
  const li = document.createElement("li");
  li.textContent = course.title;
  fragment.append(li);
}
list.replaceChildren(fragment);

Reflow and repaint

Changing the DOM can force the browser to recalculate layout. Reading a layout value immediately after writing one forces it synchronously, and doing that inside a loop is a well known performance trap:

// forces layout on every iteration
items.forEach((el) => {
  el.style.width = el.offsetWidth + 10 + "px";
});

// read everything, then write everything
const widths = items.map((el) => el.offsetWidth);
items.forEach((el, i) => { el.style.width = widths[i] + 10 + "px"; });

Important rules

  • The DOM is built from the parsed result, not from your source text.
  • childNodes includes text and comment nodes; children does not.
  • textContent is safe; innerHTML parses markup.
  • Attributes are the markup; properties are the live state.
  • A script cannot reach an element that has not been parsed yet - which is what defer solves.
  • Every DOM change is a potential layout and paint.

Common mistakes

  • Debugging against view source instead of the Elements panel.
  • Assigning user input to innerHTML.
  • Using getAttribute("value") to read what someone typed.
  • Iterating childNodes and tripping over whitespace text nodes.
  • Using innerHTML +=, which reparses the container and destroys event listeners.
  • Inserting nodes one at a time in a loop.
  • Interleaving reads and writes of layout values.

Best practices

  • Use textContent for text and createElement or template for structure.
  • Use children, not childNodes.
  • Use closest for walking upwards.
  • Batch insertions with a fragment.
  • Read layout values before writing, never alternately.
  • Trust the Elements panel over the page source.

Practice

  1. Write <p>Total: <div>1,200</div></p>, then compare view source with the Elements panel and draw both trees.
  2. Log childNodes.length and children.length for a container with indented markup, and explain the difference.
  3. Set a value containing markup with textContent and then with innerHTML.
  4. Insert two hundred list items one at a time and then via a fragment, and compare the timings.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →
HTML

Forms and JavaScript

Read values, submit without a page reload, validate with the built in API, and handle errors accessibly. All of it built on a real form element.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.