The DOM
Mental model: The DOM is not your HTML. It is a live tree of objects the browser built from your HTML, and every change you make to it is visible immediately.
Level: intermediate · about 16 minutes
You write <ul><li>Tea</li></ul> and ship it. The browser reads that text once, builds a tree of objects from it, and then throws the text away. From that moment on the tree is the truth. document is its root handle, and every method in this lesson is a way of asking that tree a question.
const doc = {
tag: 'body',
children: [
{ tag: 'h1', text: 'Shop', children: [] },
{ tag: 'ul', text: '', children: [
{ tag: 'li', text: 'Tea', children: [] },
{ tag: 'li', text: 'Coffee', children: [] },
] },
],
};
function walk(node, depth = 0) {
console.log(' '.repeat(depth) + node.tag + (node.text ? ': ' + node.text : ''));
for (const child of node.children) walk(child, depth + 1);
}
walk(doc);
// -> body
// -> h1: Shop
// -> ul
// -> li: Tea
// -> li: CoffeeA tree of plain objects, so you can see the shape before you meet the API.
The real DOM is that, with more node types and a much larger API. The DOM (Document Object Model) is the browser tree of node objects plus the methods for reading and changing it. It is not part of JavaScript. It is a set of host objects the browser hands to the language, which is why none of it exists in Node.
document
|
+-- html (Element)
|
+-- head (Element)
| +-- title (Element)
| +-- "Shop" (Text)
|
+-- body (Element)
+-- "\n " (Text, from indentation)
+-- h1 (Element)
| +-- "Shop" (Text)
+-- ul (Element)
+-- li (Element) --> "Tea" (Text)
+-- li (Element) --> "Coffee" (Text)
Nodes versus elements
A node is anything in the tree: an element, a run of text, a comment, the document itself. An element is the subset of nodes that came from a tag. The distinction only bites you when you use an API that counts nodes where you expected it to count elements.
nodeType | Name | Comes from | Is an element? |
|---|---|---|---|
| 1 | Element | a tag such as <li> | yes |
| 3 | Text | any characters, including the newline after a tag | no |
| 8 | Comment | <!-- ... --> | no |
| 9 | Document | the document object | no |
| 11 | DocumentFragment | document.createDocumentFragment() | no |
if (typeof document === 'undefined') {
console.log('No document here (this runs in Node), so here is what a browser prints:');
console.log('childNodes.length -> 5 (2 elements + 3 whitespace text nodes)');
console.log('children.length -> 2 (elements only)');
} else {
const ul = document.createElement('ul');
ul.innerHTML = '\n <li>Tea</li>\n <li>Coffee</li>\n';
console.log('childNodes.length ->', ul.childNodes.length); // -> 5
console.log('children.length ->', ul.children.length); // -> 2
console.log('firstChild ->', ul.firstChild.nodeType); // -> 3 (a Text node)
console.log('firstElementChild ->', ul.firstElementChild.tagName); // -> LI
}Prettified HTML puts text nodes between your elements. Run this in the browser to see the gap.
Finding things
document.querySelector('.price'); // first match, or null
document.querySelectorAll('li.done'); // a static NodeList of all matches
const list = document.querySelector('#cart');
list.querySelector('button.remove'); // scoped: searches inside list only
document.getElementById('cart'); // fastest, but id only
document.getElementsByClassName('done'); // live HTMLCollection
document.getElementsByTagName('li'); // live HTMLCollectionTwo methods cover almost every case. Both take any CSS selector.
| Call | Returns | Missing match | Live? |
|---|---|---|---|
querySelector(sel) | one Element | null | not a collection |
querySelectorAll(sel) | NodeList | empty NodeList | no, a snapshot |
getElementById(id) | one Element | null | not a collection |
getElementsByClassName(c) | HTMLCollection | empty collection | yes |
getElementsByTagName(t) | HTMLCollection | empty collection | yes |
el.closest(sel) | nearest ancestor (or el) | null | not a collection |
el.matches(sel) | boolean | false | not a collection |
Live versus static collections
// <ul id="list"><li>a</li></ul>
const live = document.getElementsByTagName('li');
const stat = document.querySelectorAll('li');
document.querySelector('#list').append(document.createElement('li'));
console.log(live.length, stat.length);An HTMLCollection from getElementsBy* is a live view: it re-queries the tree whenever you read it, so it sees the new <li>. A NodeList from querySelectorAll is a snapshot taken at call time and never updates. So 2 1.
Live collection, infinite loop
const items = document
.getElementsByClassName('done');
// items shrinks as you remove,
// so the index outruns it and
// half the elements survive
for (let i = 0; i < items.length; i++) {
items[i].remove();
}
Static snapshot, predictable
const items = document
.querySelectorAll('.done');
// the list was fixed the moment
// you asked for it
for (const item of items) {
item.remove();
}If you must iterate a live collection while changing it, copy it first with [...items]. Live collections are useful when you deliberately want a self-updating count, for example a status line showing how many items are still open.
`NodeList`- has
forEach, but nomaporfilter. Spread it:[...nodes] `HTMLCollection`- no iteration methods at all, and live. Spread it before you loop
both- are array-like, not arrays.
Array.isArray(nodes)isfalse
Walking the tree
el.parentNode; el.parentElement;
el.childNodes; el.children;
el.firstChild; el.firstElementChild;
el.lastChild; el.lastElementChild;
el.nextSibling; el.nextElementSibling;
el.previousSibling; el.previousElementSibling;
el.closest('form'); // upward, matching a selector
el.contains(other); // is other inside el (or el itself)?Two parallel families of properties: one counts every node, one counts only elements.
Attributes versus properties
The HTML attribute is the initial value written in the markup. The DOM property is the current state of the object. They are set up to look identical, and then they drift apart the second a user types.
input.getAttribute('value'); // -> 'tea' the markup, unchanged
input.value; // -> 'flour' the live state
input.setAttribute('value', 'salt');
input.value; // -> 'flour' still, the property has taken over
input.value = 'sugar'; // this is what you want, almost alwaysThe user types "flour" into an input whose markup said value="tea".
| Case | Attribute | Property | Notes |
|---|---|---|---|
value on an input | the default | the live value | use the property |
checked on a checkbox | the default | the live state | use the property |
class | getAttribute('class') | className / classList | use classList |
href on a link | the raw text /about | the absolute URL | they differ on purpose |
data-* | getAttribute('data-id') | dataset.id | use dataset |
disabled | present or absent | true / false | setting ="false" still disables |
dataset and classList
// <li data-product-id="42" data-in-stock="true">Tea</li>
li.dataset.productId; // -> '42' always a string
li.dataset.inStock; // -> 'true' also a string, so JSON.parse or compare
Number(li.dataset.productId); // -> 42
li.dataset.qty = 3; // writes data-qty="3"
delete li.dataset.qty; // removes the attributedata-* attributes are the sanctioned place to hang your own values on an element.
function toDataset(attrs) {
const out = {};
for (const [name, value] of Object.entries(attrs)) {
if (!name.startsWith('data-')) continue;
const key = name
.slice(5)
.replace(/-([a-z0-9])/g, (_, ch) => ch.toUpperCase());
out[key] = value;
}
return out;
}
console.log(toDataset({ 'data-product-id': '42', 'data-in-stock': 'true', class: 'row' }));
// -> { productId: '42', inStock: 'true' }The dash-to-camelCase rule dataset uses, written out. This runs anywhere.
el.classList.add('active', 'visible');
el.classList.remove('pending');
el.classList.toggle('open'); // returns the new state
el.classList.toggle('open', isOpen); // force it, no guessing
el.classList.replace('sm', 'lg');
el.classList.contains('active'); // -> true / false
el.className = 'active'; // wipes every other class, avoidclassList is a small API you will use every day.
Inline style versus classes
Inline styles
el.style.backgroundColor = 'red';
el.style.display = 'none';
// camelCase property names
// wins the specificity fight
// with your stylesheet
// scattered across your JS
A class the CSS owns
el.classList.toggle('is-error', bad);
el.hidden = true;
/* .is-error { background: red } */
// design stays in the CSS
// JS only decides stateSet state in JavaScript, appearance in CSS. Inline styles are the right tool for values only the runtime knows, such as a computed pixel position or a percentage on a progress bar, and for CSS custom properties: el.style.setProperty('--x', x + 'px').
Try it yourself
Query a tree by hand
const tree = {
tag: 'main', attrs: {}, children: [
{ tag: 'h1', attrs: {}, children: [] },
{ tag: 'ul', attrs: { class: 'cart' }, children: [
{ tag: 'li', attrs: { 'data-id': '1' }, children: [] },
{ tag: 'li', attrs: { 'data-id': '2' }, children: [
{ tag: 'button', attrs: { class: 'remove' }, children: [] },
] },
] },
],
};
function select(node, tag, found = []) {
if (node.tag === tag) found.push(node);
for (const child of node.children) select(child, tag, found);
return found;
}
console.log(select(tree, 'li').map((n) => n.attrs['data-id'])); // -> [ '1', '2' ]
console.log(select(tree, 'button').length); // -> 1
console.log(select(tree, 'table').length); // -> 0
Add a depth to each result. Then make select accept .classname as well as a tag name.
Implement closest
const root = { tag: 'section', parent: null, attrs: { 'data-panel': 'cart' } };
const row = { tag: 'li', parent: root, attrs: { 'data-id': '7' } };
const button = { tag: 'button', parent: row, attrs: {} };
function closest(node, attr) {
let current = node;
while (current) {
if (attr in current.attrs) return current;
current = current.parent;
}
return null;
}
console.log(closest(button, 'data-id').attrs); // -> { 'data-id': '7' }
console.log(closest(button, 'data-panel').tag); // -> section
console.log(closest(button, 'data-missing')); // -> null
Add a matches argument so it can look for a class instead of a data attribute. Then handle the case where nothing matches.
Exercises
querySelectorAll by hand
A node is { tag, attrs, children }. Write selectAll(root, tag) returning every node whose tag matches, in document order (a node before its own children, and children before later siblings). Include the root itself if it matches. Return [] when nothing matches.
Live collection versus snapshot
Write two functions over the same fake tree. snapshot(root, tag) returns a plain array of matches, fixed at call time. liveCount(root, tag) returns an object with a length getter that re-counts the tree every time it is read. Adding a node afterwards must change liveCount(...).length but must not change the array from snapshot.
Check yourself
- What does
ul.children.lengthreport for<ul>\n <li>a</li>\n <li>b</li>\n</ul>? - 2 —
childrenholds elements only, so it is 2.childNodeswould be 5, because the three runs of whitespace between and around the list items areTextnodes. - A checkbox is rendered as
<input type="checkbox" checked>. The user unticks it. What do you get? getAttribute('checked')is''and.checkedisfalse— The attribute is the initial value from the markup and does not move when the user interacts, so it is still present (an empty string). The property tracks live state, so it isfalse. Read the property whenever you want to know what is true right now.- What is logged?
1then2—getElementsByClassNamereturns a liveHTMLCollection. Reading.lengthre-evaluates the query against the current tree, so the second read includes the div that was just given the class.querySelectorAllwould have printed1twice.- Which is the safest way to find the row a clicked button belongs to?
btn.closest('[data-row-id]')—closestwalks upward until a node matches the selector, so it keeps working when someone adds a wrapper<div>or reorders the markup. CountingparentElementhops encodes today's HTML structure into your JavaScript.
Common mistakes
- Expecting
firstChildto be an element. In formatted HTML it is nearly always a whitespace text node. - Looping forward over a live
HTMLCollectionwhile removing elements, so you silently skip half of them. - Calling
.mapon aNodeList. It hasforEachbut nothing else. Spread it first. - Reading
getAttribute('value')and wondering why it ignores what the user typed. - Setting
disabled="false"withsetAttribute, which disables the control. - Assuming a
querySelectortypo will throw. It returnsnulland fails one line later.
Takeaways
- The DOM is a live tree of objects built from your HTML, not the HTML text itself.
- Every element is a node, but text and comments are nodes too. Prefer the element-flavoured properties.
querySelectorandquerySelectorAllcover almost everything, and the latter is a static snapshot.getElementsBy*returns live collections that change under your loop. Copy with spread before mutating.- Attributes are the markup defaults, properties are the current state. Read the property.
- Use
datasetfor your own data,classListfor state, and CSS for appearance.