Combining data with the DOM
At the end of the day, as a news developer, the technical part of our job boils down to having data on the one hand, a web page on the other, and trying to get the two of them to communicate. This is not as easy as it sounds, and it can be a daunting task when you're staring at a blank text editor on deadline. So in this chapter, we're going to talk about all the ways that you can transform data into markup, and how to reverse the process when a user interacts with the page.
Making markup
Let's imagine that we have an array containing a set of objects, each of which is the biographical details of a person in our story. How do we turn that data into web markup that people can actually see, just using the standard browser APIs? Usually, there are two methods we can pick from: generating HTML as a string, or creating actual element nodes. For either one, however, we'll want a placeholder element where our dynamic content will go:
<div class="rogues"></div>
By setting the innerHTML property of this element, the browser will parse and generate elements for us. I like to do this using a single big string, to which we add the markup for each item, like so. Note that for this and all the other examples, we'll be using the selector functions from our Deconstructed jQuery chapter.
var container = $.one(".rogues");
var html = "";
// people is our data array
people.forEach(function(person) {
html += `
<div class="person" data-person="${person.id}">
<img src="${person.mug}">
<h2>${person.name}</h2>
</div>
`;
});
container.innerHTML = html;
You can also add the markup to the innerHTML property one unit at a time, using the += operator, but it's faster for the browser to get it and parse it all in one big chunk.
Creating markup this way is fast and easy, but it does have a flaw: because the elements don't exist until the string is handed over to the browser, we can't add event listeners or retain references right away. Instead, we have to generate a big string, use that to create the container's contents, and then query the newly-created elements in order to manipulate them. It's not very convenient, it's slower (since we have to query for all the elements), and we have to write a second loop:
/* generate elements as a string, then... */
$(".person", container).forEach(el => el.addEventListener("click", function() {
console.log("You've clicked on:", el);
}));
If you want a live reference to elements, so that you can immediately manipulate them, you'll need to use the DOM APIs and then append the resulting elements to the document tree. Unfortunately, creating elements this way is way too verbose (a common problem for built-in browser functions):
people.forEach(function(person) {
var div = document.createElement("div");
div.className = "person";
div.dataset.person = person.id;
var img = document.createElement("img");
img.src = person.mug;
div.appendChild(img);
var h2 = document.createElement("h2");
h2.innerHTML = person.name;
div.appendChild(h2);
container.appendChild(div);
});
What if we could make this a bit cleaner, writing JavaScript code that mimics the structure of the tree we're trying to create, similar to the HTML markup itself? That's absolutely possible if we steal a trick from React.
React uses an embedded language called JSX, which puts HTML templates directly into the JavaScript. However, behind the scenes, that JSX is actually just translated into function calls—specifically React.createElement(). Our version of this will take three arguments: the tag that we want to create, an optional object containing attributes, and then the contents of the element as a string (for setting text contents) or an array of child elements.
In practice, it doesn't look that much different from HTML itself:
// <div class="container">
makeDOM("div", { class: "container" }, [
// <figure>
makeDOM("figure", [
// <img src="example.svg">
makeDOM("img", { src: "example.svg" }),
// <caption>Hello, world!</caption>
makeDOM("caption", "Hello, World!")
])
// </figure>
]);
// </div>
Here's the actual code to implement makeDOM. Most of the complication here is the code to support different argument combinations (attributes but no children, no attributes and string contents, no attributes and child elements, both attributes and text or child elements), but the ergonomics are much better that way.
var makeDOM = function(tagName, attributes = {}, children = []) {
// generate the element
var element = document.createElement(tagName);
// did you skip attributes, meaning that it's actually children?
if (attributes instanceof Array || typeof attributes == "string") {
children = attributes;
attributes = {};
}
// set attributes on our element
for (var attr in attributes) {
var value = attributes[attr];
element.setAttribute(attr, value);
}
// is children actually a string for the inner HTML?
if (typeof children == "string") {
element.innerHTML = children;
} else {
// append children
children.forEach(c => element.appendChild(c));
}
// hand back the constructed DOM
return element
};
As above, because makeDOM accepts an array of elements to be children, we can nest multiple calls to this inside the arguments, and build a whole subtree on the spot. Here's our loop through the bio data using this function instead of manually calling the DOM API, which is much more readable:
people.forEach(function(person) {
//shorten the function name for readability
var m = makeDOM;
// <div class="person" data-person="...">
var element = m("div", { class: "person", "data-person": person.id }, [
// <img src="...">
m("img", { src: person.mug }),
// <h2>...</h2>
m("h2", person.name)
]);
container.appendChild(element);
});
Our JavaScript is now structured in a very similar way to our HTML markup, indentation and all, but it creates "live" DOM elements. Now it's time to associate those elements with data, by binding for events.
Matching DOM to data
Making HTML from our data is only half the challenge, and it's the easier half. We still have to be able to reverse that process whenever a user interacts with our page: for each element, we need to retrieve the corresponding data for display or manipulation.
As mentioned above, one way to do this is by associating the data directly with the event listener for the element, using a closure inside of our loop. A "closure" is a function that incorporates outside variables from the context in which it was created. For example, the following loop has an event listener that "closes over" the person value.
people.forEach(function(person) {
var m = makeDOM;
var element = m("div", { class: "person", "data-person": person.id }, [
m("img", { src: person.mug }),
m("h2", person.name)
]);
// this listener will remember the specific "person"
element.addEventListener("click", function() {
console.log(`You clicked on: ${person.name}`);
});
container.appendChild(element);
});
Unfortunately, by using individual listener functions for each element, you're adding some extra memory for every element you create. That may not be important, if you only have a few people in your graphic. But if you have a lot, or if you repeatedly remove and create new elements (say, because you allow the list of people to be filtered), you'll end up using a lot more memory, which may cause your application to stutter, slow down, or even become unresponsive.
It's preferable instead to share a single listener function between all of your elements. By defining it outside the loop, you'll create the function once, and then it can use the this value to determine which specific element was clicked.
var clickedPerson = function() {
console.log("You clicked this element:", this);
};
people.forEach(function(person) {
var m = makeDOM;
var element = m("div", { class: "person", "data-person": person.id }, [
m("img", { src: person.mug }),
m("h2", person.name)
]);
element.addEventListener("click", clickedPerson);
container.appendChild(element);
});
Now, however, we have a problem: we're sharing a single function across all our elements, which means that it can figure out which element was clicked, but it doesn't know what data is associated with that element, because it was defined outside of the loop where that information was available. Luckily, we've been setting the element's "data-person" attribute to be the same as the person's ID in our data (and then we can access that through the dataset property on the JavaScript side). We could loop through the original source array to search for that identifier, but a little preparation can speed things up.
Let's build a lookup table using a JavaScript object. During element creation, we'll add references to the object, with the keys being the person IDs and the values being the source data itself. Then, inside our listener, we can get the attribute value from this and ask the lookup table for the matching array object.
var lookup = {};
var clickedPerson = function() {
var id = this.dataset.person;
// which person has that ID?
var person = lookup[id];
console.log(`You clicked on ${person.name}`);
};
people.forEach(function(person) {
// store the person in our lookup table
lookup[person.id] = person;
var m = makeDOM;
var element = m("div", { class: "person", "data-person": person.id }, [
m("img", { src: person.mug }),
m("h2", person.name)
]);
element.addEventListener("click", clickedPerson);
container.appendChild(element);
});
As a rule of thumb, remember that when you're writing markup, you're leaving clues for your future self. Be generous! HTML generated via JavaScript doesn't cost the user in terms of download time or (barring extreme cases) parse time, so make sure that you place classes and attributes throughout to be helpful for yourself, instead of just using bare tags. You never know when you might need those hooks later.
Of course, if you're using the this value and a lookup object, you can probably use event delegation, which gives you the best of both worlds: easier-to-write string templating, low memory usage, and simple code. This is our most common methodology at the Seattle Times for its simplicity and speed under deadline.
var lookup = {};
var clickedPerson = function() {
var id = this.dataset.person;
// which person has that ID?
var person = lookup[id];
console.log(`You clicked on ${person.name}`);
};
var html = "";
people.forEach(function(person) {
// store the person in our lookup table
lookup[person.id] = person;
html += `
<div class="person" data-person="${person.id}">
<img src="${person.mug}">
<h2>${person.name}</h2>
</div>
`
});
container.innerHTML = html;
// see definition for delegate in the earlier chapter
delegate(container, "[data-person]", "click", clickedPerson);
Either way, the circle is now complete: we generate markup from the data, track the IDs that we've left on our elements, and then use those IDs to get the data back when events occur in the generated markup. This architecture is pretty consistent across every interactive I've ever created, with the real differences only being in where I get the data, and what I do in response to events. Learn to adapt these patterns, and they'll serve you well.