How to create a vanilla JS routing for SPA?
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.
Luigi
Updated on June 09, 2022Comments
-
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 over 5 yearsThere are plenty of JS SPA routers yo get your started 1. My advice would be to use
#
to avoid page reload andwindow.addEventListener('popstate', ROUTERCALLBACK)
to handle the url change. -
Luigi over 5 yearsBut how do I go about creating this? How do I create a router and change the view to the appropriate view?
-