TL;DR: Go to the last paragraph
I've made a simple proof of concept to illustrate how one could achieve what you were looking for, you can see it here.
In this version, I'm using the last URL parameter to determine what should be loaded. This is standard practice, but it requires server-side configuration in order to route all your incoming requests to a single file.
The other alternative is, as mentioned in the comments, using the anchor fragment. My script supports both alternatives, and you can see here the uploaded version that uses anchors instead of URL rewriting.
The code is pretty simple (though it could be further simplified by using fetch or a less verbose XHR implementation).
HTML
There are only two things that concern us here. One is the #content-box
, where we will place whatever content we load from our APIs. In my version, it looks like this:
<section id="content_box"></section>
The other is the a
elements used for internal routing, which must have the class link
associated to them, like so:
<ul>
<li><a class="link" href="/section-1">Link #1</a></li>
<li><a class="link" href="/section-2">Link #2</a></li>
</ul>
JS
We start with some basic initialization of general variables:
const useHash = true;
const apiUrl = 'https://lucasreta.com/stack-overflow/spa-vanilla-js/api';
const routes = ['section-1', 'section-2'];
const content_box = document.getElementById("content_box");
useHash
will determine whether we should use the anchor (hash) of the URL, or the last parameter for our internal routing.
apiUrl
sets the base URL of our simple API.
routes
defines the valid paths of our application.
content_box
is the DOM element we'll update with out data.
Then we define our asynchronous getter of information, which remained a pretty standard XHR call similar to what you already had (error handling missing):
function get(page) {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
data = JSON.parse(xhr.responseText);
content_box.innerHTML = data.content;
const title = `${data.title} | App Manual`;
window.history.pushState(
{ 'content': data.content, 'title': title},
title,
useHash ?
`#${page}` :
page
);
}
};
xhr.open('GET', `${apiUrl}/${page}`, true);
xhr.send();
}
Here we send our get
function a parameter called page
, that matches the endpoint of the API we will consume and the name we will use in our state and URL to determine what we must show.
Given the simplicity of the response object you showed on your code, I think it's appropriate to push the whole content-and-title object into our history and later consume it from there. In more complex scenarios, we'll probably need to store only the page
parameter and do a new request to the API.
Now we have to handle the three events in which the state of our single page application is modified:
// add event listener to links
const links = document.getElementsByClassName('link');
for(let i = 0; i < links.length; i++) {
links[i].addEventListener('click', function(event) {
event.preventDefault();
get(links[i].href.split('/').pop());
}, false);
}
// add event listener to history changes
window.addEventListener("popstate", function(e) {
const state = e.state;
content_box.innerHTML = state.content;
});
// add ready event for initial load of our site
(function(fn = function() {
const page = useHash ?
window.location.hash.split('#').pop() :
window.location.href.split('/').pop();
get(routes.indexOf(page) >= 0 ? page : routes[0]);
}) {
if (document.readyState != 'loading'){
fn();
} else {
document.addEventListener('DOMContentLoaded', fn);
}
})();
First, we get all elements with class .link
and attach an event listener to them, so that when they are clicked the default event is stopped and instead our get
function is called upon with the last parameter of the href.
So when we click the first link listed above, we will perform a GET request to api.com/section-1
and update our application's URL to either app.com/section-1
or app.com/#section-1
.
Here lay two limitations of my implementation:
- API and app routes must match.
- routes can't have multiple parameters.
Both are fixable, I won't go into detail for it escapes the point of the simple POC, but I had to point it out. The first one could be fixed by using some sort of dictionary that matches our routes to the endpoints they should fetch. The second one could be fixed by making a bit more complex the logic in our event listener for links, expanding the simple links[i].href.split('/').pop()
to include all the parameters expected.
Next we have the event listener for the changes in history. As we are storing the contents returned by the API in the history states themselves, all we have to do when history changes is repopulate the content_box
with our state.content
.
Lastly, we have our ready function, called upon when the DOM initially ends loading:
We check our URL to get either the last parameter or the value of the hash/anchor. Then we verify if what we got from the URL exists in our array of defined internal routes. If it does, we call our get
function with that as a parameter. If it doesn't, we get the first route off of our array and call it with it instead.