DEV Community

loading...

A beginners' tutorial to Preact - Part 2: The DOM, and Hyperscript

solarliner profile image ๐Ÿ‡จ๐Ÿ‡ต๏ธ Nathan Graule ใƒปUpdated on ใƒป5 min read

How to birth HTML DOM from JavaScript

Before we dive into writing "HTML", let's clarify something.

The Document Object Model

The DOM is, in effect, what the browser translates the HTML to when loading the web page into memory. The DOM contains all information about the current document. It's a tree, branching out from a root element (as represented by the <html> tag in HTML). HTML is the written representation of the DOM, and JavaScript manipulate the DOM instead of the HTML, because it is faster to modify the content tree than write HTML for the browser to parse again.

Creating DOM nodes from JavaScript

Ideally, we want a fast way to type the DOM that we want to insert as part of the reusable elements that will be the foundation of our applications. In a nutshell, a DOM node must be created, setup and appended to the page as a child of another node for it to appear in the browser window. A vanilla JavaScript solution would look like this:

const element = document.createElement("div");
element.classList.add("foo");
element.appendChild(document.createTextNode("I am the visible part of the node iceberg");

document.body.appendChild(element);

This will create the following DOM (as represented in HTML):

<div class="foo">I am the visible part of the node iceberg</div>

And a second version with a paragraph element nested inside the div:

const divEl = document.createElement("div");
element.classList.add("foo");
const parEl = document.createElement("p");
parEl.classList.add("content");
parEl.appendChild(document.createTextNode("I am the visible part of the node iceberg"));

document.body.appendChild(element);

will render the following DOM:

<div class="foo"><p class="content">I am the visible part of the node iceberg</p></div>

Seeing as this is the quintessential pattern for inserting content into the page (or, more generally, "DOM manipulation"), we can clearly do better.

Let's define a function that will create the element with the given tag and class list, and return it.

Note: The types come from TypeScript, which I'll use in this series. Stip the types away (after the colon) to make it into regular JavaScript.

/**
 * Creates an element and returns it
 * @param tag Tag name, like the HTML tag
 * @param classes List of CSS classes to apply
 */
function createElement(tag: string, classes: string[]) {
  const el = document.createElement(tag);
  for (const klass of classes) {
    if (klass === "" || klass === undefined) continue;
    el.classList.add(klass);
  }

  return el;
}

Okay, but that's not helpful. We still need to manually insert them into the page. We can do this two ways: either add a parent node as argument to the function, or a list of children to add to the element being created.

The first solution means we will need to first define the parent node in order to pass it to children, to avoid duplication and to ensure the children all have the same parents. This introduces boilerplate.

The second solution doesn't suffer from the disadvantages of the first; furthermore, you can simply nest calls to the createElement function to create both the parent and the children nodes at once. The node returned by the top-most call to createElement can directly be appended to an existing node, inserting the whole sub-tree into the page.

How to text

The DOM is a tree structure, meaning that everything is a node. Plain old text is, therefore a node. But it's treated differently from other nodes in the DOM, particularly because of its inability to have children.

Note: Technically, a TextNode is almost nothing like other HTML elements, as a TextNode inherits Node, whereas HTMLElement inherits Element which inherits Node itself. Therefore, an HTMLElement ends up being much more "rich" than a TextNode. Read more about Text and HTMLElement

This means we have to make way in our function to treat this special case. We're going to allow strings to be passed in lieu of HTML elements, which will get turned into Text Nodes inside.

Our function turns into this:

/**
 * Creates an element and returns it
 * @param tag Tag name, like the HTML tag
 * @param classes List of CSS classes to apply
 * @param children Children nodes to append into the created element
 */
function createElement(
  tag: string,
  classes: string[],
  ...children: (HTMLElement | string)[]
) {
  const el = document.createElement(tag);

  // Append given CSS classes
  for (const klass of classes) {
    if (klass === "" || klass === undefined) continue;
    el.classList.add(klass);
  }
  // Insert children
  for (const child of children) {
    // Text is special in the DOM, we must treat it accordingly
    if (typeof child === "string") {
      el.appendChild(document.createTextNode(child));
    } else {
      el.appendChild(child);
    }
  }
  return el;
}

We can now recreate the DOM from above, with nested createElement calls:

document.body.appendChild(
  createElement(
    "div",
    ["foo"],
    createElement("p", ["content"], "This is the tip of the node iceberg")
  )
);

Which gives us the same results as previously:

Result of the function calls as seen in the Inspect panel of the Firefox DevTools

What about images?

There is a serious limitation with our current function however, is that we can't include other attributes into the tags. We need to generalize our function so that it accepts an object of attributes to add to the element.

/**
 * Creates an element and returns it
 * @param tag Tag name, like the HTML tag
 * @param attrs Object of attributes to add to the node
 * @param children Children nodes to append into the created element
 */
function createElement(
  tag: string,
  attrs: object,
  ...children: (HTMLElement | string)[]
) {
  const el = document.createElement(tag);

  // Append given CSS classes
  for (const key of Object.keys(attrs)) {
    el.setAttribute(key, attrs[key]);
  }
  // Insert children
  for (const child of children) {
    // Text is special in the DOM, we must treat it accordingly
    if (typeof child === "string") {
      el.appendChild(document.createTextNode(child));
    } else {
      el.appendChild(child);
    }
  }
  return el;
}

What we have created, more or less, is hyperscript. Hyperscript is a small library which does what we've just done: it encapsulates DOM elements creation into nested function calls. For this reason, and to shorten the name of the function, let's rename it to h, for hyperscript.

With that done, let's create and insert some DOM into the page!

const root = h(
  "app",
  { id: "app" },
  h(
    "header",
    {},
    h("h1", {}, "My custom-generated web page"),
    h("h2", {}, "How neat is that?")
  ),
  h(
    "section",
    {},
    h(
      "div",
      { class: "container" },
      h("p", { class: "content" }, "That's pretty neat.")
    )
  )
);

// Prepending here because scripts should ideally be at the end of the body
document.body.prepend(root);

Conclusion

We now have a powerful generic function to construct any DOM tree that we want. There is one last thing to know that applies on top of that h function, which is used in almost all Preact projects, and which comes back full circle in a way.

Discussion (0)

pic
Editor guide