1
\$\begingroup\$

As an exercise, I found a basic way of updating pages without refreshing, partially mimicking the behaviour of SPAs and routing libraries such as React Router. I was not looking for something as advanced as those libraries, but I thought it was interesting to find a quick way of doing this.

The idea is that, when clicking on an anchor within a wrapper element (#wrapper), the content of the linked page is shown. In place of a full-page refresh/transition, a custom loader element (#loader) is shown for a short period while the linked page's content is being loaded. The title and body of the page is replaced with those of the loaded linked page.

This way is sub-optimal. For example, one major drawback is that the back functionality does not work; the previous page does not load when navigating via the browser's back button.

To keep it minimal, I included just a few elements in the HTML snippet. The snippets will not work here because of the fetch requests, but I hope it gives an overview of the idea.

Please let me know how this could be improved. Thank you in advance!

/**
 * Handle clicking links
 */
const clickAnchor = (event) => {
  event.preventDefault();

  const target = event.target ?? event.currentTarget;

  if (!target.href) {
    return;
  }

  loadPage(target.href);
};

/**
 * Load event listeners on anchors
 */
const init = () => {
  const anchors = document.querySelectorAll("#wrapper a");

  for (const anchor of [...anchors]) {
    const currentUrl = new URL(window.location);
    const newUrl = new URL(anchor.href);

    if (currentUrl.pathname === newUrl.pathname) {
      // Do not load if on same page (e.g. if fragment/hash is given)
      continue;
    }

    anchor.addEventListener("click", clickAnchor);
  }
};

/**
 * Determine whether to show the loader
 */
const loading = (show = true) => {
  const loader = document.querySelector("#loader");

  loader.classList = show ? '' : 'hidden';
};

/**
 * Navigate to the given URL
 */
const loadPage = async (url) => {
  loading();

  const res = await fetch(url);

  if (res.ok) {
    const dom = new DOMParser();

    const data = await res.text();
    const html = dom.parseFromString(data, "text/html");

    history.pushState({}, "", url);

    document.title = html.title;
    document.querySelector("body").innerHTML = html.body.innerHTML;

    loading();

    setTimeout(() => {
      loading(false);
      init();
    }, 1000);
  }
};

window.addEventListener("DOMContentLoaded", init);
#loader.hidden {
  display: none;
}
 
#loader {
  position: fixed;
  inset: 0;
  background-color: rgb(255, 255, 255, 0.5);
  backdrop-filter: blur(5px);
  display: grid;
  place-items: center;
}
<div id="wrapper">
  <p>Test</p>
  
  <a href="/another-page.html">Another page</a>
</div>

<div id="loader" class="hidden">Loading&hellip;</div>

\$\endgroup\$
1
  • \$\begingroup\$ When you really want to create a SPA, I advise to put the content of your pages in JSON files instead of HTML files, and then you can use fetch just to load the content. This will more accurately emulate SPA frameworks such as React. It will also be MUCH faster and have less security risks. \$\endgroup\$
    – Kokodoko
    Commented Jul 31, 2022 at 12:07

2 Answers 2

4
\$\begingroup\$

Danger

Your approach to navigation is problematic an should be strictly limited to your own domain.

Cross origin headers will prevent most requests being returned however do not make requests as 'no-cors' because any code comes with the fetch will have access to your domain and can be potential harmful to you and your clients.

Issues

  • The function loadPage parses the new document twice, though likely not noticeable to most it is an unneeded overhead making load to page slower than just navigating to the URL.

  • You fetch the next document parse it and then dump (ignore) all of the content outside the body. I will assume that you have control over the loaded pages to ensure all the data needed to render the page is within the new body.

  • Any scripts invocated outside the current page's body will still be active. Depending on the scripts used this could represent a memory leak, with each navigation adding to the resources used rather than replacing the resources used.

  • The URL you construct from the anchor href may be relative. As your page does not move that URL is relative to the first loaded address and may not be the same address the new page's relative url is pointing to. You need to ensure that the URL you fetch is an absolute address

Don't

Unless you use this in a sandboxed network I would advise you to never use this approach for navigation. New API's are being introduced to make in App page navigation a better developer and end user experience. Let the browser handle the page context and you just provide the when and where.

\$\endgroup\$
1
\$\begingroup\$

I too love the appeal of avoiding heavy frameworks and writing a few lines of JS to create an SPA. But as Blindman67 correctly points out, it's pretty hard to get all of the edge cases handled and create something production-ready.

One advantage/disadvantage/characteristic of your approach is that resources aren't fetched until they're needed. Initial load time should be good but each navigation will hurt a bit, and the site won't work offline. You might want to serve up more than just the basic page as strings and inject them without trips to the server. Prefetching is worth considering.

Watch out for sanitization of user input that may be present in some of the pages you're fetching. That's handled automatically in Vue/React/Angular/etc. Since you're pointing to .html files I assume you have a static site that might not involve any untrusted data, but it has to be mentioned.

External links don't seem to be supported.

dom.parseFromString(data, "text/html"); and .innerHTML do the same work twice. You might skip the title changes or find another way to communicate that along with the HTML payload.

The setTimeout and loading calls seem misplaced. As soon as .innerHTML is called, I'd set loading to false rather than losing 1 second during which the user could be interacting with the page.

I suggest using a skeleton that has the HTML boilerplate with a <div id="app"></div> that the page content fragments get loaded into rather than the body. If you have a nav bar or other repeated boilerplate, that can also be static.

Not having URL path updates or back functionality is pretty much an instant deal-breaker in terms of UX. Add something like:

window.addEventListener("popstate", e =>
  loadPage(new URL(window.location.href).pathname)
);

...but this won't really work until you fix pushState state to change the URL. Something like:

anchor.addEventListener("click", evt => {
  evt.preventDefault();
  const {pathname: path} = new URL(evt.target.href);
  window.history.pushState({path}, path, path);
  loadPage(path);
});

...but this introduces a new problem which is that any of your routes could be navigated to rather than the home screen, forcing you to duplicate the JS router code and probably create a design problem that's probably out of scope since resolving it would likely be very use-case dependent, short of rewriting a general-purpose SPA library.

See How to create a vanilla JS routing for SPA? for additional sketches and ideas (also proofs of concept rather than production-ready).

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.