In this post I'll intoduce you in the creation of a routing system for SPA (Single Page Application), completely from scratch.
Have you already used the Angular RouterModule? or maybe react-router, or any other routing system of any library/framework. One of the limitations I found using these systems, is not being able to specify a dynamic base url.
Example: You created an app using RouterModule. Now you want to publish it, but on different folders of the same website. Think about having a public and a private version to do some tests, or having different folders for each language you support.
What you have to do, to make it work correctly? Well, you need to specify a base_href during the build or in the index.html file, but this mean you need n builds for every subfolder. Too bad!
So our goal is to create a routing system to solve this problem, lightweight, and simple to use. We will also take advantage of Typescript, to make proper types from routes parameters.
First thing first, a bit of html. Let’s create a simple layout , with 2 buttons to navigate between 2 pages, and a div where we are going to load the actual page content.
<button id="btn-home">Home</button>
<button id="btn-page1">Page 1</button>
<div id="outlet">
</div>
Code language: HTML, XML (xml)
Then we attach some events, and we define the API of the class we are going to create.
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)
It may seems a strange syntax, mostly the way we define the different routes.
route`page/${'id'}`
Code language: TypeScript (typescript)
We are going to use tagged template literals, a powerful es6 feature that combined with typescript let use extrapolate route parameter names and use them to create a type.
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)
In the code above, params property will become the following type.
{
userId: string;
projectId: string;
}
Code language: TypeScript (typescript)
If you studied tagged templates, which i reccomend, you’ll know that we can build functions that intercept the various values interpolated in the template literal.
function route<Value extends string>(
template: TemplateStringsArray,
...values: Value[]
): Route<ValuesToParams<Value>> {
return {
match: template.join("([^/]*)"),
values,
};
}
Code language: TypeScript (typescript)
In the above function, template param will be an array composed of the different static parts of the string, and values will be another array composed of the values between ‘${ }‘.
route`page/${'id'}`
// template: ['page/', '']
// values: ['id']
Code language: JavaScript (javascript)
Joining to static parts we can build a regex to match a certain route.
The following pattern, “([^/]*)” match all characters except slash.
const regex = template.join("([^/]*)")
// "page/([^/]*)
Code language: JavaScript (javascript)
Another thing to note, ValueToParams. This type turn an union type in an object composed of the different string values in the 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)
Routing class will handle routing, obviously…
It accept a base url (AKA base_href) in the constructor, and through on method we can add the routes with their callbacks.
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)
The mount method make everything start. It calls the first route change (otherwise users will see nothing initially) and it start listening to popstate event, a part of the History API. This event is called when the user click back or forword browser buttons.
Let’s define some utility functions first.
/**
* 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)
They are lot of functions, i reccomend you to read and try them, or just read the comments to unserstand what they do 😃
Now that we have some building blocks, let’s see how to handle route change.
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)
The onChangePath method does as follow:
Next, the main functionality:
Last thing, the most important. The navigate method, which we attached to the click events at the start.
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)
It takes the base url from the current path, and add the path where we want to go. Then it calls the pushState function.
The pushState function, from the History API, change the visible path and add a new entry in the history, so that if the user go back it will change page without strange things happening.
Last but not least, we call onChangePath method to handle the actual path change, only because popstate it’s not automatically called on pushState.
I invite you to read the code on this little example project that I published on github. Moreover for every question, do not hesitate to reach me out on every social, or just open an issue on github 🐛
Come back soon!