DOM to JSON and back
Time to read
, 1332 words, 4th grade
Here we will create two simple but powerful JavaScript functions.
The first, jsToDom
, will take a JavaScript (or JSON) object and turn it into a
DOM
object that we can insert into our page. The second, domToJs
,
does the reverse. It takes a DOM object and converts it to
JS.
Now we can stringify it and persist it in our database. And rehydrate it at will.
As you may know, JSON does not recognize functions. So we will need a way to deal with our event listeners. Donʼt worry! Weʼve got it covered.
Check out our example of DOM to JSON and back in action. We will explain it below.
Our reasoning
A key axiom of Craft Code is less is more. This influences several of the Craft Code methods, such as code just in time and keep it simple.
This means that we donʼt rush to load up dozens of frameworks, libraries, and other dependencies. Instead, we start with nothing: zero dependencies.
We build the site structure with semantically-correct and accessible HTML. Then we add just enough CSS to make it attractive, user-friendly, and responsive.
Finally, we add JavaScript to progressively enhance the user experience. But only if we need it.
This vanilla approach works very well. One goal of the Craft Code effort is to see how far we can go before we have to add a dependency on someone elseʼs code.
The code
For the impatient, letʼs take a look at the final code. Then weʼll explain it. The next two functions are the totality of the module. The rest are specific to this example.
Note: this is about concepts, not production-ready code. This is a first pass and might could use some refactoring. YMMV.
// ./modules/js-to-dom.js
export default async function jsToDom(js) {
const { attributes, children, events, tagName } = js
const elem = document.createElement(tagName)
for (const attr in attributes) {
elem.setAttribute(attr, attributes[attr])
}
if (Array.isArray(children)) {
for (const child of children) {
typeof child === "object" ? elem.appendChild(await jsToDom(child)) : elem.appendChild(document.createTextNode(child))
}
}
if (events) {
for (const key in events) {
if (!events[key]) {
break
}
const handler = typeof events[key] === "function" ? events[key] : (await import(`./${events[key]}.js`)).default
handler && elem.addEventListener(key, handler)
}
setDataEvents(elem, js.events)
}
return elem
}
function setDataEvents(elem, obj = {}) {
const eventString = Object.keys(obj)
.reduce((out, key) => {
if (typeof obj[key] === "string") {
out.push(`${key}:${obj[key]}`)
}
return out
}, [])
.join(",")
if (eventString) {
elem.setAttribute("data-events", eventString)
}
}
The jsToDom
function takes a JavaScript or JSON object as its sole parameter.
First, we extract the attributes
, children
, events
, and tagName
from the passed object into variables. Then
we create an element of type tagName
using document.createElement
.
Next we loop through the attributes
object using element.setAttribute
to add each as an attribute to the DOM element.
Moving on, we loop through the children
array, if it exists.
For each child object, we call jsToDom
recursively, passing
it the object. In this way we build a tree of DOM elements. We use element.appendChild
to append them to our element.
Next, we need to handle any events. If an events
object exists,
then we loop through it. Each key-value pair represents an event name and
a function or function name, respectively. If the value is of type “function”, then we use element.addEventListener
to
add it to the element. If the value is a string, then we import the default
function from a module with that name and use that as our handler.
For example, if we have a key “click” and a value of “log”, then we would import the default from ./modules/log.js
and attach it with addEventListener("click", log)
.
Finally, we use a utility function called setDataEvents
,
passing it our element and our events
object. This converts
our object to a comma-separated string of key-colon-function-namne. The
above example would yield “click:log”. We set this as an
attribute on our element with name “data-events”.
This permits us to reverse our cast and recreate the JS or JSON object from our DOM element. But it only works for function names. If we pass a function, there is no easy way to get it back.
We grab the tagName
from the JSON and create a DOM element of
that type. Then we add the attributes to that element. Then we apply jsToDom
recursively on the children
, appending them to the
element.
The events
object is pretty clever, IOHO. The keys are the names of the events (e.g., click
) and
the values are the names of the handler functions. We will import those
functions only when needed. See an example below.
We also create a string representation of the events object. We could have simply stringified it, but we wanted to make it human readable, so we wrote our own (lines #39 to #52).
// ./modules/dom-to-js.js
export default function domToJs(dom) {
const { attributes, childNodes, tagName } = dom
const eventList = dom.getAttribute("data-events")
const events = eventList?.split(",").reduce((out, evt) => {
const [key, value] = evt.split(":")
if (key) {
out[key] = value
}
return out
}, {})
const attrs = Object.values(attributes)
.map((v) => v.localName)
.filter((name) => name !== "data-events")
return {
tagName,
attributes: attrs.reduce((out, attr) => {
out[attr] = dom.getAttribute(attr)
return out
}, {}),
events,
children: Array.from(childNodes).map((_, idx) => {
const child = childNodes[idx]
return child.nodeType === Node.TEXT_NODE ? child.nodeValue : domToJs(child)
}),
}
}
The domToJs
function takes a single parameter. This is the
root DOM element that we want
to convert to JavaScript or JSON.
We begin by extracting the attributes
, childNodes
, and tagName
from the element. First, we will deal with
the events. We get the data-events
attribute with element.getAttribute
. If it exists, we split the string on the commas, then loop through
our array using reduce
. We recreate our events
object in this way and store it in an events
variable.
Next we use Object.values
to get the attributes and map
through them using attribute.localName
to get the attribute
name. We filter out our data-events
attribute as weʼve handled that already.
Finally, we return an object. Our first key is our tagName
. Using our attribute names, we create an attributes
key
and set its value to an object we create with reduce
and getAttribute
. We drop in our events
object.
Lastly, we turn our childNodes
NodeList
into an Array with Array.from
. Then
we map
it to an array of strings (if the child is a text node),
or JS objects by calling domToJs
recursively.
This is a bit tricky as nothing in the DOM is simple. Go figure. We explain below.
This extracts the attributes
, the childNodes
,
and tagName
from the passed DOM element. Then we use these to
create a simple JS/JSON object, recursing through the child nodes.
We pull the data-events
attribute out and treat it separately.
We parse the value back into an actual object and add it at the events
key.
The output is JS, but we can stringify it to JSON as required. The JSON shown below is a typical example. It creates our test form.
form
.{
"tagName": "FORM",
"attributes": {
"action": "#",
"method": "POST",
"name": "form"
},
"events": {
"focusin": "log",
"submit": "parse-submission"
},
"children": [
{
"tagName": "TEXTAREA",
"attributes": {
"data-type": "json",
"name": "json"
},
"children": ["{\"tagName\":\"DIV\",\"attributes\":{\"class\":\"sb-test\",\"data-type\":\"string\",\"id\":\"sb-test-id\"},\"events\":{\"click\":\"log\"},\"children\":[{\"tagName\":\"STRONG\",\"children\":[\"Bob's yer uncle.\"]}]}"]
},
{
"tagName": "BUTTON",
"attributes": {
"aria-label": "Run this baby",
"type": "submit"
},
"children": ["Run"]
}
]
}
Our JSON is simple. We have a tagName
key with the value “FORM”. This will create a form
element.
Next we have an attributes
key whose value is an object. The
object contains key-value pairs where the key is the name of the attribute
and the value is the attributeʼs value. Here we set action to “#”, method to “POST”, and name to “form”. Not very imaginative, we admit.
Next is a children
key with value set to an array of similar
JSON objects for each of the children. These are nested successively.
Finally, we have an events
key whose value is an object. The
keys are event names, such as “focusin”, and the values
are the names of functions to import and use as handlers, e.g., “log”.
We import this JSON and pass it to jsToDom
in our
index.js
file below.
div
and the form
into main
on DOMContentLoaded
.// index.js
import jsToDom from "./modules/js-to-dom.js"
import formJson from "./modules/form-json.js"
import outJson from "./modules/out-json.js"
export async function injectForm() {
const main = document.querySelector("main")
main.appendChild(await jsToDom(outJson))
main.appendChild(await jsToDom(formJson))
}
globalThis.addEventListener("DOMContentLoaded", injectForm)
This is our main JS file, index.js
. We begin by importing
the jsToDom
function, our form JSON and the JSON to produce
a div
element for our output.
Then we create an asyncronous function called injectForm
.
We use document.querySelector
to get the main
element. Then we convert our output JSON to a DOM element with jsToDom
and use appendChild
to append it to main
.
Finally, we use jsToDom
again on our form JSON this time,
and append that element to main
. Now that we have our
function ready, we use globalThis.addEventListener
to run
it on DOMContentLoaded
, injecting the form.
Pretty self-explanatory. Now, how do we handle our events?
Simple. We take our events
object from the passed JSON/JS. Then
we loop through the keys, which are the event types. The values are the names of the handler functions.
We add an event listener for each type and assign it the default
function from the module with that name. For example, our output div
gets a click
handler called “log”. This
function is in ./modules/log.js
.
We import the handler: (await import("./log.js")).default
.
We assign it to handler
.
Then we add it like this:
addEventListener("click", handler)
.
Drop dead simple. And we only import the modules that we need. See the
actual log
handler below.
// ./modules/log.js
export default function log({ target }) {
console.log(target?.tagName, target?.innerText || target?.value)
}
This super simple function, for example purposes only, gets the target
element and logs out the tagName
and the innerText
or the value
to the console.
Kinda dumb, but it is merely an example. We add this log
function
as a click
handler on our output strong
element
and as a focusin
handler on our form
.
The submit
handler for the form is a bit more exciting:
// ./modules/parse-submission.js
import domToJs from "./dom-to-js.js"
import jsToDom from "./js-to-dom.js"
export default async function (event) {
event.preventDefault()
const form = event.target
const textarea = form.querySelector("textarea")
const out = document.querySelector(".out")
const js = JSON.parse(textarea.value)
out.appendChild(await jsToDom(js))
const newForm = domToJs(form)
document.querySelector("main").appendChild(await jsToDom(newForm))
}
Here we begin by importing our functions, domToJs
and jsToDom
. Then we export as default an asynchronous anonymous function. It
takes one parameter: the event.
First we call event.preventDefault
to prevent the form from
reloading the page. Then we get the form
element from the
event.target
.
From this element, we use querySelector
to get the formʼs textarea
element. And we use querySelector
again on the document to get the output dive
.
We use JSON.parse
to parse the textarea.value
into a JS object. We pass this to jsToDom
and use appendChild
to append it to our output div
.
So we have used the JSON in the textarea to generate a DOM element and
have appended it to the page. Nice. Now, for fun, we pass the form
element to the domToJs
to convert it to JS. Then we use
the jsToDom
function again to convert it right back and we
append this copy of the form to the main
element, proving
that we can make the round trip.
We canʼt store functions in JSON, so we put them into modules. Then we will import them as needed when we rehydrate the DOM elements.
We attach parseSubmission
as the submit
handler
for our form
element.
What can we do with this?
Ooo. All sorts of cool things.
Easy element creation
Instead of messing around with createElement
, setAttribute
, etc., we can use jsToDom
. We pass it a JS or JSON object
representing the DOM elements we want.
We create handler functions ahead of time in modules. When we need an
event listener, jsToDom
imports it just in time and assigns
it to the element.
This works like Reactʼs createElement
function. Or a library
such as hyperscript. Sure, weʼd prefer JSX for its much reduced cognitive load. But our alternative here is the DOM methods such as createElement
. Unless we want to load up
a bulky library such as React, that is.
We donʼt.
Suppose I wanted to inject a password field with a show/hide button. First, we create a toggle handler such as this:
// ./modules/toggle-visibility.js
export default function (event) {
const button = event.target
const div = button.closest(".form-field")
const input = div?.querySelector("input")
if (input) {
if (input.type === "password") {
input.type = "text"
button.innerText = "hide"
button.setAttribute("aria-label", "Hide password.")
return
}
input.type = "password"
button.innerText = "show"
button.setAttribute("aria-label", "Show password.")
}
}
This is an example of an event handler. We create these in advance so
that we can import them by name as needed. Here, we put them in a modules
folder.
Our code exports as default an anonymous function which takes a single
parameter. This is the event
object, of course.
Next we assign the event.target
to a button
variable.
(The target is a button.) We use closest
to find the closest
element to the button with class “form-field”. This will
be the div
wrapper.
Then, using that wrapper, we can use querySelector
to get
the password input
.
With these three variables — button, div, and input — we go to work.
If the input type
is “password”, then we want
to toggle it to “text” to unhide the password. We set input.type
to “text”. We also set the button.innerText
to “hide” (the password is now showing) and the buttonʼs aria-label
to “Hide password.” And we return.
Otherwise, we set input.type
to “password”, button.innerText
to “show”, and the aria-label
to “Show password.”
Now I can call jsToDom
with the following JSON and it will create
my password input. Try pasting it into the example form. Remember that the click event handler is already available at
./modules/toggle-visibility.js
.
{
"tagName": "DIV",
"attributes": {
"class": "form-field"
},
"events": {
"click": "log"
},
"children": [
{
"tagName": "INPUT",
"attributes": {
"type": "password"
}
},
{
"tagName": "BUTTON",
"attributes": {
"aria-label": "Show password.",
"class": "xx-toggle-password",
"type": "button"
},
"events": {
"click": "toggle-visibility"
},
"children": ["show"]
}
]
}
The password field JSON object starts with an outer div
element,
so we set the tagName
key to the value “DIV”.
We want this div
to have a class name “form-field”, so we set our attributes
value to an object with a single
key of class
having the value “form-field”.
Then we add a click
event set to use the “log” module. When we click on the div
, it will log its tagName
and innerText
to the console.
Finally, we want to add children to our div
wrapper. We set
the children
key to an array of objects. The first represents an input
element with attribute type
set to “password”.
The second child is a button
. We set three attributes: aria-label
to “Show password.”, type
to “button” to prevent form submission, and a class
of
“xx-toggle-password” just because.
Now we add the events
key. It is an object with one key-value
pair. The key is click
and the value is “toggle-visibility”, which is the name of the module holding our
visibility toggle event handler.
Finally, we set the buttonʼs children to a single text element, “show”.
We hope that it is straightforward how all this works.
Easy element persistance
What if I want to save current UI state? We can use domToJs
to do just that.
We took the example page (linked above) and passed the html
element to domToJs
. Then we stringified it. Now we have
preserved both the head
and body
elements.
So we can take a blank HTML document like this:
head
and body
elements.<html lang="en">
<head> </head>
<body>
<script
src="./modules/make-page.js"
type="module"
></script>
</body>
</html>
This example is a very simple HTML document. It has a html
element with the lang
attribute set to “en”. Inside the html
element are an empty head
element and a body
element containing a single script
element. This script loads the “make-page.js” module which
then injects the actual page.
And we can use our persisted JSON head
and body
elements to create the page on the fly. We can store the JSON in a database
or load it from an API.
Below is the code minus the JSON. Or view the actual code.
Then see it in action. View the source on that page to see what we mean.
head
and body
elements.import jsToDom from "./js-to-dom.js"
globalThis.addEventListener("DOMContentLoaded", async () => {
const h = document.documentElement.querySelector("head")
const b = document.documentElement.querySelector("body")
h.replaceWith(await jsToDom(/* head JSON here */))
b.replaceWith(await jsToDom(/* body JSON here */))
})
This is the very simple script that generates our example page. We
begin by importing the jsToDom
function. Then we add an event
listener to globalThis
to run on DOMContentLoaded
and we pass it an asynchronous anonymous arrow function.
In the function we use querySelector
to grap the head
and body
elements. Then we use element.replaceWith
and our jsToDom
utility function to convert our head and
body JSON objects to DOM elements and inject them into our page. Done!
A whole page generated from simple JSON!
Data-driven user interfaces
One idea that we have been promoting for several years now is that of a data-driven interface. Weʼve got an article in the pipeline on that coming soon. But we can give a quick overview here.
The idea is quite simple. The easiest example to visualize is automated form rendering.
Forms collect data. Typically, that data is then persisted in a database.
Databases have schemas. That means that the database already knows the types it expects. If we have defined our schema well, then it knows those types precisely.
Particular data use particular interface widgets. For example,
an email address would use an input
element of type
“email”. An integer might use an input
element of type
“number” with its step
attribute set to “1”.
An enum might use a select
or radio
buttons or
a checkbox
group.
From this schema we should be able to determine how to both display and validate that data. After all, there is only a small number of widgets.
So what if the response to our database query for the data included the schema? Some GraphQL queries already make this possible. From that schema, we can generate validation functions. And we know which widgets to display.
So we can generate our form and our validators automatically.
Best of all, we have a single source of truth: our database schema.
In a coming article, we will explain how easy it is to achieve this.
(There is an advanced version of this that uses a SHACL ontology and a triple store such as Fuseki. We then use SPARQL queries to generate the HTML, CSS, and JS straight from the database. Wee ha! Weʼll get to that sometime soon, too. Promise.)
If anyone requests it, weʼll give a detailed explanation of the above code in a separate article. This one is long enough.