Minimal Router: un router minimalista para aplicaciones SPA

En el último post explicaba cómo me gustaría que fuese un router para aplicaciones SPA y diseñaba un router extremadamente simple pero que se ajustaba bastante a lo que creo que debería hacer una librería de este tipo. Mi intención original era quedarme ahí, pero disfrutar de unos días de descanso en la España profunda me ha permitido entretenerme implementando una primera versión.

El resultado es Minimal Router (Miguel, gracias por el nombre), y en este post vamos a aprovechar para ver cómo está implementado y, sobre todo, por qué está implementado así.

Igual que me parecía entretenido analizar las decisiones de diseño, creo que puede ser entretenido analizar las decisiones de implementación, pese al componente subjetivo que tienen este tipo de decisiones.

El diseño del API

En el post con el diseño de Minimal Router ya quedaba bastante claro el tipo de API que me gustaría tener, y el API actual es prácticamente idéntica.

El Router es un objeto que expone determinados métodos para definir rutas y hacer el dispatch de urls. Durante un tiempo jugué con la idea de hacer un router más «funcional», separando por completo estado de ejecución, pero finalmente me pareció más idiomático en Javascript encapsular un poco de estado mutable (básicamente la lista de rutas) dentro de un objeto con un par de métodos para operar sobre él.

Eso hace que la definición de rutas no se haga con objetos literales, como aparecía en el diseño, sino mediante un método en el Router que es quien, por debajo, crea esos objetos:

const router = new Router();

router.add('/users', function() {
  // ...
});

La opción de pasarle al método add un único objeto con la definición de la ruta era tentadora porque permitía construir los objetos de forma independiente del router y luego añadirlos, pero creo que merece la pena tener este método y escribir un poco menos. A fin de cuentas, siempre se podrían construir los objetos de forma independiente y tener un único punto encargado de hacer el registro en el Router a partir de los objetos con la definición de rutas, consiguiendo ese aislamiento máximo entre aplicación y router.

El método permite además añadir rutas con nombre, uno de los puntos Estaría bien tener del post anterior:

const router = new Router();

router.add('user-by-city', '/users/city/:cityName', function({params}) {
  const city = params.cityName;
  // ...
});

Las rutas con nombre son un añadido final para facilitar la detección de la ruta activa y para poder construir fácilmente urls para una ruta a partir de su nombre y sus parámetros. Una vez montado todo el Router, añadirlas suponía unas 10 líneas de código y no incrementaba en exceso la complejidad, por lo que decidí incluirlas pese a que mi objetivo sigue siendo mantener el router lo más simple posible.

El método dispatch permite «activar» una ruta y es exactamente igual que el que veíamos en el post anterior:

router.dispatch('/users/city/madrid?sort=desc')

Para construir la url asociada una ruta a partir de sus parámetros, podemos usar el método formatUrl:

const url = router.formatUrl('users-by-city', {cityName: 'madrid'}, {sort: 'desc'})
// url === '/users/city/madrid?sort=desc'

Si queremos conocer la ruta activa sin realizar un dispatch, debemos utilizar el método getCurrentRoute:

const current = router.getCurrentRoute('/users/city/madrid');
// current.name === 'users-by-city'
g

Aquí se podría devolver sólo el nombre de la ruta asociada a la url, pero me pareció más interesante devolver el objeto entero, que incluye también el path y el handler por si resultaba de utilidad. Implica exponer algo más de informmación, pero a fin de cuentas es información que ya se suministró de forma externa al router.

Por último, al empezar a jugar con Router me pareció cómodo poder definir un prefijo para las rutas, con el fin de simplificar el uso con navegadores que no soporten el API History de HTML5 en los que tenemos que «simular» las rutas con un hash (#).

router.setPrefix('#');

const url = router.formatUrl('users-by-city', {cityName: 'madrid'}, {sort: 'desc'})
// url === '#/users/city/madrid?sort=desc'

router.dispatch('#/users/city/madrid?sort=desc');
// dispatch to users-by-city route

Cuando se activa el prefijo, el router es capaz de trabajar con urls tal cual están definidas al añadir rutas, o con urls que incluyen el prefijo.

Como ya expliqué en el post sobre el diseño del router, quería que Minimal Router fuese independiente de las APIs del browser, por lo que Minimal Router no se integra automáticamente con ellas. No obstante, hacer que responda a cambios en la url es tan simple como gestionar el evento window.onpushstate:

window.onpopstate = function(event) {
  var path = document.location.hash;
  if (document.location.search.length) {
    path += '&' + document.location.search;
  }
  router.dispatch(path);
};

Un poco de porno

Una vez que hemos hablado de las cosas útiles, pasamos a lo que nos gusta. La pornografía de código.

Técnicamente el router es muy simple (era uno de los objetivos), y a la hora de implementarlo, lo más complicado (y tampoco es que sea muy complicado) es la gestión de parámetros.

Al final todo se reduce a ser capaz de convertir una url como ésta:

/users/:userId/contact/:contactId

En algo que podamos usar para detectar rutas y extraer parámetros de la url.

Si estuviésemos hablando de un router para servidor, preparado para resolver cientos de miles de rutas por segundo, lo lógico habría sido emplear algún sistema basado en tries, pero afortunadamente nuestros requisitos de rendimiento son ínfimos: no vamos a despachar ni una ruta por segundo y, comparado con lo que tardará en modificarse el DOM, lo que hagamos para el dispatch de la ruta importa poco.

Por tanto, lo más sencillo es convertir la url que teníamos antes en una expresión regular:

/users/(:[^\/]+)/contact/(:[^\/]+)

A partir de esa expresión regular, es trivial detectar la ruta a la que hay que hacer el dispatch y extraer los parámetros de la misma. Lo más complicado es casi ser capaz de escribir correctamente los distintos caracteres de escape al formar la expresión regular (ahí he echado mucho de menos los verbatim string de C#).

Para esta parte utilicé TDD porque me resultaba cómodo a la hora de ir construyendo la expresión regular y dándole soporte a distintos casos. No soy un purista de TDD, pero hay escenarios como éste en el que funciona bastante bien. Los tests acabaron repartidos en un par de ficheros, uno con tests de más bajo nivel y otro con tests sobre el API completa del router.

Teniendo clara la expresión regular, el resto del código es más bien «pinta y colorea», rellenando funciones muy simples que básicamente recorren listas (de urls, de matches de la expresión regular, de parámetros en el query string…).

Si echáis un vistazo al código, veréis que se hace un uso bastante intensivo de las características de ES2015. De todas ellas, me quedo sin duda con el destructuring, al que estaba muy acostumbrado en clojure y, aunque el de Javascript es más limitado, ayuda mucho a escribir menos código y, para mi gusto, más legible.

Un problema relacionado con eso es que no he sabido hacer convivir de una forma limpia los export default de ES2015 con los require de ES5 y el uso en el browser. Al final la solución que he adoptado (cutre donde las haya) es duplicar el default, lo que hace que el uso sea ligeramente distinto en función del tipo de módulo que se esté importando:

// ES2015
import Router from 'minimal-router';

const router = new Router();

// ES5
var Router = require('minimal-router').Router;

var router = new Router();

// Browser
<script src='path/to/minimal-router.min.js'></script>

var router = new Router.Router();

Es sumamente feo, así que si alguien sabe cómo mejorar esto y me echa una mano, se lo agradeceré.

El sistema de compilación está basado en scripts de npm, desde donde que se lanza la compilación con browserify+babel y los tests con mocha. En un proyecto tan simple como éste tampoco merece la pena complicarse mucho y al final el flujo de trabajo es ejecutar npm run dev y empezar a ver cómo se lanzan los tests automáticamente cada vez que guardas un fichero. Teniendo en cuenta que los tests se ejecutan en menos de 100ms, es extremadamente ágil.

Qué falta

Para convertir esto en un proyecto real, sería necesario mejorar el control de errores. Eso incrementaría el código bastante (probablemente un 20-30%), pero haría que fuese mucho más usable en la vida real.

Actualmente si hay más de una ruta posible para una url, se elige la primera. O si se intenta construir una ruta sin indicar su handler, se genera un error en el dispatch en lugar de en la definición.

Son pequeños detalles que no impiden utilizar la librería, pero hacen que sea más complicado depurar problemas y, para mi, marcan la diferencia entre un experimento y algo «production ready». Todo se andará.

De momento, si queréis jugar con el código podéis encontrarlo en github y, si sois realmente valientes, podéis incluso instalar el paquete npm y probarlo en una aplicación.

Un comentario en “Minimal Router: un router minimalista para aplicaciones SPA

  1. Gran iniciativa y gran nombre ;-D Sólo he ojeado el código un poco por encima, pero está interesante, sobre todo para los que todavía no nos hemos puesto con ES2015.

Comentarios cerrados.