Handlebars

<< Gestión de mapas

Trabajar con el DOM utilizando métodos como document.createElement, document.append, etc. es lo más eficiente a nivel de rendimiento pero es poco práctico para proyectos grandes, ya que dificulta la mantenibilidad y legibilidad del código.

Realmente, lo recomendado para proyectos que no sean pequeños, sería utilizar un framework de cliente (Angular, React, Vue, Solid, Svelte, Qwik, etc.), pero es no es competencia del curso actual, por lo que optaremos por una solución que aunque no sea muy eficiente, nos facilitará el trabajo a la hora de generar contenido para el DOM a partir de una serie de datos. Estamos hablando de los motores de plantillas.

Entre las diferentes opciones que hay, vamos a optar por una muy simple de utilizar, aunque limitada y no muy eficiente. Estamos hablando de Handlebars.

¿Qué es Handlebars?

Handlebars es un motor de plantillas simple. Se basa en preparar un fragmento de código HTML con expresiones encerradas entre dobles llaves {{}}. Posteriormente ese fragmento se compila generando una función JavaScript a la cual le pasamos un objeto con los datos para la plantilla,  y que tendrá los valores que serán procesados y sustituidos por la misma. 

Finalmente, devolverá un string con el HTML resultante de aplicarle dichos datos a la plantilla. Este HTML lo tendremos que añadir al DOM por medio de una función como innerHTML. Esto puede ser problemático a nivel de rendimiento, por lo que se recomienda utilizar el elemento HTML template para ello como veremos más adelante, en lugar de añadir directamente el resultado al DOM. 

Handlebars se encarga de escapar y eliminar fragmentos de códigos maliciosos como los que podrían provocar un ataque XSS, por lo que el código debería ser seguro para usar incluso con innerHTML.

Las plantillas Handlebars normalmente se crean en archivos con extensiones .hbs o .handlebars.

Instalar Handlebars

Para instalar Handlebars simplemente ejecutamos npm i handlebars.

Sustitución de valores

Para sustituir un valor en una plantilla Handlebars, simplemente pondremos entre dobles llaves el nombre de la propiedad del objeto que se recibe:

<p>{{name}}</p>
<p>{{age}}</p>

También podemos especificar propiedades anidadas:

<p>{{name}}</p>
<p>{{age}}</p>
<p>{{address.street}}, {{adress.city}}</p>

Expresiones condicionales

En una plantilla, podemos usar expresiones condicionales como if..else. Se puede usar de forma limitada porque no puede evaluar expresiones, por lo que solo verificamos si un valor existe o no (o es equivalente a falso → null, 0, '').

<p>{{name}}</p>
<p>{{age}}</p>
{{#if address}}
  <p>{{address.street}}, {{adress.city}}</p>
{{else}}
  <p>No address!</p>
{{/if}}

En lugar de la expresión if, podemos utilizar unless que hace lo contrario, muestra el contenido si la propiedad no existe o tiene valor vacío.

El bloque with

Utilizando el bloque with, podemos acceder a propiedades anidadas sin necesidad de utilizar la propiedad especificada en el bloque como prefijo:

<p>{{name}}</p>
<p>{{age}}</p>
{{#with address}}
  <p>{{street}}, {{city}}</p>
{{/with}}

Iteradores (each)

Si necesitamos iterar a través de una propiedad que es un array, usaremos el bloque each, que repite el contenido tantas veces como posiciones tenga este array. Dentro del bloque, accederemos a las propiedades de cada objeto dentro del array.

<p>{{name}}</p>
<p>{{age}}</p>
{{#each addresses}}
  <p>{{street}}, {{city}}</p>
{{/each}}

Si el array contiene valores (cadenas, enteros, otros arrays) y no objetos, podemos referenciar el valor usando la propiedad this. También podemos usar propiedades como @index que contendrá la posición actual:

<p>{{name}}</p>
<p>{{age}}</p>
{{#each phones}}
  <p>Phone{{@index}}: {{this}}</p>
{{/each}}

Precompilar plantillas

Handlebars no es 100% compatible con ESM (EcmaScript Modules), es decir, con la sintaxis import/export. Sí que permite importar la librería con la instrucción import, pero tendremos que compilar las plantillas (pasar de plantilla HTML a JavaScript) desde código en tiempo de ejecución, con el coste de rendimiento y tiempo que puede suponer.

Además, la plantilla la tenemos que almacenar como string en el código, en lugar de en un archivo aparte. Con la sintaxis clásica de NodeJS (CommonJS), que utiliza require sí se puede importar el archivo con la plantilla, pero no con la sintaxis moderna de módulos EcmaScript.

import Handlebars from "handlebars";
const template = Handlebars.compile("<p>Name: {{name}}</p>");
console.log(template({ name: "Nils" })); // <p>Name: Nils</p>

Una buena solución es precompilar las plantillas antes de lanzar la aplicación (o de empaquetarla para producción). Esto se hace mediante línea de comandos con el comando handlebars. Lamentablemente, a la hora de escribir esto, no tiene soportes para módulos ES, es decir, las funciones JavaScript generadas a partir de las plantillas no se pueden importar con la instrucción import.

Vamos a solucionar esto modificando un par de líneas del archivo compilado utilizando un pequeño script. Para ello debemos instalar el paquete replace-in-file:

npm i -D replace-in-file

En este ejemplo, los archivos con las plantillas Handlebars (.hbs) están situados en la carpeta templates/. A partir de estos archivos, las plantillas compiladas se crearán en un archivo llamado compiled.js en la raíz del proyecto. Vamos a crear otro archivo en la raíz llamado hbs-replace.js para hacer las modificaciones oportunas y poder importar las plantillas desde nuestro código. 

import {replaceInFileSync} from 'replace-in-file';

const options = {
    files: './compiled.js',
    from: ['var Handlebars = require("handlebars");', /$/],
    to: ['import Handlebars from "handlebars/runtime";', 'export default templates;'],
}

replaceInFileSync(options);

A la hora de precompilar plantillas debemos especificar la sintaxis CommonJS con la opción -c "handlebars". Esto genera una instrucción require("handlebars") en el archivo resultante que debemos cambiar por el import correspondiente. También se genera al final del archivo una instrucción para exportar el objeto templates, que contiene todas las plantillas compiladas.

Estos serían los scripts necesarios en el archivo <b>package.json (estamos en un proyecto Vite):

  "scripts": {
    "handlebars": "handlebars -c \"handlebars\" templates/*.hbs -f compiled.js --esm handlebars",
    "posthandlebars": "node hbs-replace.js",
    "prestart": "npm run handlebars",
    "start": "vite",
    "prebuild": "npm run handlebars",
    "build": "vite build",
    "preview": "vite preview"
  }

Estas serían las modificaciones del archivo compiled.js:

import Handlebars from "handlebars/runtime";  
// Código sin modificar
export default templates;

"handlebars/runtime" es una versión más ligera de handlebars que no tiene la funcionalidad de compilar, solo de ejecutar las plantillas. Gracias a que hemos precompilado las plantillas, podemos utilizar esta versión.

Añadir resultado al DOM

Cuando ejecutemos la aplicación con el script start, o build, automáticamente se precompilarán las plantillas y se modificarán. Así las importaremos en nuestro código y las utilizaremos:

import templates from './compiled.js';
let html1 = templates['plantilla1.hbs']({nombre: 'Juan'});
let html2 = templates['plantilla2.hbs']({precio: 24});

A la hora de añadir el HTML generado al DOM, se recomienda utilizar un elemento template para procesar el HTML sin afectar al documento visualizado. Este elemento permite utilizar innerHTML, y posteriormente acceder al fragmento del DOM generado para insertarlo al documento.

import templates from './compiled.js';

//...

function showProduct(product) {
  const priceFormat = new Intl.NumberFormat("es", {
    style: "currency",
    currency: "EUR",
  }).format(product.price);

  const availFormat = new Intl.DateTimeFormat("es").format(
    new Date(product.available)
  );

  // Le pasamos a la plantilla los datos. Algunos los hemos formateado.
  const prodHTML = templates['product.hbs']({...product, price: priceFormat, available: availFormat });

  const template = document.createElement("template");
  template.innerHTML = prodHTML;

  // Procesamos el HTML generado, principalmente para añadir eventos
  const tr = template.content.firstChild;
  const btnDelete = template.content.querySelector("button");

  btnDelete.addEventListener("click", async (e) => {
    await productsService.delete(product.id);
    tr.remove();
  });

  // Finalmente añadimos el HTML generado al DOM
  productsTable.querySelector("tbody").append(template.content);
}

//...

<< Gestión de mapas