DEV Community

loading...
Cover image for Build a very basic SPA JavaScript router

Build a very basic SPA JavaScript router

pixari profile image Raffaele Pizzari ・5 min read

Simple Plain JavaScript Router

In this post I'll implement an extreme basic SPA routing using plain JavaScript.
The goal is to give an idea of how it's possible to render different dynamic contents based on the URL with plan JavaScript.

Requirements

We want to have a basic website that shows different topic based on 3 urls:

For other urls we show an error message.
We can use HTML and plain JavaScript.

Setup

Let's create the HTML page index.html:

<html>
  <head>
    <title>JavaScript Router Example</title>
  </head>
  <body>
    <header>
      <h1>JavaScript Router Example</h1>
    </header>
    <section id="app"></section>
    <nav>
      <a href="/">Home</a> -
      <a href="#/page1">Page 1</a> -
      <a href="#/page2">Page 2</a>
    </nav>
    <script type="text/javascript" src="./app.js" />
  </body>
</html>

and an empty JS file app.js.

In order to serve it, we can install live-server globally:

npm install -g live-server

and then run it on our HTML file:

live-server --port=3000 --entry-file=’./index.html’

Now it should be possibile to visit http://localhost:3000/ and see the page.

Create the components

Let's create the components now.

We use the "template literal" expression, which is a string literal that can span multiple lines and interpolate expressions.

Each component has a render method that returns the HTML template.

// Components
const HomeComponent = {
  render: () => {
    return `
      <section>
        <h1>Home</h1>
        <p>This is just a test</p>
      </section>
    `;
  }
} 

const Page1Component = {
  render: () => {
    return `
      <section>
        <h1>Page 1</h1>
        <p>This is just a test</p>
      </section>
    `;
  }
} 

const Page2Component = {
  render: () => {
    return `
      <section>
        <h1>Page 2</h1>
        <p>This is just a test</p>
      </section>
    `;
  }
} 

const ErrorComponent = {
  render: () => {
    return `
      <section>
        <h1>Error</h1>
        <p>This is just a test</p>
      </section>
    `;
  }
}

Now we have the components that we want to show in the page.

Create the routes

We need to create the routes and connect them somehow with the components.

So, let's do it in a easy way:

// Routes 
const routes = [
  { path: '/', component: HomeComponent, },
  { path: '/page1', component: Page1Component, },
  { path: '/page2', component: Page2Component, },
];

Router

How should the router look like?
Let's assume that our goal is to code something like that:

const router = () => {
  // TODO: Get the current path
  // TODO: Find the component based on the current path
  // TODO: If there's no matching route, get the "Error" component
  // TODO: Render the component in the "app" placeholder
};

Then let's start! :)

Get the current path

The location object is excatly the tool we need.

The Location interface represents the location (URL) of the object it is linked to. Changes done on it are reflected on the object it relates to. Both the Document and Window interface have such a linked Location, accessible via Document.location and Window.location respectively. (MDN Web Docs)

One property of the location object is location.hash, which contains the part of the URL from '#' followed by the fragment identifier of the URL.

In other words, given this URL: http://foo.bar/#/hello, location.hash would be: '#/hello'.

So we need to extract from that string something we can use with out routes.

We remove the "#" char from and in case any hash value is provided, we assume it will be the base url: /.

const parseLocation = () => location.hash.slice(1).toLowerCase() || '/';

At this point we solved the first "TODO" of the list:

const router = () => {
  //  Find the component based on the current path
  const path = parseLocation();
  // TODO: If there's no matching route, get the "Error" component
  // TODO: Render the component in the "app" placeholder
};

Get the the right component

Since we have the path, what we need to do is to get the first matching entry of the routes.

In case we can't find any route, that we just return undefined.

const findComponentByPath = (path, routes) => routes.find(r => r.path.match(new RegExp(`^\\${path}$`, 'gm'))) || undefined;

We solve the next TODO now!
We use a "destructuring assignment" to assign the matching component to the const component, which gets by default the ErrorComponent.
Since the "destructuring assignment" requires an object on the right-hand side and since our findComponentByPath function could return undefined, we provide in this case just an empty object {}.

const router = () => {
  // Find the component based on the current path
  const path = parseLocation();
  // If there's no matching route, get the "Error" component
  const { component = ErrorComponent } = findComponentByPath(path, routes) || {};
  // TODO: Render the component in the "app" placeholder
};

Now we are ready to solve the third and last TODO: render the component in the app.

Render the component

If you remember, our components have a render method that returns the HTML template.
So we have to put that template in the app <section id="app"></section>.

This is very easy, you know.
We get the element using the id and put the content in the innerHTML property.

document.getElementById('app').innerHTML = component.render();

The router is ready:

const router = () => {
  // Find the component based on the current path
  const path = parseLocation();
  // If there's no matching route, get the "Error" component
  const { component = ErrorComponent } = findComponentByPath(path, routes) || {};
  // Render the component in the "app" placeholder
  document.getElementById('app').innerHTML = component.render();
};

Make it work

Even if the code would work, there's something sill missing.
We are never calling the router! Our code right hasn't been executed yet.

We need to call it in 2 cases:
1) On page load since we want to show the right content from the very first moment
2) On every location update (actually every "hash" location update)

We need to add to event listeners and bind them with our router.

window.addEventListener('hashchange', router);
window.addEventListener('load', router);

That's it :)

Here you can find a live example:

Key takeaway points:

• Learn how Window.location works
• Learn how template literals work
• Learn how EventTarget.addEventListener() works

Docs:

Window.location
Template literals (Template strings)
EventTarget.addEventListener()

About this post

I'm am running a free JavaScript Learning Group on pixari.slack.com and I use this blog as official blog of the community.
I pick some of the questions from the #questions-answer channel and answer via blog post. This way my answers will stay indefinitely visible for everyone."

If you want to join the community feel free to click here or contact me:

Discussion (8)

pic
Editor guide
Collapse
mactepyc profile image
MACTEPyc • Edited

An error occurs

  1. Added to HTML
    Added to html

  2. Added to JS
    Added to js

  3. We get an error
    We get an error

Collapse
mactepyc profile image
MACTEPyc

I decided

Case-insensitive search
Case-insensitive search

P.S. Thanks for the router, what I needed

Collapse
labzdjee profile image
Gérard Gauthier

Well, parseLocation (findComponentByPath uses) transforms any upper case letters to lower case letters, that's why you need the i switch downstream
You could also modify parseLocation
I also noticed \\ as well as gm are unnecessary in the RegExp.

Collapse
labzdjee profile image
Gérard Gauthier

Hello, good base for adding routes in an SPA. Inspirational. That's why I took this as an example, adding title dynamic update (gives a more readable history), programmatic call to links (well not too proud of the trick I used, any suggestion welcome).
As I had to test how ES6+ code with import/require on modules, CSS variables, assets can be comprehensively be transpiled and bundled with Webpack (never did that because Vue.js took care of this for me up to now), I restructured this code to make it work under Internet Explorer (another constraint I had to work on).
If you're interested it is here: github.com/LabZDjee/basic-router.
It works in development (live server) and production modes with source maps.

Collapse
akhilarjun profile image
Akhil Arjun

Thats a neat implementation.

I once meddled with the same idea and built a small. Router called RouteNow.

It started with a simple hashbang routing as you did here, and then shifted default routing method to history.pushState and popState. As that forms better readable urls.

GitHub logo akhilarjun / RouteNow

RouteNow is a small fast library ⚡ that will help you in developing a SinglePage Application without any dependencies like jQuery, AngularJs, vue.js or any of those bulky frameworks.

RouteNow

Contributions-Welcome experimental File-Size Version

A new revolutionary small router with no bullshit!

RouteNow is a small library that will help you in developing a SinglePage Application without any dependencies like jQuery, AngularJs, vue.js or any of those bulky frameworks.

Installation

Installating RouteNow.js is as easy as it gets.

Add the script to the end of your body

<script src="../pathto/route-now.js"></script>

Or use the CDN link


<script src="https://cdn.jsdelivr.net/gh/akhilarjun/RouteNow@latest/js/route-now.js"></script>
<!-- For specific version use the version tag -->
<script src="https://cdn.jsdelivr.net/gh/akhilarjun/RouteNow@v2.0.1/js/route-now.js"></script>
<!-- For Minified -->
<script src="https://cdn.jsdelivr.net/gh/akhilarjun/RouteNow@v2.0.1/js/route-now.min.js"></script>

Use anchor tags with r-href property to point to your paths

<a r-href="home">Home</a>

Now add the Router outlet where the views should…

Collapse
labzdjee profile image
Gérard Gauthier

Thank you for the information. Seems you haven't published this as an NPM package. That's a must nowadays, should we want to include your work in a Webpack managed project.

Collapse
geomc profile image
Sascha

Neat, thanks!

Collapse
mactepyc profile image
MACTEPyc

Another question:

One argument
One argument

Shouldn't it be like that?
Shouldn't it be like that?