How to create a vanilla JS routing for SPA?

12,610

Solution 1

As i said in the comments, listening for popstate and using the hashtag (#) method is the easiest way to do routing in JS.

This is the most bare bones for a router:

//App area
var appArea = document.body.appendChild(document.createElement("div"));
//Registered routes
var routes = [
    {
        url: '', callback: function () {
            appArea.innerHTML = "<h1>Home</h1><a href=\"#todo\">To-Do</a><br/><a href=\"#calendar\">Calendar</a>";
        }
    }
];
//Routing function
function Routing() {
    var hash = window.location.hash.substr(1).replace(/\//ig, '/');
    //Default route is first registered route
    var route = routes[0];
    //Find matching route
    for (var index = 0; index < routes.length; index++) {
        var testRoute = routes[index];
        if (hash == testRoute.url) {
            route = testRoute;
        }
    }
    //Fire route
    route.callback();
}
//Listener
window.addEventListener('popstate', Routing);
//Initial call
setTimeout(Routing, 0);
//Add other routes
routes.push({ url: "todo", callback: function () { appArea.innerHTML = "<h1>To-Do</h1><a href=\"#\">Home</a><br/><a href=\"#calendar\">Calendar</a>"; } });
routes.push({ url: "calendar", callback: function () { appArea.innerHTML = "<h1>Calendar</h1><a href=\"#\">Home</a></br><a href=\"#todo\">To-Do</a>"; } });

Now in any real context you would want reusable DOM elements and scope-unload functions so here is how the above should probably look:

// ## Class ## //
var Router = /** @class */ (function () {
    function Router() {
    }
    //Initializer function. Call this to change listening for window changes.
    Router.init = function () {
        //Remove previous event listener if set
        if (this.listener !== null) {
            window.removeEventListener('popstate', this.listener);
            this.listener = null;
        }
        //Set new listener for "popstate"
        this.listener = window.addEventListener('popstate', function () {
            //Callback to Route checker on window state change
            this.checkRoute.call(this);
        }.bind(this));
        //Call initial routing as soon as thread is available
        setTimeout(function () {
            this.checkRoute.call(this);
        }.bind(this), 0);
        return this;
    };
    //Adding a route to the list
    Router.addRoute = function (name, url, cb) {
        var route = this.routes.find(function (r) { return r.name === name; });
        url = url.replace(/\//ig, '/');
        if (route === void 0) {
            this.routes.push({
                callback: cb,
                name: name.toString().toLowerCase(),
                url: url
            });
        }
        else {
            route.callback = cb;
            route.url = url;
        }
        return this;
    };
    //Adding multiple routes to list
    Router.addRoutes = function (routes) {
        var _this = this;
        if (routes === void 0) { routes = []; }
        routes
            .forEach(function (route) {
            _this.addRoute(route.name, route.url, route.callback);
        });
        return this;
    };
    //Removing a route from the list by route name
    Router.removeRoute = function (name) {
        name = name.toString().toLowerCase();
        this.routes = this.routes
            .filter(function (route) {
            return route.name != name;
        });
        return this;
    };
    //Check which route to activate
    Router.checkRoute = function () {
        //Get hash
        var hash = window.location.hash.substr(1).replace(/\//ig, '/');
        //Default to first registered route. This should probably be your 404 page.
        var route = this.routes[0];
        //Check each route
        for (var routeIndex = 0; routeIndex < this.routes.length; routeIndex++) {
            var routeToTest = this.routes[routeIndex];
            if (routeToTest.url == hash) {
                route = routeToTest;
                break;
            }
        }
        //Run all destroy tasks
        this.scopeDestroyTasks
            .forEach(function (task) {
            task();
        });
        //Reset destroy task list
        this.scopeDestroyTasks = [];
        //Fire route callback
        route.callback.call(window);
    };
    //Register scope destroy tasks
    Router.onScopeDestroy = function (cb) {
        this.scopeDestroyTasks.push(cb);
        return this;
    };
    //Tasks to perform when view changes
    Router.scopeDestroyTasks = [];
    //Registered Routes
    Router.routes = [];
    //Listener handle for window events
    Router.listener = null;
    Router.scopeDestroyTaskID = 0;
    return Router;
}());
// ## Implementation ## //
//Router area
var appArea = document.body.appendChild(document.createElement("div"));
//Start router when content is loaded
document.addEventListener("DOMContentLoaded", function () {
    Router.init();
});
//Add dashboard route
Router.addRoute("dashboard", "", (function dashboardController() {
    //Scope specific elements
    var header = document.createElement("h1");
    header.textContent = "Dashboard";
    //Return initializer function
    return function initialize() {
        //Apply route
        appArea.appendChild(header);
        //Destroy elements on exit
        Router.onScopeDestroy(dashboardExitController);
    };
    //Unloading function
    function dashboardExitController() {
        appArea.removeChild(header);
    }
})());
//Add dashboard route
Router.addRoute("dashboard", "", (function dashboardController() {
    //Scope specific elements
    var header = document.createElement("h1");
    header.textContent = "Dashboard";
    var links = document.createElement("ol");
    links.innerHTML = "<li><a href=\"#todo\">To-Do</a></li><li><a href=\"#calendar\">Calendar</a></li>";
    //Return initializer function
    return function initialize() {
        //Apply route
        appArea.appendChild(header);
        appArea.appendChild(links);
        //Destroy elements on exit
        Router.onScopeDestroy(dashboardExitController);
    };
    //Unloading function
    function dashboardExitController() {
        appArea.removeChild(header);
        appArea.removeChild(links);
    }
})());
//Add other routes
Router.addRoutes([
    {
        name: "todo",
        url: "todo",
        callback: (function todoController() {
            //Scope specific elements
            var header = document.createElement("h1");
            header.textContent = "To-do";
            var links = document.createElement("ol");
            links.innerHTML = "<li><a href=\"#\">Dashboard</a></li><li><a href=\"#calendar\">Calendar</a></li>";
            //Return initializer function
            return function initialize() {
                //Apply route
                appArea.appendChild(header);
                appArea.appendChild(links);
                //Destroy elements on exit
                Router.onScopeDestroy(todoExitController);
            };
            //Unloading function
            function todoExitController() {
                appArea.removeChild(header);
                appArea.removeChild(links);
            }
        })()
    },
    {
        name: "calendar",
        url: "calendar",
        callback: (function calendarController() {
            //Scope specific elements
            var header = document.createElement("h1");
            header.textContent = "Calendar";
            var links = document.createElement("ol");
            links.innerHTML = "<li><a href=\"#\">Dashboard</a></li><li><a href=\"#todo\">To-Do</a></li>";
            //Return initializer function
            return function initialize() {
                //Apply route
                appArea.appendChild(header);
                appArea.appendChild(links);
                //Destroy elements on exit
                Router.onScopeDestroy(calendarExitController);
            };
            //Unloading function
            function calendarExitController() {
                appArea.removeChild(header);
                appArea.removeChild(links);
            }
        })()
    }
]);

Solution 2

There are at least two basic approaches to making a vanilla SPA.

Hash router

The strategy is to add a listener to window.onhashchange (or listen to the hashchange event) which fires whenever the hash in the URL changes from, say, https://www.example.com/#/foo to https://www.example.com/#/bar. You can parse the window.location.hash string to determine the route and inject the relevant content.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <div id="app"></div>
    <script>
      const nav = `<a href="/#/">Home</a> | 
                   <a href="/#/about">About</a> | 
                   <a href="/#/contact">Contact</a>`;
      const routes = {
        "": `<h1>Home</h1>${nav}<p>Welcome home!</p>`,
        "about": `<h1>About</h1>${nav}<p>This is a tiny SPA</p>`,
      };
      const render = path => {
        document.querySelector("#app")
          .innerHTML = routes[path.replace(/^#\//, "")] || `<h1>404</h1>${nav}`;
      };
      window.onhashchange = evt => render(window.location.hash);
      render(window.location.hash);
    </script>
  </body>
</html>

History API

A modern approach uses the History API which is more natural for the user because no hash character is involved in the URL.

The strategy I used is to add an event listener to all same-domain link clicks. The listener makes a call to window.history.pushState using the target URL.

"Back" browser events are captured with the popstate event which parses window.location.href to invoke the correct route.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <div id="app"></div>
    <script>
      const nav = `<a href="/">Home</a> | 
                   <a href="/about">About</a> | 
                   <a href="/contact">Contact</a>`;
      const routes = {
        "/": `<h1>Home</h1>${nav}<p>Welcome home!</p>`,
        "/about": `<h1>About</h1>${nav}<p>This is a tiny SPA</p>`,
      };
      const render = path => {
        document.querySelector("#app")
          .innerHTML = routes[path] || `<h1>404</h1>${nav}`
        ;
        document.querySelectorAll('[href^="/"]').forEach(el => 
          el.addEventListener("click", evt => {
            evt.preventDefault();
            const {pathname: path} = new URL(evt.target.href);
            window.history.pushState({path}, path, path);
            render(path);
          })
        );
      };
      window.addEventListener("popstate", e =>
        render(new URL(window.location.href).pathname)
      );
      render("/");
    </script>
  </body>
</html>

The above examples are as minimal as possible. I have a somewhat more full-featured proof of concept on Glitch that adds a component-based system and modules.

If you want to handle more complicated routes, the route-parser package can save some wheel reinvention.

Without JS

As an aside, there's a trick for making a hash-based SPA without JS, using the :target CSS pseudoselector to toggle display: none and display: block on overlapping, full-screen sections as described in A Whole Website in a Single HTML File and https://john-doe.neocities.org.

html {
  height: 100%;
}
body {
  margin: 0;
  height: 100%;
}
section {
  padding: 1em;
  padding-top: 2em;
  display: none;
  position: absolute;
  width: 100%;
  height: 100%;
  background: #fff;
}
nav {
  padding: 1em;
  position: absolute;
  z-index: 99;
}
section:target {
  display: block;
}
#home {
  display: block;
}
<nav>
  <a href="#">Home</a> |
  <a href="#about">About</a> |
  <a href="#contact">Contact</a>
</nav>
<section id="home">
  <h1>Home</h1>
  <p>Welcome home!</p>
</section>
<section id="about">
  <h1>About</h1>
  <p>This is a tiny SPA</p>
</section>
<section id="contact">
  <h1>Contact</h1>
  <p>Contact page</p>
</section>

Solution 3

You can use navigo or have a brainstorm by seeing what other people are doing.

Another option - away from React/Angular - is to use sapper and you can take a truly revealing comparison of frameworks from there.

In my opinion a router should be generic and not only show/hide existing parts of an application but also send requests to server and receive ajax responses for page inclusion; in that way a request for /eshop/phones/samsung should make a ajax request and include html code at a node sth like <div id="eshop">. That way we need:

1) a url handler that blocks all clicks and reformats browser path and

2) a callback, what to do with that

and that's all!

SEO is achieved by mapping the exact same urls with actual cached pages; such urls are a subset of what the router handles as some - like the above - result in dynamically constructed pages.

From the point of view of a network bot dynamically constructed pages require js code to run, the router + supporting code (the bot can be capable of running js but even then the path is just one of thousands built from parameters converted to paths so no usable for indexing but it should be usable for bookmarking!).

Now you have a SEO + bookmarking feature on top of a router sth difficult to get from say Angular (that's so complicated that when one app is finished you have no clue how on earth you would reuse it to another project!).

At the end of day such a router mirrors a server one for the urls of cached pages + urls for dynamically constructed page parts, is minimal and combines both worlds: SPAs and server rendered pages.

Share:
12,610
Luigi
Author by

Luigi

Updated on June 09, 2022

Comments

  • Luigi
    Luigi about 2 years

    I'm creating a web app with no frameworks/tools/libraries, all Vanilla JS. I'm doing it in more of a 'React' style.

    I'd like to call a view that is in my views/pages/dashboard.js, display that view and change the URL when the user clicks the dashboard nav link. This is the navbar: https://codepen.io/Aurelian/pen/EGJvZW.

    Perhaps it'd be nice to integrate the sub-nav items into the routing. What if the user is in the GitHub folder on profile, how would I display that in the URL as well?

    How can I create a routing for this?

    The GitHub repo is https://github.com/AurelianSpodarec/JS_GitHub_Replica/tree/master/src/js

    This is what I've tried:

    document.addEventListener("DOMContentLoaded", function() {
        var Router = function (name, routes) {
            return {
                name: name,
                routes: routes
            }
        };
        var view = document.getElementsByClassName('main-container');
        var myRouter = new Router('myRouter', [
            {
                path: '/',
                name: "Dahsboard"
            },
            {
                path: '/todo',
                name: "To-Do"
            },
            {
                path: '/calendar',
                name: "Calendar"
            }
        ]);
        var currentPath = window.location.pathname;
        if (currentPath === '/') {
            view.innerHTML = "You are on the Dashboard";
            console.log(view);
        } else {
            view.innerHTML = "you are not";
        }
    });
    
    • Emil S. Jørgensen
      Emil S. Jørgensen over 5 years
      There are plenty of JS SPA routers yo get your started 1. My advice would be to use # to avoid page reload and window.addEventListener('popstate', ROUTERCALLBACK) to handle the url change.
    • Luigi
      Luigi over 5 years
      But how do I go about creating this? How do I create a router and change the view to the appropriate view?