Can we create a Single Page Application (SPA) without server side modifications, frontend libraries / frameworks, and without a need to define routes? Yes and it's easy. Let me show you how I did it. (Also, there's a demo at the end that you can try out)
Once completed, the router will be capable of:
- fetching pages from the server
- navigating without triggering a reload
- storing pages to avoid resending the same request and retain DOM state
Finally we run it with a single function that takes care of everything:
enableSpaNavigation()
Don't worry about the compatibility. Browsers that don't support the router's features will be ignored thanks to our awesome feature detection that we're going to define as well
1. Modify HTML
We need to tell the router which <a>
tags should be prevented from causing a page reload, and instead fetch the page in the background by marking them like this: class="interlink"
Content of each web page you want to update also needs a container. I mark it like this: id="app"
<div id="app">
<a classname="interlink" href="./about">About Us</a>
<!--rest of the page content comes here-->
</div>
2. Modify Javascript
Define a state variable
const pages = [];
Yes, that's all the state we're going to need
2. "Possess" the "interlinks"
Remember those <a>
tags we marked? Now's the time to change their behavior. We do this by adding a click
event listener on each. The listener stops them from reloading the page with preventDefault
function, and calls navigateTo
function passing in the url...
function possessInterlinks() {
Array.from(document.getElementsByClassName('interlink')).forEach(link => {
link.addEventListener('click', function (evt) {
evt.preventDefault()
navigateTo(evt.target.href)
})
})
}
Navigation
this function updates the browser's history stack and the address bar with window.history.pushState
method if necessary. It also fetches the page, if the page hasn't been previously stored; And it calls possessInterlinks
if the links haven't been previoulsy 'possessed'.
function navigateTo(url, isHistoryUpdated) {
const targetPage = getStoredPage(new URL(url).pathname)
if (!isHistoryUpdated) window.history.pushState({}, '', url)
if (!targetPage.content)
fetchPage(url).then(pageText => {
targetPage.content = pageFromText(pageText)
replacePageContent(targetPage.content)
setTimeout(() => {
possessInterlinks()
}, 1)
})
else replacePageContent(targetPage.content)
}
Page storage
Stores and accesses the pages from the pages
state variable we declared earlier.
function getStoredPage(pathname) {
// returns the stored page, if it doesn't exist, creates one and returns it
const page = pages.filter(p => p.pathname === pathname)[0]
if (page) return page
const newPage = {pathname}
pages.push(newPage)
return newPage
}
function storeCurrentPage() {
getStoredPage(window.location.pathname).content = document.getElementById('app')
}
Utility functions
function fetchPage(url) {
return fetch(url).then(res => res.text())
}
Converts the fetched page text into DOM and returns the new #app
element.
function pageFromText(pageText) {
const div = document.createElement('div')
div.innerHTML = pageText
return div.querySelector('#app')
}
replaces the previous #app
element with a new one.
function replacePageContent(newContent) {
document.body.replaceChild(newContent, document.querySelector('#app'))
}
enableSpaNavigation
This function sets up the router. It calls possessInterlinks
and takes care of browser's navigation back / forward buttons.
function enableSpaNavigation() {
// feature detection: proceed if browser supports these APIs
if (window.fetch && window.location && URL && window.history && window.history.pushState) {
//store the page (optional)
storeCurrentPage()
// add 'click' event listeners to interlinks
possessInterlinks()
// handle browser's back / forward buttons
window.addEventListener('popstate', evt => {
const page = getStoredPage(location.pathname)
if (page && page.content) {
evt.preventDefault()
navigateTo(evt.target.location, true)
} else {
window.location.reload()
}
})
}
}
Finally call enableSpaNavigation
we make sure the document is ready before calling enableSpaNavigation
if (document.readyState !== 'loading') enableSpaNavigation()
else
window.addEventListener('load', () => {
enableSpaNavigation()
})
That is all.
Here's the demo
And here's the source in github repository
I'd like to know what you guys think of this.
Top comments (1)
This was very interesting, thank you very much. I'm not sure yet if it fits my use case, but I can tell I found the page content caching very smart, and it's something I haven't seen on other tutorials.
However, I'm unsure if your demo works, and your GitHub link seems dead.
Also, I imagine you already know the function, but you could more idiomatically rewrite
pages.filter(p => p.pathname === pathname)[0]
bypages.find(p => p.pathname === pathname)
.I also wanted to share with you some other way to deal with this
pages
array. Instead of dealing with its content withgetStoredPage()
andstoreCurrentPage
to get an array like this :you could have a global object, whose keys would be the pathnames, and values would be the according pages content. It would be easier to manipulate, in my opinion. It would go like so :
Both methods (yours and mine) are obviously a matter of taste, I just wanted to share mine.
Thank you for your post !