HTML templating with ES6 template strings

[2015-01-15] esnext, dev, template literals, javascript
(Ad, please don’t block)

Despite their name, template strings in ECMAScript 6 (ES6) are more like a different kind of function call than a mechanism for defining templates. This blog post explains how you can still use them for HTML templating. It is based on an idea by Claus Reinke.

If you are not familiar with ES6 template strings, you may want to consult [1] before reading on.

Defining and using a template  

You define a template as follows. It relies on the template handler html (a function that we’ll look at later).

const tmpl = addrs => html`
    <table>
    ${addrs.map(addr => html`
        <tr>$${addr.first}</tr>
        <tr>$${addr.last}</tr>
    `)}
    </table>
`;

The trick is that the the inside of each substitution ${} can be an arbitrary expression. We use map() to create an array of strings inside the first substitution (which html() turns into the appropriate string). Thanks to arrow functions [2], the callback of map() is nicely concise.

Inside the callback, we are “invoking” html again. Therefore, calling the function tmpl leads to several calls of the function html.

The double dollar sign in $${addr.last} is not ES6 syntax, it is simply the normal text “$” in front of the substitution ${addr.last}. But the template handler treats a substitution differently if it is preceded by a dollar sign – it HTML-escapes the string returned by it.

The template is used like this:

console.log(tmpl([
    { first: '<Jane>', last: 'Bond' },
    { first: 'Lars', last: '<Croft>' },
]));

This code produces the following output:

    <table>
    
        <tr>Jane</tr>
        <tr>&lt;Bond&gt;</tr>
    
        <tr>&lt;Lars&gt;</tr>
        <tr>Croft</tr>
    
    </table>

Note that the angle brackets around Jane and Croft are escaped, whereas <tr> isn’t.

The template handler  

The template handler is surprisingly simple:

function html(literalSections, ...substs) {
    // Use raw literal sections: we don’t want
    // backslashes (\n etc.) to be interpreted
    let raw = literalSections.raw;

    let result = '';

    substs.forEach((subst, i) => {
        // Retrieve the literal section preceding
        // the current substitution
        let lit = raw[i];

        // In the example, map() returns an array:
        // If substitution is an array (and not a string),
        // we turn it into a string
        if (Array.isArray(subst)) {
            subst = subst.join('');
        }

        // If the substitution is preceded by a dollar sign,
        // we escape special characters in it
        if (lit.endsWith('$')) {
            subst = htmlEscape(subst);
            lit = lit.slice(0, -1);
        }
        result += lit;
        result += subst;
    });
    // Take care of last literal section
    // (Never fails, because an empty template string
    // produces one literal section, an empty string)
    result += raw[raw.length-1]; // (A)

    return result;
}

Each substitution is always surrounded by literal sections. If the template string ends with a substitution, the last literal section is an empty string. Accordingly, the following expression is always true:

literalSections.length === substs.length + 1

That’s why we need to append the last literal section in line (A).

The following is a simple implementation of htmlEscape().

function htmlEscape(str) {
    return str.replace(/&/g, '&amp;') // first!
              .replace(/>/g, '&gt;')
              .replace(/</g, '&lt;')
              .replace(/"/g, '&quot;')
              .replace(/'/g, '&#39;')
              .replace(/`/g, '&#96;');
}

More ideas  

There are more things you can do with this approach to templating:

  • This approach isn’t limited to HTML, it would work just as well for other kinds of text. Obviously, escaping would have to be adapted.

  • if-then-else inside the template can be done via the ternary operator (cond?then:else) or via the logical Or operator (||):

    $${addr.first ? addr.first : '(No first name)'}
    $${addr.first || '(No first name)'}
    
  • Some of the leading whitespace in each line can be trimmed if the first non-whitespace character in the first line defines where the first column is.

  • Destructuring [3] can be used:

    ${addrs.map(({first,last}) => html`
        <tr>$${first}</tr>
        <tr>$${last}</tr>
    `)}
    

Should I use this in production code?  

Use this approach if you need something quick and dirty. It is not as readable as the template syntax supported by Handlebars.js and similar templating engines. On the other hand, it is lightweight and control flow mechanisms (loops and if-then-else) are easy to understand, because they are just JavaScript.

Further reading  


  1. Template strings: embedded DSLs in ECMAScript 6 ↩︎

  2. ECMAScript 6: arrow functions and method definitions ↩︎

  3. Multiple return values in ECMAScript 6 ↩︎