Foreword
In this article I will reconstruct my development process for a tiny but powerful helper I've built myself to generate DOM nodes using Javascript. 😁
The reason why I built this in the first place is that I just don't like writing HTML, and most templating engines out there are essentially just glorified string interpolation. 😖
This also isn't really new code: for the most part, it's a rewrite of a Lua library that implements the exact same mechanism. 😅
With that being said...
Let's get started!
For a starting point, let's define a simple function that creates an HTML element:
export const node = (name) => {
const element = document.createElement(name)
return element
}
Okay, simple enough. But that's little more than an alias to document.createElement()
. We'll want to pass more arguments; the function should return the (mostly) finished DOM node.
Setting up additional arguments
To allow nested structures, let's use a recursive function to handle the arguments. First add it to the node
function:
export const node = (name, args) => {
const element = document.createElement(name)
parseArgs(element, args)
return element
}
Note: some readers might already realize that we could collect many args into an array with
...args
; don't worry, I'll get to that in another step 😉
Attributes
And now we implement it. Let's start with a simple case: an empty element with some attributes: node("div", [{class:"box"}])
should return an element like <div class="box">
.
+ const parseArgs = (element, args) => {
+ for (arg of args)
+ for (key in arg)
+ element.setAttribute(key, arg[key])
+ }
Text Content
Good! That's progress! But what's the point of HTML elements if they can only be empty? When we call node("p", ["Hello, World!"])
, it should return a <p>
element with the text "Hello, World!". Let's add that logic as well:
const parseArgs = (element, args) => {
for (arg of args)
+ if (typeof(arg) == "string")
+ element.appendChild(document.createTextNode(arg))
+ else
for (key in arg)
element.setAttribute(key, arg[key])
}
And with that, most of the hard work is done already. Structurally, we're done; all that's left is add more cases.
Nested Arrays
First, let's do the recursion thing. It's useful if we don't have to flatten our input before passing it in, so let's add a recursive case for array arguments. Let's turn node("p", ["foo", ["bar", "baz"]])
into <p>foobarbaz</p>
:
const parseArgs = (element, args) => {
for (arg of args)
if (typeof(arg) == "string")
element.appendChild(document.createTextNode(arg))
+ else if ("length" in arg)
+ parseArgs(element, arg)
else
for (key in arg)
element.setAttribute(key, arg[key])
}
Child Elements
And lastly, the nicest part: adding other HTML elements as children. This is very similar to the string case:
const parseArgs = (element, args) => {
for (arg of args)
if (typeof(arg) == "string")
element.appendChild(document.createTextNode(arg))
+ else if ("nodeName" in arg)
+ element.appendChild(arg)
else if ("length" in arg)
parseArgs(element, arg)
else
for (key in arg)
element.setAttribute(key, arg[key])
}
Trying it out
And with that, our basic HTML rendering mechanism is done. We can now write code like:
let navlink = (name) =>
node("a", [{href: "/"+name}, name])
let menu = (items) => node("nav", [
node("ul", [
items
.map(navlink)
.map(link => node("li", [link])
])
])
body.appendChild(menu(["home", "about", "contact"]))
It works, and we can do a whole lot of code-reuse with that setup already. But it feels very cumbersome to use:
- Passing the node type as a string 💢
- Wrapping arguments in an array 😩
- Having to use a wrapper function with
map
🤔
Another layer of convenience
To remedy these, let's do some meta-programming and build ourselves a nice wrapper around this:
export const html = new Proxy({}, {
get: (_, prop) => (...args) => node(prop, args)
})
And that's it! When we index our new html
Proxy object with a string, like html.div
; it will automatically return a wrapper for the node
function that adds the node type and collects all the arguments into an array.
This only leaves the problem with map
: this array method passes the array item as the first argument to the callback function, but then also adds the index and the array as well. This means those values will get passed to our node
function and cause problems.
To fix this, we can change the Proxy like this:
export const html = new Proxy(Window, {
get: (target, prop, receiver) => {
if (prop.search(/^[A-Z]/)+1)
return (arg) => node(prop, [arg])
else
return (...args) => node(prop, args)
}
})
Now, when we index our html
proxy with a string starting with uppercase, it will return a different closure that only passes its first argument.
Trying out the changes
We can now rewrite the example above as:
let navlink (name) => html.a({href: "/"+name}, name)
let menu = items => html.nav(
html.ul(
items
.map(navlink)
.map(html.Li)
)
)
body.appendChild(menu(["home", "about", "contact"]))
And that was it 😁
If you have any questions or feedback, please leave a comment. If there's anything in the code that needs clarifying, tell me and I will extend the post or maybe write a new one 💖