1

I've been coding up my first single page website for about a week relying on "AJAX calls" thinking those would give me an "SPA", however, at some point, when I hit the refresh button at the top [by sheer luck], what I witness is the string result of the latest AJAX call, on the white page of the browser, instead of my site's HTML page!

Actually, the reason is I am changing the browser's URL with window.history.pushState, and then the request gets directly sent to the backend instead of going through my JavaScript code.

Things(blind attempts) I've tried myself so far (TL;DR-Caution: None of them worked):

  • The first thing I thought I could do was deleting the history! however, that doesn't seem to be possible(except for a single redirect). Otherwise, it could cause an obvious privacy issue, if one website can bully the user about their history of other websites!

  • The next idea was changing the history of the browser. I thought maybe ONLY injecting the main page(the one that wraps all the DOM elements created for the AJAX responses) to the window.history would make the browser neglect whatever else happening next. That I was wrong.

  • I also thought of the ugly solution of storing the whole DOM elements in a variable and pushing it to the window.history, but then detecting a refresh event and distinguishing it from direct access would be the next challenge.

  • I also tried listening on the beforeunload event, trying to prevent the refresh and then changing the location myself (such that it sounds like an actual refresh), but changing the browser's url happens after the refresh button has been clicked and so the new URL won't affect the route that the browser has already sent the request to, when the refresh button was hit.

So is there a way of handling this with vanilla JavaScript? Or maybe I'm mistaken about something simple? Or the better solution would be using tools like React, VueJS, etc. to get my SPA done "easier"? There's nothing wrong with those tools, I only prefer the simpler ways first. The SPA I want is something like the documentation page of the MongoDB website.

Edit: Code:

xhr.onreadystatechange = function () {
        if (xhr.readyState === 4) {
          if (xhr.status === 200) {
            data = JSON.parse(xhr.responseText);
            content_box.innerHTML = data.content;
            window.history.pushState({"content":data.content, "title": data.title + " | App Manual"}, data.title + " | App Manual", href); 
aderchox
  • 3,163
  • 2
  • 28
  • 37
  • @Brad Sorry I agree it sounds like that. But I will provide/accept any information you may suggest. – aderchox Oct 17 '20 at 20:13
  • 1
    I think that your attitude on this question and your approach is going to prevent you from getting to a technical solution. AJAX/XHR doesn't necessarily have to have anything to do with an SPA. And yes, if you mess with the URL via the history API, your server needs to be ready to handle requests to that URL. And no, using React/Vue.js/any other framework doesn't magically change any of these constraints. What you use server-side (like MongoDB) is irrelevant. You're confusing usage of a lot of different concepts. – Brad Oct 17 '20 at 20:16
  • @Brad Sorry I didn't get my point across properly, I never said I'm using MongoDB server-side, I want a single page website __similar to the__ MongoDB documentation's website. I've also cleaned the question a bit to make it have less non-technical words in it. Thanks. – aderchox Oct 17 '20 at 20:22
  • 1
    Since it sounds like you have nothing dynamic running on the server, I think you should consider using the anchor fragment (`location.hash`). I think you should also consider the Fetch API over XHR... it's easier. – Brad Oct 17 '20 at 20:22

1 Answers1

2

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.

lucasreta
  • 965
  • 2
  • 10
  • 25