This is the first in the series of posts explaining how to implement a routing system for Glimmer.js apps. In this post we are going to create a Router
component for Glimmer. Since Glimmer is only a rendering engine, it doesn't have the full-fledged routing capabilities like Ember.js. Lately I have been playing with the glimmer-experimental libraries for my pet projects, I needed a routing system for those apps. This is my attempt at creating one.
So I have searched for similar implementations with Glimmer and I found out this realword-example-app by Dan Freeman, where he conditionally render the components based on the active route manually. I wanted a more refined and automated approach which can be scaled to a lot of routes or pages.
I have been heavily inspired by the Routing libraries used by other JS Frameworks like React, Svelte, etc. I wanted to build one for Glimmer. So if you look at how react-router is implemented, you layout your components and their respective paths inside the Router
component.
import React from "react";
import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";
export default function App() {
return (
<Router>
<div>
<nav>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
</ul>
</nav>
{/* A <Switch> looks through its children <Route>s and
renders the first one that matches the current URL. */}
<Switch>
<Route path="/about">
<About />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</div>
</Router>
);
}
In the same manner, Svelte has the svelte-routing library which is kind of similar.
<!-- App.svelte -->
<script>
import { Router, Link, Route } from "svelte-routing";
import Home from "./routes/Home.svelte";
import About from "./routes/About.svelte";
import Blog from "./routes/Blog.svelte";
export let url = "";
</script>
<Router url="{url}">
<nav>
<Link to="/">Home</Link>
<Link to="about">About</Link>
<Link to="blog">Blog</Link>
</nav>
<div>
<Route path="blog/:id" component="{BlogPost}" />
<Route path="blog" component="{Blog}" />
<Route path="about" component="{About}" />
<Route path="/"><Home /></Route>
</div>
</Router>
So for our Glimmer apps, the routing logic is going to be something like this:
App.js
import Component, { hbs, tracked } from "@glimmerx/component";
import { Router, Route, Link } from "./GlimmerRouter.js";
import Home from './pages/Home.js';
import About from './pages/About.js';
import Contact from './pages/Contact.js';
import "./App.css";
export default class App extends Component {
Home = Home;
About = About;
Contact = Contact;
static template = hbs`
<nav>
<ul>
<li><Link @to="/">Home</Link></li>
<li><Link @to="/about">About</Link></li>
<li><Link @to="/contact">Contact</Link></li>
</ul>
</nav>
<main>
<Router>
<Route @path="/" @component={{this.Home}}/>
<Route @path="/about" @component={{this.About}}/>
<Route @path="/contact" @component={{this.Contact}}/>
</Router>
</main>
`;
}
We are going to see how we can build each of those components, Router
, Route
, and Link
using Glimmer components.
Router Registry
The first thing we need for the routing system to work is a Router registry, which is a collection of route urls and the route object. Every routing system needs a mapping for the url and the component to be mounted when the user visits that url. So we need a registry for the same which stores these mappings. It could be as simple as an array of objects like this:
[
{ path: '/', component: Home },
{ path: '/about', component: About },
]
Route component
The Route component is responsible for mapping a particular path or url to the corresponding Glimmer component. It basically tells the router that when the user is visiting the url it needs to mount the component. It is also responsible for adding a route to the registry.
export class Route extends Component {
constructor() {
super(...arguments);
const route = {
path: this.args.path,
component: this.args.component,
};
window.routerRegistry.push(route);
}
static template = hbs`{{yield}}`;
}
Link component
The next component is a custom Link
component to basically render the links with anchor elements and a custom class called glimmer-link
which we will use to bind click events to trap the onclick
events on the anchor elements to implement client-side navigation.
export class Link extends Component {
static template = hbs`
<a href={{@to}} class="glimmer-link">{{yield}}</a>
`;
}
Router Component
This is the meat of our Routing system where all the actions take place. The Router
component is responsible for handling the routing events, by listening to the click
events of our anchor elements generated by our Link
components and the popstate
events of the window object whenever there a change in the browser url happens.
It uses the Router registry which we saw earlier and find the matching route objects, in our case, Glimmer components and mount them accordingly in the outlet DOM nodes which is signified by the DOM element with the id glimmer-router-outlet
.
Router initialization
In the Router component's constructor we need setup the router registry and event listeners for anchor element click events and window.popstate
events. And we will start the router to listen for these as soon the DOMContentLoaded
event fires,it will start navigating to the respective pages.
constructor() {
super(...arguments);
window.routerRegistry = [];
document.addEventListener("click", this.route.bind(this));
window.addEventListener("popstate", this.handlePopState.bind(this));
document.addEventListener("DOMContentLoaded", this.start.bind(this));
}
The start
function will just redirect to the /
url which is the home page route.
start(ev) {
const path = location.pathname || "/";
this.navigate(path);
}
Rendering the components
Next, we need a function called renderPage
where we will render our components on to the outlet component which is nothing but a div with an id glimmer-router-outlet
.
renderPage(component) {
const outlet = document.getElementById("glimmer-router-outlet");
outlet.innerHTML = "";
renderComponent(component, outlet);
}
Routing to pages on anchor clicks
Next we will take a look at our route
function which is the click event handler for our anchor elements. We are listening to click
events in the window object and filter them out for the anchor tags with the class name glimmer-link
and then route the page. One thing to note here is to prevent the default behavior of the anchor clicks so that you are not redirected to a different page. We will find the route objects from the registry using the href
value from the respective anchor tags and match it against the path property of the route objects in the registry. We are also pushing the url to the history state so that our history navigation
with the back and forward buttons work properly.
route(ev) {
if (ev.target.classList.contains("glimmer-link")) {
ev.preventDefault();
const url = new URL(ev.target.href);
const [route] = this.registry.filter((r) => r.path === url.pathname);
if (route) {
history.pushState({}, "", url);
this.renderPage(route.component);
}
}
}
Handling window.popstate
Next we will add a function to handle the window.popstate
events and route to the corresponding pages and
mounting the components accordingly.
handlePopState(event) {
const [route] = this.registry.filter((r) => r.path === location.pathname);
if (route) {
this.renderPage(route.component);
}
}
Programmatic Navigation
We also need our Router object to have capabilities like navigating programmatically to different routes based on the
application state. This will be useful for Authentication, login and other similar stuff where we need to redirect our
users from Login page to Home page or something like that. This is done in the navigate
function where in we will pass a path
parameter like /about
and using that we find the respective route object and invoke the renderPage function.
navigate(path) {
const [route] = this.registry.filter((r) => r.path === path);
if (route) {
this.renderPage(route.component);
}
}
Tearing down event listeners on component destroy
The last thing we need to do is to cleanup our Router object in the willDestroy
lifecycle hook by removing all the event listeners we have attached previously in the constructor function.
willDestroy() {
document.removeEventListener("click", this.route);
window.removeEventListener("popstate", this.handlePopState);
}
This is how our final Router component will look like.
export class Router extends Component {
@tracked registry = window.routerRegistry;
constructor() {
super(...arguments);
window.routerRegistry = [];
document.addEventListener("click", this.route.bind(this));
window.addEventListener("popstate", this.handlePopState.bind(this));
document.addEventListener("DOMContentLoaded", this.start.bind(this));
}
renderPage(component) {
const outlet = document.getElementById("glimmer-router-outlet");
outlet.innerHTML = "";
renderComponent(component, outlet);
}
route(ev) {
if (ev.target.classList.contains("glimmer-link")) {
ev.preventDefault();
const url = new URL(ev.target.href);
const [route] = this.registry.filter((r) => r.path === url.pathname);
if (route) {
history.pushState({}, "", url);
this.renderPage(route.component);
}
}
}
handlePopState(event) {
const [route] = this.registry.filter((r) => r.path === location.pathname);
if (route) {
this.renderPage(route.component);
}
}
willDestroy() {
document.removeEventListener("click", this.route);
window.removeEventListener("popstate", this.handlePopState);
}
navigate(path) {
const [route] = this.registry.filter((r) => r.path === path);
if (route) {
this.renderPage(route.component);
}
}
start(ev) {
const path = location.pathname || "/";
this.navigate(path);
}
static template = hbs`
{{yield}}
<div id="glimmer-router-outlet"></div>
`;
}
Lazy loading components
One thing we need to notice here is that, we are eagerly loading all the component JS in the App
component while
setting up the router. This will lead to performance issues when our component size becomes bigger and we have large number of components.
Before code splitting, this is how our network request timeline look like in the Dev Tools Network panel
Other frameworks like React have come up with something like code-splitting JS bundles and lazy loading components. We can do something like this in our Glimmer apps with dynamic imports
in the Router's renderPage
function.
After code splitting, as you can see we will only load the respective components in the page instead of loading all the components code at one shot.
renderPage(component) {
const outlet = document.getElementById("glimmer-router-outlet");
outlet.innerHTML = "<h1>Loading page...</h1>";
import(`./pages/${component}.js`).then(component => {
outlet.innerHTML = "";
renderComponent(component.default, outlet);
});
}
First we display the loading indicator, before fetching the component through dynamic imports, then once the promise is resolved, we will get the component instance and use the default import which is the component class definition and pass it to the renderComponent
function.
For this to work, we need to change the prop type of the Route component by giving the component name instead of
the component instance itself in App.js
.
<Router>
<Route @path="/" @component="Home"/>
<Route @path="/about" @component="About"/>
<Route @path="/contact" @component="Contact"/>
</Router>
Please keep in mind that for code-splitting and lazy-loading to work properly we need to make some changes to our build configuration also. I am using Snowpack as my bundler and it automatically bundle the component source files separately. You can read more about bundling Glimmer apps with Snowpack in my previous blog post here.
If you are using Webpack you need to set up multiple entry-points in you webpack.config.js
.
So this is how our full and final Router implementation will look like.
GlimmerRouter.js
import Component, { hbs, tracked } from "@glimmerx/component";
import { renderComponent } from "@glimmerx/core";
export class Link extends Component {
static template = hbs`
<a href={{@to}} class="glimmer-link">{{yield}}</a>
`;
}
export class Route extends Component {
constructor() {
super(...arguments);
const route = {
path: this.args.path,
component: this.args.component,
};
window.routerRegistry.push(route);
}
static template = hbs`{{yield}}`;
}
export class Router extends Component {
@tracked registry = window.routerRegistry;
constructor() {
super(...arguments);
window.routerRegistry = [];
document.addEventListener("click", this.route.bind(this));
window.addEventListener("popstate", this.handlePopState.bind(this));
document.addEventListener("DOMContentLoaded", this.start.bind(this));
}
renderPage(component) {
const outlet = document.getElementById("glimmer-router-outlet");
outlet.innerHTML = "<h1>Loading page...</h1>";
import(`./pages/${component}.js`).then(component => {
outlet.innerHTML = "";
renderComponent(component.default, outlet);
});
}
route(ev) {
if (ev.target.classList.contains("glimmer-link")) {
ev.preventDefault();
const url = new URL(ev.target.href);
const [route] = this.registry.filter((r) => r.path === url.pathname);
if (route) {
history.pushState({}, "", url);
this.renderPage(route.component);
}
}
}
handlePopState(event) {
const [route] = this.registry.filter((r) => r.path === location.pathname);
if (route) {
this.renderPage(route.component);
}
}
willDestroy() {
document.removeEventListener("click", this.route);
window.removeEventListener("popstate", this.handlePopState);
}
navigate(path) {
const [route] = this.registry.filter((r) => r.path === path);
if (route) {
this.renderPage(route.component);
}
}
start(ev) {
const path = location.pathname || "/";
this.navigate(path);
}
static template = hbs`
{{yield}}
<div id="glimmer-router-outlet"></div>
`;
}
References
Demo
The demo for this post is hosted here and the source code for the same is available in this repository.
In the next post, we will replace all the boilerplate logic of our Router component with a more standard and established routing library which will take care of all the necessary things we missed in the Router like query parameters, dynamic segments, etc.,
Please let me know about any queries or feedback in the comments section. I will be happy to discuss more about the things in the post with you all.
Top comments (0)