Working through the lessons on online coding schools, we are often taught HTML and CSS, then basic javascript, and then move into basic DOM manipulation. All great and logical steps: learn about the content, learn about presenting the content, learn about coding, and then learn about using that code to make the content somewhat interactive.
But we never really learn about development, in the sense of how to strategize. For example, when we are building things like the Todo project or the Library project, some planning and design are essential. And it's no shortcoming of the courses, it's something we tend to learn as we go. We try things, we find what doesn't work, and we try something else.
I'm hoping to use this as an idea, and a conversation starter. Before we begin, replit has what we're going for.
The Problem
We'll create an artificial example, rather than give the answers away to any given course project. Let us imagine that we have been given the assignment to create a "Word Blanks" style game. Users are presented a few text input boxes, and a filled version of the string. Clicking "Show me!" should check if all the input elements have been completed, and if they have, display the original phrase with the words inserted into the blanks. And, once the thing has been displayed, let's add a qualification: if the user types into an input once the phrase has been displayed, we update that word as they type.
Not overly complicated, good DOM manipulation practice, and we can have some fun. But we'll also add a few more challenges: first, the DOM manipulation should be encapsulated, and kept separate from the internal state of the Word Blanks. Let's require that the DOM manipulation be handled by a javascript module, while the mad-lib itself be a factory function. But the word blanks state shouldn't know about the DOM, and the DOM should know as little as possible about the internal state. We want to keep them discrete, completely separate from each other.
That sounds a little more challenging, and will require some planning and foresight. And this is where we start strategizing.
First Steps
So we know we will need a WordBlanksFactory, and a WordBlanksDisplay module. And we know we want to keep them separate, as much as possible. The factory should keep charge of the data, while the module handles the display. In terms of "design patterns", we might think of this along the lines of the MVC (Model/View/Controller) - we have the factory storing the data model, and we have the module handling the display of that data... but we need to somehow connect them. They can't know about each other directly, but what about some way of communicating indirectly?
When we need to have two components of our code to be able to interact with each other, but we also need them to be completely separate, it is time to think about a third component. In MVC, that's the controller: it takes the data from the model and communicates that to the view, and also takes interaction from the view (clicking buttons, typing text, hovering) and commnicates that back to the model.
In that way, we keep the data (the state) consistent, and keeps the view in step with that data.
But how might we apply that to this particular project?
Modelling the Data
Let's start by planning out what our data might look like. Doing that, we can start to see some patterns of communication.
To begin, a word-blank will display a sentence or paragraph of text. Let's lay out a sample of what one might look like:
"To be or not to be, that is the __noun__. Whether 'tis __adjective__ to __verb__ the slings and arrows of outrageous fortune..."
That sort of gives an idea, we will have a sentence, phrase or paragraph. In it, there should be blanks of some sort that we will fill. It'd be nice to know what part of speech goes into the string, so we can allow for that functionality too.
Looking at that, we can see some useful things: we will likely have an array of words to be inserted, and we will also likely have that phrase as a "template," into which we'll be injecting those words. At a quick glance, that might be a useful start.
A Quick Aside...
It is key to introduce a common concept or mantra at this point, as I suspect it will become very important here: "Plan to an interface, not an implementation." What does this actually mean, in practical terms? In this context, it means "Don't overplan." We have some ideas what sorts of data we might be dealing with, in a general sense, but we haven't gotten too deeply into how we'll make it happen.
It is very easy to get lost in the bog of "how" we'll make a thing happen, and lose sight of the "what" we want to happen. Often, when faced with the data ideas in that last section, we might jump into mapping out the data, figuring if an array or hash or object or whatever are the way to go, how we'll handle that internally, whether we'll use for
loops or .map()
to work with the array.... and we've suddenly lost focus.
We don't care how the data is implemented, we don't care how the DOM is displayed, we don't care how we'll update the model when the input changes... that is all implementation. Instead, we need to watch what each piece can say, and can hear. We need to define the external what, not the internal how.
Back on Task: Planning Interfaces
So we know we'll have three components of some sort: a WordBlanksFactory
and a WordBlanksDisplay
, both of which talk to each other indirectly, by communicating through an intermediary that we'll call WordBlanksController
. In this way, we can decouple the display from the data. Each can work independently, and be tested independently.
What might be a useful interface for the data? Perhaps, when we create the data model, we want to pass in something to start. Further, we likely want to be able to query the data for its blanks, and for the full string, perhaps before and after applying the supplied words. We might want to be able to get or set the value of a particular word. Much more than that... not really. That's basically the entire interface for the data:
WordBlanksFactory(string){
get {
originalString // the original, undoctored string
filledString // the edited string, with either blanks or word values in
words // the array of WordBlank words
blanks // the array of WordBlank types
wordBlanks // a copy of the array of WordBlank things
}
set (
word
)
reset
save
}
There may be more methods we want to add later, but this gives us a general idea: we want to provide an interface that lets us pass in a starting thing, which sets up an internal state; we want to be able to view that state or alter it as needed, and we might want to add some functionality to handle resetting and perhaps "exporting" the word-blank in case we want to save this state for later use..
Note, I'm not defining any kind of input types, or export types. I'm not saying how we'll export the thing, I'm not specifying what the reset
should do, I'm not defining how the setter will look - I'm just preparing my interface wish list.
But that leads to another Factory we may want: a WordBlank
. That should take the __proper name__
and return us an accessor. What might that look like:
WordBlank(string){
get{
id,
type,
word
}
set{
word
}
reset
}
That one's pretty basic, doesn't need a lot of functionality. That's enough to move on for now.
Let's do the same with the WordBlanksDisplay
module. This one doesn't have an awful lot, really: we want to be able to provide it with some data and have it display that data. If the data changes somehow, we might want to let the display module know to re-render, likely by simply calling the rendering method again. We might also want some way for the display to let us know if the words change, might be handy to know.
WordBlanksDisplay(container){
render
* updateModel
}
that updateModel
is a tricky one - it's more an idea at this point, not really a function. Do we want to have something we subscribe to? Do we want to have some event outside the Display that we trigger? How might we.... and there we are, falling down the rabbit hole. Don't go there. It's enough to know, at this point, that we want to somehow comunicate back that we have had a change. Again, we're simply exploring interface ideas, not implementing it yet.
I do have some ideas, as I write this, but honestly they're just kind of percolating and will evolve as we go.
Now, we've talked some about the two main components that we can see when we first consider the project, but what about that third one I'd mentioned? Some sort of a controller that acts as the "glue" between these two? It might need some methods of its own, and it also needs to be able to connect to the other two. Let's ideate!
WordBlanksGame(container){
set{
wordBlankTemplate
}
get{
wordBlank
}
load
save
reset
}
Offhand, that looks pretty solid. the game itself doesn't need a lot of externally-available instance methods. It'd be nice to be able to pass in a new word blank template string, and to save or load the existing one.
When I'm defining the interfaces, for the most part I'm not even thinking about the DOM. I'm not so concerned with how I might talk to the thing, just that I can. I often picture using the entire thing from the console or command line, simply calling interface methods directly. When we use our factories or modules, that's what we'll be doing - calling their interface methods from other factories or modules. So why shouldn't we test in that same way?
Start Building Something Already!
Let's start with the core WordBlanksFactory
, the thing we'll use to handle the data bits. To begin, we might just make an outline. At the same time, we can define the WordBlank
, as it's a pretty simple factory as well.
const WordBlank = (string) => {
const id = crypto.randomUUID();
const type = string.replaceAll('_','');
let entry = '';
return Object.freeze({
id,
type,
get entry(){ return entry; },
set entry(value){ entry = value;},
reset(){ entry = ''; }
})
}
const WordBlanksFactory = (string)=>{
// Break the string into words...
const blanks = string.split(' ')
// remove anything that's not a blank...
.filter((word)=>word.startsWith('__'))
// and make each blank a WordBlank thing!
.map(WordBlank);
return Object.freeze({
get originalString(){ return string; },
get filledString(){
return String.raw({raw: string.split(/__[a-z\s]*[a-z]__/i)},
...blanks.map((blank)=>blank.entry ? blank.entry : '_______'))
},
byId: (id)=>blanks.find(blank => blank.id===id),
get words(){=>return blanks.map((blank)=>blank.entry) },
get blanks(){=>return blanks.map((blank)=>blank.type) },
get wordBlanks(){ return blanks.map({blank}=>({...blank}) ) },
reset: ()=> blanks.forEach(blank=>blank.reset() ),
})
};
As that was being built, you may have noticed a few methods and a factory we didn't really plan on. We don't need the abstraction of a WordBlank
factory, but it makes storing the complex data object a little tidier. And, in the process of defining that, I was seeing other methods that could be useful: being able to get either the types of each word, or the actual word for each word; being able to get a particular blank by id.
Further, note that I wrapped the returned object in an Object.freeze()
. By doing this, I am ensuring that any getting or setting being done happens within the closure, and not on the returned object. This is important, as it is easy to lose sight of the fact that they're two different things.
Finally, note the get wordBlanks
function: it doesn't return the array of WordBlank
objects, it returns a static copy of each, containing an id
, a type
and an entry
. It loses all WordBlank
functionality, but it provides everything needed to reference and display each entry! By doing this, I ensure that we can't simply access write or reset methods from the display - the display can only consume that data.
The only really funky bit of this whole thing that I really had to research was how can I built a tagged template...without having a tagged template? That's what's going on in the filledString
getter function. To see what that's actually doing, feel free to ask or view the docs in MDN (well worth the read, because it explains what's actually happening inside of template literals!)
With that one, we have the data side ready. that's really all there is to it. We can create the data model by
const wbModel = WordBlanksFactory("To be or not to be, that is the __noun__. Whether 'tis __adjective__ to __verb__ the slings and arrows of outrageous fortune...");
console.log(wbModel.filledString);
//To be or not to be, that is the _______. Whether 'tis _______ to _______ the slings and arrows of outrageous fortune...
console.log(wbModel.wordBlanks)
//[
// { id: 'a3392c30-df20-4353-922d-429ec4e7eb28',
// type: 'noun',
// entry: '',
// },
// { id: 'd13f57f8-7342-479b-a238-25ed35f26918',
// type: 'adjective',
// entry: '',
// },
// { id: '790161d5-ee88-4cbf-840a-af845f0bf98f',
// type: 'verb',
// entry: '',
// }
//]
wbModel.byId('790161d5-ee88-4cbf-840a-af845f0bf98f').entry='snozz'
We can create and tinker with that model entirely from the console, or from a script if we like. It is completely testable, and it doesn't depend on the DOM at all. But now, lets switch tracks. Lets look at the DOM and how that might work.
Meanwhile, Out Front of the Curtain...
The display parts might take some planning. Again, I think the idea of having two different parts going makes some sense. Perhaps a function that can create the WordBlank inputs, to look like:
<label class="wordblank-label"><span>noun:</span>
<input class="wordblank-input"
type="text"
placeholder="noun"
data-id="a3392c30-df20-4353-922d-429ec4e7eb28">
</label>
Everything in there can be got from the WordBlankFactory
's .wordBlank
getter - it gives us an array of exactly what we need. So let's start by defining a createWordBlankInput
function - we pass that the object and it returns that DOM node.
I should pause here for a minute, because I've been asked often what I think of the whole innerHTML
vs createElement
mess, in terms of creating entire DOM trees. Each has its advantages and disadvantages. innerHTML
is fast and easy, you pass in a string and it parses it as DOM in place, but it is insecure and dangerous. createElement
and DOM creation/manipulation is great for small jobs, or for simple elements, but it quickly gets ridiculous to maintain. On the plus side, though, with createElement
and in-memory DOM creation, we can attach listeners and populate the thing in memory before we even inject it.
But I have found a third way I like, which seems to combine the best of both worlds. Thanks to David Walsh's Blog, I can take a string (or string literal) and create my DOM structure in memory, and then manipulate it I like before injecting it.
That said, I created a utility function for the purpose:
const toHtml = (str) => document.createRange()
.createContextualFragment(str.trim())
.firstChild;
So passing in a valid DOM string consisting of a root node and any number of descendants, we get back a DOM tree. Very handy, and way easier to just type toHtml()
to create simple or complex structures.
Now, back on task. The createWordBlankInput
:
import toHtml from './toHtml.js';
const createWordBlankInput = ({id, type, entry})=>{
const input = toHtml(`
<label class='wordblank-label'><span>${type}:</span>
<input class='wordblank-input'
type='text'
placeholder="${type}"
data-id="${id}"
${entry && `value="${entry}"`}>
</label>`)
return input;
}
So that does the entire thing, creates the input and sets the custom values for us. In the parameters, we destructure the wordBlank
object, pulling out the properties we'll use, and then we use those in the string literal.
What about the rest of the HTML for the WordBlanks game? That creates the inputs, but we need to wrap them in something! That'd be the WordBlankView
:
const WordBlankView = ({filledString, wordBlanks})=>{
let state = {
blanks: wordBlanks.map(createWordBlankInput),
filledString
};
const domEl = toHtml(`
<main class='wordblank-game'>
<section class='blanks-pane'>
<header><h2>Word Blanks!</h2></header>
<ul></ul>
</section>
<section class='filled-pane'>
<p></p>
</section>
</main>`);
// just to tinker with the DOM in memory,since we *can*:
domEl.querySelector(".filled-pane p").textContent = state.filledString;
domEl.querySelector(".blanks-pane ul").textContent='';
domEl.querySelector(".blanks-pane ul").append(...state.blanks.map(blank=>{
// and we take each of those `wordBlank` input elements we created
// in the state, wrap them in a <li></li> tag
const el = document.createElement(`li`)
el.append(blank);
return el;
}) );
There it is: the WordBlankView
expects an object with a string (the filledString
) and an array of wordBlank
objects. Using those, it creates an internal state, to hold the the filledString
and blanks
(which are those createWordBlankInput
DOM elements).
We create the DOM using the same toHtml
function, and then we can add stuff to that DOM as we like in memory, treating it as a DOM tree in itself. And finally, we return the DOM node we've created.
That's it. That's all there is. Mostly.
Yeah, but Wait.
Yep. We have the DOM and it is complete and self-contained. We have the model, and it is complete and self-contained. Each works independently of the other, so we could do this:
import WordBlanksFactory from './wordBlanksFactory.js';
import WordBlanksView from './wordBlanksView.js';
// we create the data model...
const wbModel = WordBlanksFactory("To be or not to be, that is the __noun__. Whether 'tis __adjective__ to __verb__ the slings and arrows of outrageous fortune...");
// and we create the view, passing in the model
const wbView = WordBlanksView(wbModel);
With that, the view doesn't care that it's getting a data model: it only expects an object with two properties. It doesn't matter what we passed in, so long as we adhered to that interface we defined in the view's function parameters, it's happy.
So now comes the sketchy bit: we can create the DOM, and the data model, but how can we track changes to the one and update them in the other?
Most commonly, folks would look at the DOM we've created, and create the listeners:
wbView.querySelector("input.wordblank-input").forEach((input)=>{
input.addEventListener("input", (event)=>{
const { dataset, value } = event.currentTarget;
wbModel.byId(dataset.id).entry = value;
wbView.querySelector(".filled-pane p").textContent = wbModel.filledString
})
})
And yaaay, it works! Celebration all around! Except nope. Remember, the view can't know about the data directly, and the data can't know about the view directly. We are creating a listener by poking inside the DOM (in effect breaking the encapsulation of the DOM component, and in that listener, we're poking stuff into and out of the data.
That, folks, is pollution. And we have a better way.
What if...
What if we could have the input itself tell us it was doing something? What if we don't attach listeners to the input ourselves, but we attach them to the view component? What if those events themselves told us, in an easy-to-consume way, what we needed to know? And what if we could tell the view to do things too?
We can. We have the CustomEvent API to do just that. Let's create a listener on the inputs themselves, and have them shout a custom event for us:
import toHtml from './toHtml.js';
// this is all exactly the same...
const createWordBlankInput = ({id, type, entry})=>{
const input = toHtml(`
<label class='wordblank-label'><span>${type}:</span>
<input class='wordblank-input'
type='text'
placeholder="${type}"
data-id="${id}"
${entry && `value="${entry}"`}>
</label>`)
// but before we return this, let's add an event handler:
input.querySelector('input').addEventListener("input", (event)=>{
// our custom event. It will bubble, so the main view will also
// be able to respond to it, and the detail property carries our
// custom payload.
const changedEvent = new CustomEvent('wordblank.changed', {
bubbles: true,
detail: {
id: event.currentTarget.dataset.id,
value: event.currentTarget.value,
}
})
// finally, we add our custom event to the event pipeline.
input.dispatchEvent(changedEvent)
})
return input;
}
That is all we need. Just like that, our input element is shouting "Hey! Hey you! I've got a wordblank.changed
for you! It happened on event.detail.id
, which now contains event.detail.value
, if you care!"
Why does that matter? Because our event handling can now change:
wbView.addEventListener("wordblank.changed", (event)=>{
// we can destructure the event.detail to get the data we need,
const {id, value} = event.detail;
// tell the model to update that one value...
wbModel.byId(id).entry=value;
// and finally, notify the view that the data it uses has changed.
const updatedEvent = new CustomEvent("wordblank.updated", {
detail: wbModel
})
wbView.dispatchEvent(updatedEvent);
})
So rather than having to dip our sticky fingers into the view component, we simply listen for an event that the view component itself passes along. We use that event, taking the detail we need, notifying the model to update, and then we fire another custom event back into the view. We do that, because in updating one of the inputs, we have changed the filledString
. So we pass the wordblank.updated
event into the pipeline, passing the data back to the view.
Which means the view needs to be aware of this:
const WordBlankView = ({filledString, wordBlanks})=>{
let state = {
blanks: wordBlanks.map(createWordBlankInput),
filledString
};
const domEl = toHtml(`
<main class='wordblank-game'>
<section class='blanks-pane'>
<header><h2>Word Blanks!</h2></header>
<ul></ul>
</section>
<section class='filled-pane'>
<p></p>
</section>
</main>`);
domEl.querySelector(".filled-pane p").textContent = state.filledString;
domEl.querySelector(".blanks-pane ul").textContent='';
domEl.querySelector(".blanks-pane ul").append(
...state.blanks.map(blank=>{
const el = document.createElement(`li`);
el.append(blank);
return el;
})
);
// and the new stuff: what event we pass *in*, and how to handle it.
domEl.addEventListener("wordblank.updated", (event)=>{
state.filledString = event.detail.filledString;
domEl.querySelector(".filled-pane p").textContent = state.filledString;
});
return domEl
}
that last domEl.addEventListener
is the handler for our custom event. When we notify it, it pulls the filledString
out of the passed object, updates its own state, and updates its own DOM content as needed.
Note that, if we wanted, we could add functions internally to the view. If we wanted to hide the final string, for example, and only display it when the user has filled all the inputs? That is all functionality that could be contained within the view generator. It doesn't impact anything outside that scope, so you could (for extra credit) create a "Show the Quote" or "Edit the Words" toggle, flipping between those two panes. Doing so would not alter its functionality, or trigger any changes to the WordBlanks data.
Recap
Our goal was not to confuse you, but there are quite a few different ideas going on here. The goal was to decouple the data from whatever we use to display that data.
With the WordBlanksFactory
, we could interface that to anything. It doesn't rely on any other component to function, it simply waits for updates, and tells us about those updates if we ask.
With the WordBlanksView
, we have a complete DOM tree, completely separate from the data. It doesn't require a WordBlanksFactory
to work, it simply requires an object that provides the data in a format it knows to expect. It emits and handles custom events, allowing us to talk to and listen to it, as though it were any other interface.
What we've done is weird, I grant. We have one traditional Factory function, with a nice interface, and a second traditional DOM tree, with an interface of its type... and we simply manage the communications between the two.
As always, I look forward to hearing questions, comments, snide remarks. Until next time!
Top comments (0)