Guide
Let's build some power tools for JavaScript – in this guide, we'll implement a basic JavaScript framework allowing you to split your code up into components, easily hook into and add handlers for events, and paves the way for more advanced features.
So let's assume you know how to build a website. You start with a basic HTML template like the one below:
index.html
<!doctype html><html lang="en"><head><meta charset="utf-8"></head><body></body></html>Usually, the next step is to start adding some content to the page by placing markup tags inside the <body> section.
index.html
<body> <h1>Hello, World!</h1></body>We're going to take a different route here by adding a script instead, which we'll control the entire page from.
index.html
<head><meta charset="utf-8"> <script type="module" src="index.js"></script></head><body> <h1>Hello, World!</h1></body>You may know that you can add elements to a page via JavaScript by using functions on the global document object:
index.js
const heading = document.createElement("h1")heading.textContent = "Hello, World!"document.body.appendChild(heading)Or, alternatively, using document.createTextNode to create the text node and then appending it to the heading element:
index.js
const heading = document.createElement("h1") heading.textContent = "Hello, World!" const text = document.createTextNode("Hello, world!") heading.appendChild(text)document.body.appendChild(heading)We could build our entire page this way, though with repetitive and imperative document.createElement calls, as opposed to the more declarative approach of standard HTML markup. When I say "imperative" as opposed to "declarative" here, I'm referring to the fact that the JavaScript code requires a lot of machinery relating to describing what you want the computer to do, as opposed to what you want to see.
More declarative code cuts out the repetitiveness of telling the browser exactly what to do, and generally results in shorter code. However, we can introduce functions to abstract away this repetitiveness and end up JavaScript that's more declarative, at the cost of needing to understand how the abstractions work.
Imagine a function as follows:
index.js
function addChildren(node, children) {for (const child of children)node.appendChild(child)return node}Pretty simple, though it'll allow a nicer approach to building sections of HTML:
index.js
const part = addChildren(document.createElement("div"), [addChildren(document.createElement("h1"), [document.createTextNode("Hello, world!"),]),addChildren(document.createElement("p"), [document.createTextNode("This is a paragraph."),]),])document.body.appendChild(part)Though none of this is yet as simple as writing vanilla HTML yet. We're sacrificing quite a bit of readability and requiring using custom non-standard abstractions. Are there any benefits to this?
Well, to start with, we can create reusable components:
index.js
const part = const createPart = (heading, text) =>addChildren(document.createElement("div"), [addChildren(document.createElement("h1"), [ document.createTextNode("Hello, world!"), document.createTextNode(heading),]),addChildren(document.createElement("p"), [ document.createTextNode("This is a paragraph."), document.createTextNode(text),]),]) document.body.appendChild(part) document.body.appendChild(createPart( "Part 1", "This is part 1." )) document.body.appendChild(createPart( "Part 2", "Here's another part!" ))You could do this with normal HTML by extracting reusable components from the page with JavaScript and interpolating values into them. Here, since everything is in JavaScript, we can just return it from a normal function, and call the function instead.
Here, each element is created with document.createElement() and document.createTextNode(). They don't show immediately in the browser yet though, since they aren't part of the Document Object Model, or the DOM. The addChildren functions here are used to add the elements as parents of each other, and then finally we use document.body.appendChild() to add the parent element to the DOM.
We could do this final part of parenting elements to the DOM with our addChildren() function as well:
index.js
addChildren(document.body, [ createPart( document.body.appendChild(createPart("Part 1","This is part 1." )) ), document.body.appendChild(createPart( createPart("Part 2","Here's another part!" )) ), ])And additionally, we can make our HTML dynamic by keeping references to the elements we create and updating them as needed.
index.js
let counter = 0const counterText = addChildren(document.createElement("span"), [document.createTextNode(`Counter: ${counter}`),])function increment() {counter++}const button = addChildren(document.createElement("button"), [document.createTextNode("Increment"),])button.addEventListener("click", increment)addChildren(document.body, [counterText,button,])So when the button is clicked, the increment() function is called, which increments the counter variable. However, it doesn't update the counterText element, and so the counter value is not displayed correctly. What's wrong?
Well, the counterText element is declared once upfront, and isn't updated when the counter value changes. There's ways to do this automatically, using a reactive system, which would make our code even more declarative. We won't do this in this guide though, as it's a complex topic. For now, we'll just update the counterText element manually.
index.js
function increment() {counter++counterText.textContent = `Counter: ${counter}`}You may have heard of other systems that allow you to write HTML templates in JavaScript, such as React or Vue. These allow you to build your entire app in a way similar to this, in that the HTML file is just an empty stub that gets filled in by the JavaScript code. So, creating these elements, parenting to the DOM, and updating them based on what you want code to do with them – is this how these frameworks work under the hood? Well, it's close, though not quite there yet.
The reason for this is that when the elements aren't in the DOM, they're just sitting around in memory. Element objects contain a lot of information which is useful when the elements are in the DOM, though they're kind of big for something that's just sitting around in memory or needs to be updated/replaced frequently. So, what these systems use is called a Virtual DOM, sometimes "VDOM". It's a lighter-weight representation of the DOM, with each element as just a plain JavaScript object, that can be compared with the actual DOM or a previous version of the VDOM to determine what needs to be updated.
In the following steps we'll build a simple Virtual DOM, to lay the foundations for some cool stuff you might be inspired to do with it. Let's start with a elements.js file with some classes for representing elements:
elements.js
// Represents a HTML element in the Document Object Model (DOM)export class Element {}// Represents a HTML DOM element with a name, attributes, events, and childrenexport class TagElement extends Element {}// Represents a text node in the DOMexport class TextNode extends Element {}Seems like a good set of types for representing elements. Let's add some parameters to these classes: attrs for attributes like href="https://hackclub.com", events for event handlers, and childElements for children.
elements.js
export class TagElement extends Element { attrs = {} events = {} childElements = []}And constructors, so we can create instances of these classes:
elements.js
export class TagElement extends Element {// ... constructor(name) { // Always call the parent constructor first super() this.name = name }}elements.js
export class TextNode extends Element {constructor(text) { super() this.text = text}}And some type checking for good measure and to prevent errors in future.
elements.js
export class TagElement extends Element {// ...constructor(name) {// Always call the parent constructor firstsuper() // Check types of arguments if (typeof name !== "string") throw new Error("name must be a string")this.name = name}}elements.js
export class TextNode extends Element {constructor(text) {super() if (typeof text !== "string") throw new Error("text must be a string")this.text = text}}Next, some functions for actually rendering these elements, turning them from virtual elements into real ones. Not necessarily in the main document yet, though they will be appended to it later.
elements.js
export class TextNode extends Element {// ... render() { const node = document.createElement(this.name) for (const [name, value] of Object.entries(this.attrs)) node.setAttribute(name, value) for (const [event, handler] of Object.entries(this.events)) node.addEventListener(event, handler) for (const child of this.childElements) node.appendChild(renderElement(child)) return node}}elements.js
export class TextNode extends Element {// ... render() { const node = document.createElement(this.name) for (const [name, value] of Object.entries(this.attrs)) node.setAttribute(name, value) for (const [event, handler] of Object.entries(this.events)) node.addEventListener(event, handler) for (const child of this.childElements) node.appendChild(renderElement(child)) return node}}Finally I'll add a function up at the top of the file to render elements regardless of their type. This might be useful if you want to do specific types of rendering for different elements, though we'll keep it simple and do the same for both text and tag elements for now.
elements.js
export class Element {} // Transforms a virtual element to a DOM node export function renderElement(element) { if (element instanceof TextNode) return element.render() if (element instanceof TagElement) return element.render() throw new Error("unknown element type") }Now we'll add a class for the virtual DOM as a whole in a separate file, dom.js This will hold the elements for the head and the body of the document.
dom.js
// Import some classes from the elements moduleimport { renderElement, TagElement, TextNode } from "./elements"// Represents a virtual HTML document, with a head and body sectionexport class VirtualDom {constructor(head, body) {this.head = headthis.body = body}}Add in some type checking:
dom.js
export class VirtualDom {constructor(head, body) { if (!Array.isArray(head)) throw new Error("head must be an array") if (!Array.isArray(body)) throw new Error("body must be an array")this.head = headthis.body = body}}And finally a function to render the virtual DOM to the page.
dom.js
export class VirtualDom {// ... render() { for (const element of this.head) { const node = renderElement(element) document.head.appendChild(node) } for (const element of this.body) { const node = renderElement(element) document.body.appendChild(node) } }}For good measure, we'll add some shorter aliases for the new TextNode() and new TagElement() constructor functions.
dom.js
export class VirtualDom {// ... export const text = str => new TextNode(str) export const tag = name => new TagElement(name)}Not super dynamic yet though. We'll go back to the elements.js file and add some helper functions to the TagElement class.
elements.js
export class TagElement extends Element {// ... attribute(name, value) { if (typeof name !== "string") throw new Error("name must be a string") if (typeof value !== "string") throw new Error("value must be a string") this.attrs[name] = value return this } onEvent(event, handler) { this.events[event] = handler return this } children(...nodes) { if (this.childElements.length > 0) throw new Error("children have already been added") for (const [i, node] of nodes.entries()) if (!(node instanceof Element)) throw new Error(`nodes[${i}] must be an instance of Element`) this.childElements = nodes return this }}Check it out! Let's make a simple tree and render it now.
index.js
import { tag, text, VirtualDom } from "./dom.js"const dom = new VirtualDom([tag("title").children(text("JS tools test"))],[tag("h1").children(text("JS tools test")),tag("p").attribute("style", "color: blue").children(text("hello, world")),tag("button").onEvent("click", () => {console.log("sup")}).children(text("Click me")),tag("div").children(tag("h2").children(text("List of things I can do")),tag("ul").children(tag("li").children(text("write code")),tag("li").children(text("trial & error")),tag("li").children(text("reinvent the wheel")),tag("li").children(text("procrastinate")),)),])dom.render()This guide is simply an example of a simple frontend framework that could be built in an hour or 2. I suggest you try implementing a similar version or building something inspired by it. Alternatively, you could try expanding on this example and adding more features to it – see the challenge below!
Challenge
Updating the DOM here from an event still requires updating the content of the element manually. How about trying to integrate a reactivity system into the above virtual DOM implementation? See if you can modify the dom.js and element.js files, and implement value.js, to allow the following code snippet, or something similar to it, to increment both counters automatically.
For bonus credit, make sure that there are no more updates made than are absolutely necessary, that is, only 1 update for each VDOM element/computed value upon each root value change. Have fun!
index.js
import { tag, text, VirtualDom } from "./dom.js"import { Computed, Value } from "./value.js"const count = Value(0)const doubled = Computed(use => use(count) * 2)const counterText = Computed(use => text(`count: ${use(count)}`))const doubledText = Computed(use => text(`doubled: ${use(doubled)}`))const dom = new VirtualDom([tag("title").children(text("Reactivity!"))],[tag("p").children(counterText),tag("p").children(doubledText),tag("button").onEvent("click", () => {count.set(count.get() + 1)}).children(text("Click me")),])dom.render()