Creare un router stile Angular, ma meglio

In questo articolo ti introdurrò nella creazione di un sistema di routing per siti web SPA (Single Page Application) completamente da zero, senza alcuna dipendenza.

A fancy long exposure road image

In questo articolo ti introdurrò nella creazione di un sistema di routing per siti web SPA (Single Page Application) completamente da zero, senza alcuna dipendenza.

Potresti aver già usato il RouterModule di Angular, o react-router, o qualsiasi altro “routing system” di qualsiasi libreria/framework. Uno dei limiti che ho notato nell’usare questi sistemi è il non poter specificare un base url dinamico.

Esempio: hai creato la tua applicazione con il RouterModule, adesso la vuoi quindi pubblicare, però sotto diverse cartelle dello stesso sito. Immagina di avere una versione pubblica ed una privata per fare dei test, oppure di avere diverse sotto cartelle per ogni lingua che vuoi supportare.

  • www.dominio.it/italiano/…dist_files
  • www.dominio.it/inglese/…dist_files
  • www.dominio.it/esperanto/…dist_files

Quello che devi fare, per far funzionare correttamente questa configurazione, è specificare un base_href, in modo che Angular capisca come deve costruire gli url quando usi un routerLink. Ma questo vuol dire anche che devi fare tante build quante sono le sottocartelle su cui vuoi pubblicare l’app.

Quindi il nostro obbiettivo sarà quello di creare un sistema di routing che risolva questo problema, che non abbia dipendenze esterne, e che sia semplice da usare, in qualsiasi ambiente.

Un’altro proposito sarà quello di sfruttare i tipi di Typescript, per realizzare un sistema di routing che non ti faccia impazzire o sbagliare.

Iniziamo

Come prima cosa un po’ di html. Andiamo a creare un layout semplice, con due pulsanti per navigare tra le 2 pagine, e un div che chiameremo “outlet” dove andremo a caricare l’html della pagina corrente.

<button id="btn-home">Home</button>
<button id="btn-page1">Page 1</button>

<div id="outlet">
</div>
Code language: HTML, XML (xml)

Poi definiamo qualche evento e le api della classe che andremo a creare in seguito.

const routing = new Routing(route`${"lang"}`);

routing.on(route``, ({}, base) => {
outlet.innerHTML = `
<h1>Home</h1>
<h2>Language: ${base.lang}</h2>
`
;
});

routing.on(route`page/${"id"}`, (params, base) => {
outlet.innerHTML = `
<h1>Page ${params.id}</h1>
<h2>Language: ${base.lang}</h2>
`
;
});

routing.mount();

btnHome.addEventListener("click", () => routing.navigate("/"));
btnPage1.addEventListener("click", () => routing.navigate("/page/1"));
Code language: TypeScript (typescript)

Vi potrebbe sembrare una sintassi un po’ strana, soprattuto il modo in cui vengono definite le diverse rotte.

route`page/${'id'}`
Code language: TypeScript (typescript)

Per creare questa sintassi andremo a sfruttare le stringe template taggate, un potente mezzo di es6 che combinato con i tipi di typescript ci permette di estrarre dalla rotta i diversi parametri ed usarli per validare le diverse callback.

routing.on(route`user/${'userId'}/${'projectId'}`, (params) => {

console.log(params.proectId);
// Error: Property proectId does not exists on type ...

console.log(params.projectId);
// Autocompleted by ide
});
Code language: TypeScript (typescript)

Come vedete dal codice sopra ci sarà un errore di compilazione se sbagliamo a scrivere la proprietà, altrimenti nel mentre che scriviamo gli ide (ad esempio vscode) ci suggeriranno la giusta parola.

Questo perchè, come vedremo dopo, la proprietà params assumerà il tipo

{
userId: string;
projectId: string;
}
Code language: TypeScript (typescript)

La funzione ‘route’

Se avete studiato un po’ le stringhe template taggate, cosa che vi consiglio, saprete che possiamo costruire delle funzioni che intercettano i vari parametri che si passano dentro alla stringa.

function route<Value extends string>(
template: TemplateStringsArray,
...values: Value[]
): Route<ValuesToParams<Value>>
{
return {
match: template.join("([^/]*)"),
values,
};
}
Code language: TypeScript (typescript)

Nella funzione sopra infatti il parametro template sarà un array composto dalle diverse parti statiche della stringa, mentre values sarà un array composto dalle parti nel mezzo a ${ }.

route`page/${'id'}`
// template: ['page/', '']
// values: ['id']
Code language: JavaScript (javascript)

Facendo poi il join delle diverse parti statiche possiamo creare una regex che farà il match di una rotta.

La parte “([^/]*)” fa il match di qualsiasi carattere ad esclusione dello slash.

const regex = template.join("([^/]*)")
// "page/([^/]*)
Code language: JavaScript (javascript)

Altra cosa da notare è il tipo ‘ValueToParams’. Questo trasforma uno union type in un oggetto composto da tante proprietà quante ne trova nello union.

type ValuesToParams<Value extends string> = {
[Key in Value]: string;
};

ValueToParams<"id"> = {id: string}
ValueToParams<"id" | "lang"> = {id: string, lang: string}
Code language: HTML, XML (xml)

Classe ‘Routing’ ed evento ‘popstate’

Adesso andiamo a definire la classe Routing, che nel costruttore accetta il base url (AKA base_href). Attraverso, invece, il metodo ‘on’ è possibile definire altre rotte ed il relativo callback, che viene invocato quando l’utente accede a quella rotta.

export class Routing<BaseParams> {
constructor(private readonly base: Route<BaseParams>) {}

private readonly routes: RouteWithCallback<any, BaseParams>[] = [];

on<Params>(
route: Route<Params>,
callback: RouteCallback<Params, BaseParams>
): this {
this.routes.push({ route, callback });
return this;
}

mount(): void {
this.onChangePath();

window.addEventListener("popstate", () => {
this.onChangePath();
});
}

// ...
}
Code language: TypeScript (typescript)

Il metodo mount fa partire il tutto, richiama il primo cambio iniziale di rotta (altrimenti allo startup del sito non si vedrebbe niente) ed inizia ad ascoltare l’evento popstate, che fa parte delle History API. Questo evento viene richiamato quando l’utente nel proprio browser cambia pagina, ad esempio cliccando il pulsante back o forward.

Gestire il cambio di path

Prima di tutto definiamo alcune funzioni che ci torneranno utili in seguito.

/**
* Remove initial and final / from a string
*/

function removeSlash(path: string): string {
return path.replace(/(^\/)|(\/$)/g, "");
}

/**
* Check if a route match a path,
* and get interpolated parameters.
*/

function matchPath(route: Route<any>, path: string): string[] | null {
const regex = RegExp("^" + route.match + "$");
return path.match(regex)?.slice(1) || null;
}

/**
* Check if a base route match a path,
* and get interpolated parameters.
*/

function matchBase(route: Route<any>, path: string): string[] | null {
const regex = RegExp("^" + route.match);
return path.match(regex)?.slice(1) || null;
}

/**
* Get named parameters from a route match
*/

function getParams<Params>(route: Route<Params>, match: string[]): Params {
return match.reduce((acc, param, index) => {
return {
...acc,
[route.values[index]]: param,
};
}, {}) as Params;
}

/**
* Get current path, by removing slash and query params
*/

function getCurrentPath(): string {
let path = decodeURI(window.location.pathname);
path = removeSlash(path);
path = path.replace(/\?(.*)$/, "");
return path;
}
Code language: TypeScript (typescript)

Sono diverse funzioni, ti consiglio di leggerle e provarle con calma se vuoi capire come funzionano, altrimenti leggi solo i commenti per capire cosa fanno 😃

Adesso che abbiamo tutta la struttura pronta vediamo come gestire il cambio di path.

class Routing<BaseParams> {
// ...

onChangePath() {
const path = getCurrentPath();
const baseMatch = matchBase(this.base, path);
if (!baseMatch) {
throw Error("Base path cannot be matched");
}

const relativePath = removeSlash(path.replace(RegExp(this.base.match), ""));
const baseParams = getParams(this.base, baseMatch);

for (const { route, callback } of this.routes) {
const match = matchPath(route, relativePath);
if (match) {
const params = getParams(route, match);
callback(params, baseParams as any);
return;
}
}
}

// ...
}
Code language: TypeScript (typescript)

Questo nuovo metodo onChangePath, come prima cosa, va a cercare il base url nella path corrente, se non lo trova lancia un errore. Va poi a rimuovere la base e a prendersi i parametri.

In seguito, la parte più importante, scorre tutte le routes e per ognuna di essa controlla se fa match con la path relativa (quindi senza base url). Se trova un match si ferma, estrae i parametri e richiama il callback della rotta.

Si ma come faccio a navigare?

Beh si, manca solo l’ultimo metodo, forse davvero il più importante. Il metodo navigate, che abbiamo richiamato all’inizio attacandoci agli eventi click.

class Routing {
// ...

navigate(path: string) {
const base = this.getCurrentBase();
path = removeSlash(path);
const completePath = `${base ? "/" + base : ""}/${path}`;
window.history.pushState({}, "", completePath);
this.onChangePath();
}

private getCurrentBase(): string {
const path = getCurrentPath();
const match = path.match(RegExp("^" + this.base.match));

if (!match) {
throw Error("Base path cannot be matched");
}

return match[0];
}
}
Code language: TypeScript (typescript)

Questo metodo prende il base url completo di parametri e non, ci aggiunge la path dove vogliamo andare, e chiama la funzione pushState.

La funzione pushState, che fa sempre parte delle History API, cambia la path visibile all’utente e aggiunge un nuovo “entry” nella “history stack”. Questo vuol dire che se l’utente cliccasse poi sul tasto back del browser ritornerebbe alla pagina precedente, senza salti strani e soprattutto senza ricaricare la pagina.

Ultimo ma non meno importante, richiamiamo il metodo onChangePath poichè l’evento popstate non viene richiamato automaticamente dalla funzione pushState.

TL;DR

  • Abbiamo creato la funzione route per tipizzare i parametri
  • Abbiamo visto come ascoltare l’evento popstate
  • Abbiamo visto come usare le regex per controllare le rotte
  • Infine abbiamo usate pushState per cambiare rotta

Ti invito a leggere e approfondire il codice su questo piccolo progetto di esempio che ho pubblicato su github. Inoltre per qualsiasi dubbio, non esitare a scrivermi o aprire un issue su github.

A presto!