Frameworkless JavaScript Part 2: Templates and Rendering

This article is one in a series about writing client-focused JavaScript without the help of libraries and frameworks. It's meant to help developers remember that they can write good code on their own using nothing but native APIs and methods. For more, check out the original article on writing small JavaScript components without frameworks. While that article was a bit more basic, this is intended to really dig into the topic of templates and data binding and how to implement these concepts without frameworks.

One big problem that developers rely on frameworks to solve is templating markup and the myriad methods of getting data into those templates. Here we explore a few ways to do common templating operations with nothing but vanilla JavaScript.

Templating and string interpolation

HTML templates can seem cumbersome and they come in a lot of forms, especially when working in the browser. You can retrieve an HTML template from a local variable, from a file on a remote server, as an aspect of an ajax request or via whatever kind of magic and mystery your favorite framework uses to abstract those templates away from the code you write. No matter which method you use to get the template data, however, in the end the template is just a string with markers in it that you use to do a bit of find-and-replacing. In a most primitive form, a template system is nothing more than a method that takes a template string and an options hash and sticks information from the options hash into the string. An example of this might be the render method as show below:

var insertable_markup = render('<h1>{{ the_title }}</h1>', {  
  the_title: 'hello, world!'
})
// insertable_markup is now '<h1>hello, world!</h1>'

The render method above could do a whole lot of things but in this case all we need it to do is interpolate some strings. And since the template system is nothing but arbitrary string replacement we can code up a method definition like the following:

function render (template, options) {  
  return template.replace(/\{\{\s?(\w+)\s?\}\}/g, (match, variable) => {
    return options[variable] || ''
  })
}

In this example we have a method that just does a global replace on a string. It matches for any word (\w) contained within a set of curly braces (\{\{) and some optional spaces(\s?). The second argument is a function which itself takes as arguments the entire match (which is largely useless here), and the subtext that was matched which should be the name of a property in our options hash. And so with a 3 line method we can do "HTML templating" in a very basic way that is infinitely faster than any more complex framework method and does everything that we usually need. You can see the full, tested code in the example below:

While this is great for just putting data on a page, sometimes we need to bind data between an object/model and the DOM which is what we'll explore in the next article on JavaScript without frameworks: Data Binding.

Extra credit: nested properties in templates

Warning: This is an antipattern. The most reliable, maintainable, speedy, and reusable HTML templates do not allow access to nested properties in interpolated data. This is because nested properties necessarily bind the structure of the data that is passed to the template to the template itself making refactoring slower and less reliable. There's no need to do something like namespace properties in your templates because well-designed templates should never have access to more than one context. Well-designed components are also usually very small so they shouldn't have access to so many variables that they need to be namespaced. If you're passing data to your template which is not in a flat structure which is very likely (our data is rarely in a flat structure), make sure to restructure the data in a controller or view-model. For example in an "employee ID card" template we might have the following data available:

const myEmployee = {  
  id: 'asdfsafdasdfasdfa',
  name: {
    first: 'Bob',
    last: 'Builder'
  },
  role: 'Lead Engineer',
  photos: {
    primary: {
      url: 'foobar.com/img.jpg',
      description: 'A photo of Bob the Builder'
    }
  }
}

And a template that looks roughly like this:

<li data-id="{{ id }}">  
  <img src="{{ image_url }}" alt="{{ image_description }}">
  <p>
    <strong>{{ name }}</strong>
    <small>{{ title }}</small>
  </p>
</li>  

In order to prepare this data, in our controller/view-model we might transform the data to be flattened as follows:

const template_data = {  
  id: myEmployee.id,
  image_url: myEmployee.photos.primary.url,
  image_description: myEmployee.photos.primary.description,
  name: `${myEmployee.name.first} ${myEmployee.name.last}`,
  title: myEmployee.role,
}

This way the data passed to the template is nice and simple so that the templating method doesn't have to do much work and is therefore very fast and testable. But what if we just can't flatten our data? What then? Well, we won't need to do the data transform in the controller. But we will need to make our render method a bit more complicated:

function render (template, options) {  
  return template.replace(/\{\{\s?([\w.]+)\s?\}\}/g, (match, variable) => {
    return variable.split('.').reduce((previous, current) => {
      return previous[current]
    }, options) || ''
  })
}

This time we alter the regex a little bit to support dot notation in our template variable names. The code [\w.]+ means "match any sequence of word characters or periods". Then instead of simply pulling values out of our options hash we use a bit of javascript magic to split the variable name into each of it's parts and reduce them which will return the last value in that sequence. This is a bit more complicated but allows us to write templates with nested properties like this one:

<li data-id="{{ id }}">  
  <img src="{{ photos.primary.url }}" alt="{{ photos.primary.description }}">
  <p>
    <strong>{{ name.first }} {{ name.last }}</strong>
    <small>{{ role }}</small>
  </p>
</li>  

Check out the fully tested method and example below: