Diseñando un router para aplicaciones SPA

Desde que ReactJS se convirtió en mi librería por defecto para desarrollar aplicaciones en Javascript he disfrutado y padecido una de sus características: la libertad para elegir con qué componentes trabajas. Concretamente, la parte de routing ha sido un poco delicada y, aunque inicialmente React-Router me gustó mucho, sus sucesivos (y en mi opinión innecesarios) cambios de API han acabado por cansarme.

Existen muchos otros routers disponibles para aplicaciones Javascript, pero antes de empezar a probarlos a ciegas, quiero hacer un experimento de desarrollo ficción y pensar cómo me gustaría que fuese un router.

No digo que esto acabe en el desarrollo de otro router más (aunque nunca se sabe), pero al menos como ejercicio de diseño puede ser curioso. Incluso si no te interesa mucho el tema de Javascript, creo que puede resultarte entretenida la argumentación alrededor del proceso de diseño.

Algunos en este punto empezarían a escribir su primer test. Ya sabéis, TDD es el único camino verdadero hacia El Buen Diseño™. Aunque no tengo nada en contra de TDD y hay veces que lo aplico, prefiero comenzar con una fase de hamaca (ojo a la charla del enlace, merece la pena verla). Me parece más útil pensar sobre lo que quiero antes de empezar a tirar código. Ya habrá tiempo para aplicar TDD a un nivel más bajo si es que lo considero útil.

Como casi siempre que diseño algo, me gusta empezar por analizar las posibles características o funcionalidades que podría incluir, y dividirlas en las que quiero tener, las que no necesito/quiero tener, y las que estaría bien tener pero no son imprescindibles, al menos inicialmente. Desde esa perspectiva, estas son las funcionalidades que me gustaría tener en mi router ideal.

Lo que quiero y lo que no

No voy a entrar en detalles porque supongo que todos sabemos lo que debería hacer un router, pero básicamente lo que estoy buscando es algo que me permita gestionar distintas rutas en una aplicación cliente para mostrar unas páginas u otras en función de la URL que aparezca en el navegador.

El primer requisito que tengo es:

El router debe ser independiente de las librerías o frameworks que se estén utilizando.

Para mi la funcionalidad del router debería ser ortogonal a la librería utilizada para mostrar cada página, por lo que no debería estar acoplado a ella.

Eso tiene una implicación clara en el diseño y es que a la hora de definir rutas, habrá que hacerlo con primitivas del lenguaje y no con componentes de más alto nivel como controladores de AngularJS o componentes de ReactJS. Por suerte, existe una abstracción perfecta en Javascript que nos permite definir lo que será el «manejador» de cada ruta: una función.

En su versión más simple, una ruta quedará definida por un path y un handler, que será la función que se ejecutará al «navegar» a esa ruta.

Algo parecido a esto:

var route = {
  path: '/users',
  handler: function() {
    // lo que sea...
  }
}

Antes ponía «navegar» así, entre comillas, porque ese es mi segundo requisito:

El router es independiente de las APIs de navegación e histórico de HTML.

Esto puede sonar raro, pero no quiero que el router esté manipulando el objeto History, ni para escuchar eventos y actualizar la ruta actual, ni para realizar navegación entre páginas.

¿Por qué? Porque simplifica mucho el diseño. Prefiero que el router se limite a gestionar una colección de rutas y, dado un path, ejecutar la función asociada al mismo. De dónde venga ese path es completamente anecdótico.

Una vez que tenga un router inicializado con varias rutas, me gustaría poder utilizarlo más o menos así:

// Invoca el handler para la ruta /users
router.dispatch('/users');

Enganchar esto a las APIs del navegador para que se ejecute un dispatch cada vez que se cambie la URL es trivial y creo que no pasa nada porque sea la aplicación la que se encargue de hacerlo.

Además, eso permite aprovechar la librería que ya se estuviera usando para gestionar el API History (si es que se usaba alguna) en lugar de introducir una nueva o tener una funcionalidad duplicada en dos sitios.

Igual que no vamos a escuchar eventos del navegador para activar nuevas rutas, tampoco vamos a interactuar con el navegador para cambiar la URL cuando se activa una nueva ruta. Es decir, el método dispatch que veíamos antes no modifica la URL, sólo ejecuta una función. Si queremos modificar la URL, nuevamente podemos usar directamente el API History desde la aplicación o usar cualquier otra librería para ello.

Habrá a quien no le guste pasar tanta responsabilidad a la aplicación, pero éste es mi router (imaginario) y éstas son mis preferencias.

Quiero mantener el router lo más sencillo posible, pero necesito una mínima gestión de parámetros en las rutas, por lo que:

El router debe soportar parámetros básicos en las rutas.

Esto de los parámetros se puede complicar mucho y es algo en lo que se distinguen los routers potentes y flexibles de los routers que me gustan. Sí, porque los routers que me gustan son básicos y no ofrecen muchas posibilidades, pero a cambio son sencillos de manejar y es fácil entender qué ruta va a qué manejador.

En este router sólo soportaremos parámetros obligatorios. Si aparece un parámetro en la ruta, deberá aparecer en la URL.

Los parámetros siempre serán de tipo String, el router no se encargará de hacer coerción de tipos para ejecutar una ruta u otra.

No habrá validación sobre los parámetros. Los parámetros contienen lo que contienen, y deberá ser el manejador el que valide lo que considere oportuno.

Esto nos llevaría a poder definir rutas del siguiente estilo:

/users ← ruta sin parámetros
/users/:id ← ruta con un parámetro id
/users/:userId/orders/:orderId ← ruta con dos parámetros userId y orderId

Los parámetros debería llegar a los manejadores de una forma que permita trabajar cómodamente con ellos:

var route = {
  path: '/users/:userId/orders/:orderId',
  handler: function({params}) {
    const userId = params.userId,
          orderId = params.orderId;

    // hacer algo con userId y orderId

  }
}

Al no poder incluir restricciones sobre los parámetros, no tendrá sentido definir rutas con este aspecto:

/users/:id      
/users/:name ← No tiene sentido. Siempre se ejecutará la ruta de :id
              porque está definida antes

Para mi no es una limitación importante y lo que se pierde en flexibilidad se gana en claridad a la hora de saber qué ruta se ejecutará y en simplicidad a la hora de implementar el router.

Con esto, ya sólo nos queda un último requisito para que el router sea mínimamente funcional:

El router deberá gestionar el Query String.

Al igual que los parámetros, no quiero nada muy sofisticado, pero si al hacer el dispatch la ruta contiene un Query String, quiero que le llegue al manejador:


// ruta para /users/1?display=full
var route = {
  path: '/users/:id',
  handler: function({params, query}) {
    const id = params.id,
          display = query.display;

    // hacer algo con id y display
  }
}

Tanto los parámetros de la URL como los del Query String pueden estar codificados para que sean válidos en la URL, y es responsabilidad del router decodificarlos antes de pasarlos a los manejadores.

Lo que estaría bien, pero de momento no

Hay algunas funcionalidades que en algún momento podría necesitar y me gustaría tener, pero no las considero esenciales. Si tuviese que implementar este router en la realidad, probablemente no estarían disponibles en la primera versión.

Rutas con nombre

Tener rutas con nombre permitiría al router generar de una forma más sencilla las URLs para «navegar» entre rutas. Imaginad que tenemos una definición así:

var route = {
  path: '/users/:userId/orders/:orderId',
  handler: function() {...}
}

Si queremos generar un enlace para ver el pedido 5 del usuario 2, aquel que genere el enlace debe saber cuál es el formato de la ruta y queda, por tanto acoplado a ella. Si introdujésemos el concepto de rutas con nombre se podría hacer algo así:

var route = {
  name: 'userOrders',
  path: '/users/:userId/orders/:orderId',
  handler: function() {...}
}

var router = // crear router con la ruta anterior

var url = router.getUrl('userOrders', {
  userId: 2,
  orderId: 5
});

// url sería /users/2/orders/5

Sin esta funcionalidad, podemos evitar acoplar la construcción de las Urls al formato de las rutas encapsulándolas en nuestras propias funciones. No es tan cómodo como que el router lo haga automáticamente, pero puede servir.

Detección de la ruta activa

Realmente esta característica tiene poco sentido si no existen rutas por nombre, por lo que de momento se queda fuera. Aun así, sería muy útil poder detectar en ciertos escenarios si una ruta está siendo actualmente visitada o no, por ejemplo para habilitar o deshabilitar el enlace de navegación a esa ruta en un menú, o resaltarlo con un aspecto diferente.

Manejador para rutas no definidas

Estaría bien poder definir un manejador que se ejecutase si se intenta hacer un dispatch y no hay ninguna ruta registrada que cuadre con el path.

Es una característica sencilla de implementar por lo que es posible que acabase entrando en una primera versión, pero tampoco lo considero imprescindible. De hecho, se podría evitar si el método dispatch devolviese un valor falsy en caso de no encontrar ninguna ruta, y dejar que fuese la aplicación la que decidiera qué hacer.

Resumen

Como he dicho antes, este post sólo es un ejercicio de desarrollo ficción sobre cómo creo que me gustaría que fuese un router para aplicaciones SPA.

Una vez que he definido cómo sería el comportamiento de alto nivel, el siguiente paso sería diseñar los componentes internos y, a partir de ahí, empezar a implementarlo, pero es ya son palabras mayores.

Ya veremos hasta dónde llegamos.

6 comentarios en “Diseñando un router para aplicaciones SPA

  1. Estás llevando la simplicidad y la modularidad a su máxima expresión. Propongo que la librería se llame Minimal-Router.

    Al desarrollar cada vez me resulta más cansino arrastrar toneladas de funcionalidad proporcionadas por el «framework» de turno y que jamás se usarán. «npm» me ha parecido un avance enorme para limitar este problema, pero todavía falta una vuelta de tuerca más a nivel de librería.

  2. Pingback: VueJS: Introduciendo rutas en nuestra aplicación | el.abismo = de[null]

Comentarios cerrados.