DEV Community

Filip Biterski
Filip Biterski

Posted on

Let's build a simple Javascript Router

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)

Collapse
 
gprst profile image
Gabriel Proust • Edited

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] by pages.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 with getStoredPage() and storeCurrentPage to get an array like this :

const pages = [
    {
        pathname: '/some/path',
        content: '<span>Some content</span>'
    },
    {
        pathname: '/some/other/path',
        content: '<div>Some other content</div>'
    }
]
Enter fullscreen mode Exit fullscreen mode

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 :

const pages = {
    '/some/path': '<span>Some content</span>',
    '/some/other/path': '<div>Some other content</div>'
};

const pathname = '/a/yet/unvisited/path';
const pageContent = pages[pathname]; // instead of getStoredPage(pathname)
if (pageContent) {
    // do something with the page content...
} else {
    pages[pathname] = pageFromText(pageText); // instead of storeCurrentPage()
    // etc.
}
Enter fullscreen mode Exit fullscreen mode

Both methods (yours and mine) are obviously a matter of taste, I just wanted to share mine.

Thank you for your post !