Module 14 · The DOM and Events: JavaScript Touches the Page
The DOM, the Picture: The Page Is a Tree Your Code Can Change
In this lesson
- Explain what the DOM is: the tree of objects a browser builds from HTML, and draws on the screen.
- Find an element with
querySelectorand change what it says withtextContent. - Make a page answer a click with
addEventListener, and say why the script goes after the markup.
Mitu reads a recipe on her phone and taps the heart under it. The number beside the heart goes from 12 to 13 at once. The page did not reload, and no new page came from the server. Something inside the open page changed one number.
That something was JavaScript. The browser read the page's HTML once, when it loaded. Then it built a tree of objects from that HTML, and it draws the screen from the tree. A script can reach into the tree and change a node. The browser sees the change and draws the screen again. That tree is the DOM, the Document Object Model.
Up to now, every program in this track ran under Node, with no page and no screen. This module moves into the browser. Every page program here runs in the preview beside the lesson. Press Run, and the page opens in a small sandboxed frame, with its console mirrored under it. Judged problems still run under Node, so they work on the same trees written as plain objects.
The page is a tree
HTML is text. Here is a small piece of it, a shopping list:
<ul id="list">
<li>rice</li>
<li>dal</li>
</ul>
When the browser reads this text, it does not keep it as text. It builds objects. The ul becomes an element object. Each li becomes an element object too, and it is a child of the ul. The words inside, rice and dal, become text nodes, children of their li. Every node except the top one has exactly one parent.
The top of the whole tree is document. Under it sits the html element, and under that come head and body. Everything you see on a page lives somewhere under body.
A tree like this is easy to write as plain JavaScript objects. Each node is an object with a tag and a list of children. This program runs under Node, with no browser at all. It prints the tree with each child indented under its parent, the way the Elements pane of DevTools shows a page.
const page = {
tag: "body",
children: [
{ tag: "h1", children: [{ text: "Bazar list" }] },
{
tag: "ul",
children: [
{ tag: "li", children: [{ text: "rice" }] },
{ tag: "li", children: [{ text: "dal" }] },
],
},
],
};
function show(node, depth) {
const pad = " ".repeat(depth);
if (node.text !== undefined) {
console.log(pad + '"' + node.text + '"');
return;
}
console.log(pad + "<" + node.tag + ">");
for (const child of node.children) show(child, depth + 1);
}
show(page, 0);
<body>
<h1>
"Bazar list"
<ul>
<li>
"rice"
<li>
"dal"
The real DOM is the same shape with much more on each node. A browser element has its tag, its attributes, its children, its parent, its styles, and hundreds of methods. But the idea is this program's idea: nodes, children, one parent each.
A new node joins the tree
Changing a page means changing the tree. To add a third item to the list, a script makes a new li object and attaches it under the ul. That is all an insert is: the parent gets one more child, and the child learns its parent.
Watch it happen below, one step at a time, on the plain-object version of the list. Each box is an object, and each arrow is a reference from one object to another. The children array is a box of its own.
Step 3 is the one to remember. A node is in the tree only when its parent lists it as a child. Making an object is not enough. In the browser, append does both halves for you, and remove undoes both.
Find an element: querySelector
To change a node, a script first needs a reference to it. The most useful finder is document.querySelector. You give it a CSS selector, the same text a stylesheet uses, and it gives back the first element that matches, or null if none does.
"h1"finds the firsth1."#list"finds the element whoseidislist.".price"finds the first element with the classprice."#list li"finds the firstliinside#list.
The selector is a question about the tree, and querySelector walks the tree to answer it. Lesson 02 shows querySelectorAll, which returns every match.
Change what it says: textContent
Every element has a textContent property. Reading it gives all the text inside the element. Writing it replaces everything inside with one text node holding your string. The page below finds the heading and the first item, then changes both. Press Run.
<h1>Bazar list</h1>
<ul id="list">
<li>rice</li>
<li>dal</li>
</ul>
<p id="note"></p>
<script>
const title = document.querySelector("h1");
const first = document.querySelector("#list li");
console.log("before:", first.textContent);
title.textContent = "Bazar list for Friday";
first.textContent = "basmati rice";
document.querySelector("#note").textContent = "2 items, edited by the script";
</script>
Run in Compiler
The page shows the new heading and "basmati rice" as the first item. The console under the page shows the old text, rice, because the script read it before the change. The HTML in the box above never changed. Only the tree changed, and the screen follows the tree.
Listen: addEventListener
A page that only changes itself once is not much use. The interesting part is answering the reader. When the reader clicks, types or presses a key, the browser creates an event. A script can ask to be told about events on an element with addEventListener.
button.addEventListener("click", handler) says: whenever this button is clicked, call handler. The handler is a plain function, a callback, exactly like the callbacks of Module 12. The browser calls it later, once per click, on a turn of the event loop.
<p>Likes: <span id="likes">12</span></p>
<button id="heart">Like</button>
<script>
const likes = document.querySelector("#likes");
const heart = document.querySelector("#heart");
let count = 12;
heart.addEventListener("click", () => {
count = count + 1;
likes.textContent = String(count);
console.log("clicked, likes are now", count);
});
console.log("listener added");
</script>
Run in Compiler
Run it and click the button a few times. The number climbs by one per click, and the console prints a line each time. Notice that "listener added" printed first, before any click. Adding a listener does not call it. It only leaves the function with the button until a click comes.
Also notice where the count lives: in a variable, count. The page shows the count, but the script does not read it back from the page. Lesson 04 says why that habit matters.
A click travels: bubbling
A click on the button is also a click on everything around it: the p or div it sits in, the body, the whole document. The browser models this by letting the event bubble. It runs the listeners on the clicked element first, then on its parent, then on the parent's parent, up to the top.
The element that was really clicked is the event's target. Here is the route for one click, worked out under Node on a tree of plain objects. Each node knows its parent, so the route is a walk up the parent links.
const body = { tag: "body", parent: null };
const main = { tag: "main", parent: body };
const card = { tag: "div", parent: main };
const heart = { tag: "button", parent: card };
const route = [];
for (let node = heart; node !== null; node = node.parent) route.push(node.tag);
console.log("target:", heart.tag);
console.log("bubbles through:", route.join(" > "));
target: button
bubbles through: button > div > main > body
Bubbling is why one listener on a parent can hear clicks on all of its children. That trick is called delegation, and Lesson 02 builds it. For now, keep the picture: an event starts at the target and climbs.
Where the script goes
A script runs when the browser reaches it while reading the page. The pages above put the <script> at the end, after the markup. That order matters. When the script runs, the elements above it already exist in the tree, so querySelector finds them.
Put the same script at the top, before the elements, and querySelector returns null, because those nodes are not built yet. There are two common fixes. One is to keep the script at the end. The other is to wait for the DOMContentLoaded event, which fires once the whole tree is built. The first mistake below shows the bug.
The DOM at a glance
const el = document.querySelector("#list li"); // the first match, or null
el.textContent = "new text"; // replace what it says, as plain text
const item = document.createElement("li"); // a new element, not in the page yet
list.append(item); // attach it as the last child
el.addEventListener("click", (event) => { // call this on every click
console.log(event.target); // the element really clicked
});
Two buttons change one number. The number lives in a variable, and one small function draws it. Every handler changes the variable, then calls the drawing function. That keeps the page and the variable in step.
<style>
.count { font-size: 2rem; margin: 0.5rem 0; }
</style>
<p class="count" id="count">0</p>
<button id="minus">-1</button>
<button id="plus">+1</button>
<script>
let count = 0;
const shown = document.querySelector("#count");
function draw() {
shown.textContent = String(count);
}
document.querySelector("#plus").addEventListener("click", () => {
count++;
draw();
});
document.querySelector("#minus").addEventListener("click", () => {
if (count > 0) count--;
draw();
});
draw();
</script>
Click -1 first: nothing changes, because the handler refuses to go below zero. Then click +1 three times and -1 once, and the page shows 2. The rule lives in the script, not in the page.
This program runs under Node. It builds a small page as plain objects, each with a parent, and finds the bubbling route of a click on a link. It also counts how many listeners the click would reach, if some nodes have one.
function node(tag, parent, listens) {
return { tag, parent, listens };
}
const body = node("body", null, true);
const nav = node("nav", body, false);
const menu = node("ul", nav, true);
const item = node("li", menu, false);
const link = node("a", item, true);
const route = [];
let heard = 0;
for (let n = link; n !== null; n = n.parent) {
route.push(n.tag);
if (n.listens) heard++;
}
console.log(route.join(" > "));
console.log("listeners reached:", heard);
a > li > ul > nav > body
listeners reached: 3
The link's own listener runs first, then the menu's, then the body's. The nav and the li are passed through without a listener. The browser does exactly this walk for every click.
Where this is used
- Chrome DevTools: the Elements pane draws the live DOM, not the HTML file, so changes a script makes show up there at once.
- GitHub: the star button on a repository changes its count in the page, with a listener and a request, without reloading.
- Gmail: keyboard shortcuts such as
jandkarekeydownlisteners on the document that move a highlight through the DOM. - Wikipedia: the table of contents opens and closes sections through click listeners that change the tree.
Common mistakes
1. A script that runs before the elements exist
<script>
const heart = document.querySelector("#heart");
console.log("found:", heart);
heart.addEventListener("click", () => console.log("liked"));
</script>
<button id="heart">Like</button>
Run it. The console shows that heart is null, then a TypeError about reading addEventListener of null. The script ran when the browser reached it, and the button below it was not in the tree yet. Clicking the button does nothing. Move the script after the button, or wrap the code in a DOMContentLoaded listener.
2. Expecting querySelector to find them all
<ul>
<li>rice</li>
<li>dal</li>
<li>oil</li>
</ul>
<script>
const item = document.querySelector("li");
item.textContent = item.textContent.toUpperCase();
</script>
Only "RICE" changes. querySelector returns the first match and stops. To change every item, use querySelectorAll("li") and loop over the result, which Lesson 02 shows.
3. Calling the handler instead of passing it
<button id="go">Go</button>
<script>
function sayHi() {
console.log("hi");
}
document.querySelector("#go").addEventListener("click", sayHi());
</script>
The console prints "hi" once, at load, and clicks print nothing. sayHi() with brackets calls the function at once and passes its result, undefined, to addEventListener. Pass the function itself, sayHi, with no brackets.
Sumi is learning to read the Elements pane in Chrome DevTools. It draws the page as nested tags, each child indented under its parent. She has a page's tree as a list of nodes, and she wants the same picture as plain text.
Input. The first line holds n. Then n lines follow, one per node, in document order. Line i holds the parent of node i and the node's tag. Node 1 is the root and its parent is 0. Every other node's parent has a smaller number.
Output. The markup, one tag per line. Print <tag> when the walk enters a node and </tag> when it leaves, after all its children. Children come in input order. A node at depth d is indented by 2d spaces, and the root has depth 0.
Constraints. 1 <= n <= 1000; the tree is at most 50 levels deep; every tag is 1 to 10 lowercase letters.
Sample.
6
0 ul
1 li
2 a
1 li
1 li
5 span
<ul>
<li>
<a>
</a>
</li>
<li>
</li>
<li>
<span>
</span>
</li>
</ul>
const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];
// your code: read with next() and nextInt(), push every line of output to out
console.log(out.join("\n"));
Run in Compiler
Graded as tree-markup, a free problem in this module's problem set.
Farhan is testing a page by clicking things. A click starts at the element under the pointer, then bubbles up through every ancestor to the root. For each click he wants the bubbling route, as the list of tags the event passes on its way up.
Input. The first line holds n. Then n lines follow, one per node, in document order: the parent of node i and its tag. Node 1 is the root and its parent is 0. Every other node's parent has a smaller number. Then a line holds q, and q lines follow, each the node a click lands on.
Output. For each click, one line: the tags from the clicked node up to the root, separated by single spaces.
Constraints. 1 <= n <= 200; 1 <= q <= 200; every tag is 1 to 10 lowercase letters.
Sample.
7
0 body
1 header
1 main
3 ul
4 li
5 button
4 li
3
6
2
7
button li ul main body
header body
li ul main body
const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];
// your code: read with next() and nextInt(), push every line of output to out
console.log(out.join("\n"));
Run in Compiler
Graded as bubble-route, a free problem in this module's problem set.
Lima wants a greeting card. The page has a heading that says "Hello" and a button that says "Bangla". When the button is clicked, the heading should change to "Shubho shokal". Finish the script below, then press Run and click the button.
<h1 id="greeting">Hello</h1>
<button id="bangla">Bangla</button>
<script>
// find the heading and the button with querySelector
// add a click listener that changes the heading's textContent
</script>
Run in Compiler
Turn Example 1 into a stock counter for a shop. Add a third button, "Reset", that sets the count back to 0. Add a limit: the count never goes above 10, and when it reaches 10 a paragraph under it says "Shelf is full". The paragraph is empty at any other count. Keep the count in a variable and draw it in one function.
<p id="count">0</p>
<p id="status"></p>
<button id="minus">-1</button>
<button id="plus">+1</button>
<script>
let count = 0;
// add a Reset button above in the markup, then wire all three buttons here
</script>
Run in Compiler
Common doubts
Is the DOM the same thing as the HTML file?
No. The HTML file is text that the browser reads once. The DOM is the tree of objects it builds from that text, and that tree is what your script changes. After a script runs, the two differ. View Source shows the file; DevTools' Elements pane shows the DOM.
Why does this module run pages in a preview, when the rest of the track runs under Node?
Node has no page, no screen and no
document. A page program needs a browser, so the preview opens one in a sandboxed frame inside the lesson. It has no network and no storage, and every Run starts the page fresh. The judged problems still run under Node, so they work on trees written as plain objects.Can I use
alertto see a value?In the preview,
alert,confirmandpromptonly print a line in the console and return at once. Useconsole.log, or write the value into the page withtextContent. Both are better habits anyway, because a real dialog stops the whole page.Is the event handler the same as a Module 12 callback?
Yes. The browser keeps your function and calls it later, on a turn of the event loop, once per event. While your handler runs, nothing else on the page runs, so a slow handler freezes the page.
Key takeaways
- The DOM is the tree of objects a browser builds from HTML, and the screen is drawn from it.
- Each node has one parent and a list of children; a node shows only once a parent in the page holds it.
querySelectorreturns the first match for a CSS selector, ornull.textContentreads or replaces an element's text, andaddEventListenercalls a function on every event.- A click starts at its target and bubbles up through every ancestor; a script must run after the elements it needs.
- Go deeper: Under the Hood, Reflow, Repaint, Live Lists and Batching (Pro).
Next: every DOM tool one by one, from querySelectorAll to removing a listener.
End of lesson 1
Mark it done, and your progress moves with you.
Next: Every DOM Tool, One by One, With What It Returns