JavaScript in Plain English

New JavaScript and Web Development content every day. Follow to join our 3.5M+ monthly readers.

Follow publication

Demystifying the Angular Router

Angular routing may seem like a mysterious black box, but actually, it’s simple. It listens to URL changes to render the correct component and changes the URL when navigation occurs. That’s about it.

Let’s try to re-write it (or a simple version of it), we will create a class that holds the routes that our application knows, the current URL, and an observable that emits URL changes

export class Router {
routes = [];
currentUrl = '';
_routingChangeObservable = {
next: undefined,
subscribe: function (next) {
this.next = next;
},
};

constructor(routes) {}

By the time I am writing this, there is a navigation API that is under the experimental technology flag, so I will not use it, I will instead observe the mutations of the body to watch the URL change, because when the URL changes, the body is at least re-rendered so we will capture the URL change for sure. Here is the code for that

export class Router {
routes = [];
currentUrl = '';
_routingChangeObservable = {
next: undefined,
subscribe: function (next) {
this.next = next;
},
};

constructor(routes) {
this.routes = routes;
if (document) {
this.registerRoutingListner();
} else {
window.onload = this.registerRoutingListner;
}
}

registerRoutingListner() {
var bodyList = document.querySelector('body');

var observer = new MutationObserver(this.onChange.bind(this));

var config = {
childList: true,
subtree: false,
};

observer.observe(bodyList, config);
}

getRouterState() {
return this._routingChangeObservable;
}

onChange() {
console.log(document.location.href);
}

}

Alright, we’ve got our onChange being called when the URL changes. Now, I am going to cheat a little, on every URL change, I will render the corresponding component by putting its HTML in a div with an id of “router-outlet”. This is cheating because angular creates a web component router-outlet and renders the route component as a sibling to it NOT as it’s child. Here is what my onChange function would look like.

export class Router {
routes = [];
currentUrl = '';
_routingChangeObservable = {
next: undefined,
subscribe: function (next) {
this.next = next;
},
};

constructor(routes) {
this.routes = routes;
if (document) {
this.registerRoutingListner();
} else {
window.onload = this.registerRoutingListner;
}
}

registerRoutingListner() {
var bodyList = document.querySelector('body');

var observer = new MutationObserver(this.onChange.bind(this));

var config = {
childList: true,
subtree: false,
};

observer.observe(bodyList, config);
}

onChange() {
const newUrl = document.location.href;

if (this.currentUrl === newUrl) {
return;
}

const matchedRouterState = this.routes.find(
(route) => route.path === new URL(newUrl).pathname.replace('/', '')
);

if (matchedRouterState) {
const routerOutlet = document.getElementById('router-outlet');
routerOutlet.innerHTML = matchedRouterState.component;
}

this.currentUrl = newUrl;

if (this._routingChangeObservable.next) {
this._routingChangeObservable.next(newUrl);
}
}
}

Now let’s just add a couple of methods to our class in order to be able to get and set the route:

export class Router {
routes = [];
currentUrl = '';
_routingChangeObservable = {
next: undefined,
subscribe: function (next) {
this.next = next;
},
};

constructor(routes) {
this.routes = routes;
if (document) {
this.registerRoutingListner();
} else {
window.onload = this.registerRoutingListner;
}
}

registerRoutingListner() {
var bodyList = document.querySelector('body');

var observer = new MutationObserver(this.onChange.bind(this));

var config = {
childList: true,
subtree: false,
};

observer.observe(bodyList, config);
}

getRouterState() {
return this._routingChangeObservable;
}

onChange() {
const newUrl = document.location.href;

if (this.currentUrl === newUrl) {
return;
}

const matchedRouterState = this.routes.find(
(route) => route.path === new URL(newUrl).pathname.replace('/', '')
);

if (matchedRouterState) {
const routerOutlet = document.getElementById('router-outlet');
routerOutlet.innerHTML = matchedRouterState.component;
}

this.currentUrl = newUrl;

if (this._routingChangeObservable.next) {
this._routingChangeObservable.next(newUrl);
}
}

updateRoute(url) {
const BASE_URL = window.location.origin;
const navigationUrl = BASE_URL + url;
if (window.location.href !== navigationUrl) {
document.location.href = BASE_URL + url;
}
}
}

Also, we must not forget to create the div with router-outlet id

<div id="router-outlet">default view</div>

And there you have it, a simple router that has few views, here is a working Stackblitz, you can navigate to /view1 /view2 /view2 only, check the routes.js file to see the routes configuration.

Angular Url Matching

Now that we’ve seen a simple version of a router, let’s understand how the actual angular router works.

The Angular router views the routable portions of an application as a tree of router states*,* which are defined by router configuration **objects.

A Route object defines a relationship between some routable state in your application (components, redirects, etc.), and a segment of a URL

The mapping between the configuration and the actual UrlTree is described in the following diagram

The different parts of the URL are described by the following diagram ( each outlet is a urlSegmentGroup composed of URL segments as described by the second diagram)

The URL tree is composed of UrlSegmentGroups and each UrlSegmentGroup is composed of UrlSegements, QueryParams, and Fragments are accessible by all components in the different outlets, but the urlSegments are specific to each UrlSegmentGroup and routing for each outlet is completely separated.

The router takes a depth-first approach to matching URL segments with paths. This means that the first path of routes to fully consume all the URL segments wins.

Good to know:

  • Anything that starts with : will match everything and the string in that position will be provided in the router.data observable. example: /groups/:groupId/quotes the url /groups/anything-here/quotes will match and router.data will contain groupId
  • Matrix parameters can be passed to each UrlSegmentGroup.
  • it’s possible to set the path to ** which is the wild card that will match everything and anything. (It’s important to put this path last as anything after it will never be routed to)
  • The Snapshot is not refreshed if path match doesn’t change, for example, when a URL changes from /groups/15/quotes/41 to /groups/15/quotes/42, the router will recognize that only the :quoteId has changed so it will simply reuse the current set of components on screen, and will not create a new tree of snapshots.

Here is a stackblitz you can play with to test your theories:

https://stackblitz.com/edit/stackblitz-starters-axfw9d?file=src/main.ts

Navigation Cycle

In angular navigation is triggered by a URL change, always and after that, there is a cycle that runs until all the components are rendered in the right router-outlets. Here is what the cycle looks like

Guards

happens if it’s a canActivate, if it’s a canMatch there is a NavigationSkipped, these two are very different, as Skipped let’s the url matching algorithm continue looking for a fitting path, NavigationCanceled cancels the navigation.

Here is a stackblitz with guards examples

Resolvers

I have seen so many people initiate state in the guards, which is not what the guards are meant to do, resolvers are the ones supposed to do that. They are used to fetch data before routing, they actually happen before the component is loaded, if they error the component won’t even be loaded, also, a resolver that takes too long will produce a poor user experience.

A small stackblitz to test them out

Lazy Loading

Fancy name, but it’s actually not a feature of angular solely, lazy loading can be done in vanilla JS. We will demystify lazy loading by looking at an example of vanilla js that uses lazy loading ( historically it was necessary to use webpack for this but since the introduction of import in ES2016 it is no longer necessary )

Let’s write a module and name it lazy-loaded-module.js, it exports a single function loadComponent, that when executed loads the module

console.log('extra module loaded');

export function loadComponent() {
const router = document.getElementById('router-outlet');
router.innerHTML = `
<style>
p {
background: #2ECC71;
border-radius: 5px;
margin: 5px;
padding: 5px;
color: white;
}
</style>
<p>Lazy Loaded Module Component</p>
`;
console.log('Lazy Loaded Module Js');
}

And in the index.js file, on a click of a button, we load that module like this

function loadModule() {
import('./lazy-loaded-module.js')
.then((module) => {
module.loadComponent();
})
.catch((err) => {
console.log('Failed to load module:', err);
});
}

document.getElementById('btn').addEventListener('click', loadModule);

That’s all there is to lazy loading and you would do the same on Angular.

const routes: Routes = [
{
path: 'items',
loadChildren: () => import('./items/items.module').then(m => m.ItemsModule)
}
];

This articles goal was to demystify angular routing, so that it doesn’t seem scary, if you’d like to read more about how angular routing works, check out this article: The three pillars of angular routing

Thank you for the read!

In Plain English 🚀

Thank you for being a part of the In Plain English community! Before you go:

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

No responses yet

Write a response