Framework-less Single Page Application

Amin Jafari
10 min readApr 28, 2019

--

Frameworkless-SPA

You may not (always) need a Javascript framework

The evolution of Javascript and its frameworks has been a long and fun journey and has opened new doors to previously unimaginable possibilities on the web, but at the same time the overuse of these technologies has led to a lot of unnecessary problems (i.e. handling SSR and SEO, Javascript overheads, load time and etc) and low quality websites online. In this article we’re gonna discover how fast and easy it is to create an SPA without the use of any client-side Javascript frameworks.

Note: This article is in no way about affronting the Javascript frameworks and libraries but rather simply about taking a step back before starting a project and evaluating the needs of which the use of these libraries is a necessity or not, and if not, having the option of going about it without the fear that’s put in all of us as developers.

Why create an SPA?

Single page applications by essence give the user a smoother experience on the website and uses less resources to transition through different sections and pages while giving the developers the control (e.g. animating the page transition) over when and how these transitions take place.

One last thing before we start: In the following guide I’m going to reference the technologies that I’m comfortable with but you can use any technology you prefer. Please bear in mind that we’re here to talk about the logic and the concept.

Summary

What we need to do to get this project up and running is simply create individual html pages that load their own styles and scripts and handle the client side and server side routing. In order to automate the repetitive portions of the development we want to create a file system routing for our html, css and js files and we’re gonna use an html templating system to reduce the repetitive html code and inject server side data in our pages on initial render.
The best thing about this approach is that you have control over everything and all the technologies you wish to use depending on the needs of the project whether it is client side state management, server side state management, animation libraries or literally anything you can imagine.

Folder structure

|-- app
|---- images
|---- others
|---- pages
|---- scripts
|------ data
|------ helpers
|------ pages
|---- styles
|-- server

Let’s start from the top (you may now clone the boilerplate to follow the structure and the code in detail as we go through them):

app/images Contains the image files.

app/others Contains the other resources needed like fonts.

app/pages Contains the html templates. The structure under pages defines the url structure of our website including the nested routes, so pages/products will result in www.[your-domain].com/products and pages/products/latest will result in www.[your-domain].com/products/latest

app/scripts Contains the javascript files. /data contains the data object you wish to pass to the html template. /helpers contains the helper methods that you will use in your code. /pages contains the scripts that will be injected to the rendered html. Keep in mind that the folder structure you apply for the html templates must be consistent in /data and /pages but the existence of the files are not required if you don’t wish to pass any data or script to a particular page.

/app/styles Contains the SCSS files. The structure under this must be identical to the folder structure of the html templates but the existence of the styles are not required if a certain page does not have any styles.

/server Contains the configurations and the initiation code for our server including the route handling, rendering and redirections.

HTML templates

In the /app/pages we need an index.html file which will be the layout template for all the pages, it imports the global.css and global.js files and has a main tag with the id app which renders the content of other pages inside.
View index.html

There’s another html file called home.html which is the page that will be rendered when you go to the root url of the website. The name of this file can be edited in server/config.js file using LANDING_PAGE property, then you can rename the file to the new value of LANDING_PAGE

For creating nested routes we can create a folder with an index.html file inside and then again depending on the nesting level another folder or an html file with the name of the route. For example let’s say we want to have /products and /products/latest , we need to create the following folder structure:

|-- app
|---- pages
|------ products
|-------- index.html
|-------- latest.html

If we want partial nested rendering, in the /products/index.html file we need to add ?{content}? or if we want partial nested rendering limited to a specific sub-route, in this case latest, we need to add ?{content-latest}? to the place where we want to render latest.html content or else it will be a full page nesting.
We can also share the styles and scripts by extending them using ?{extend-path/to/extend}? so for example if we want to share the styles of product-sub-page in /products/latest.html we add ?{extend-product/product-sub-page}? to the beginning of the file.

View example — in this example page-c is partially rendered into page-b but page-d replaces the whole page-b.

The client-side router

We need to handle the route transition on the client side after the initial page load and serve the respective page according to the url.
View router.js

First we need to handle the anchor click event:

document.querySelectorAll('a').forEach(addListenerToAnchors);

Let’s check out the addListenerToAnchors method:

const addListenerToAnchors = anchor => {
const href = anchor.getAttribute('href');
if (
!href.startsWith('http') &&
!href.startsWith('wwww') &&
!href.startsWith('//')
) {
anchor.addEventListener('click', anchorListener);
}
};

We get the current anchor element in the iteration and check if they’re external links, if not, then we add the click listener to it:

function anchorListener(e) {
e.preventDefault();
const href = this.getAttribute('href');
if (window.location.pathname !== href) {
onRouteChange(href);
}
}

If the anchor route is not the same as the current route, we initiate the route change:

const onRouteChange = (href, noPush) => {
const _app = document.getElementById('app');
routeWillChange();
setTimeout(() => {
if (!noPush) {
window.history.pushState({}, href, `${window.location.origin}${href}`);
}
fetch(`/partial${href}`)
.then(resp => resp.text())
.then(html => {
const pageOffload = new CustomEvent('page-offload');
document.dispatchEvent(pageOffload);
app.querySelectorAll('a').forEach(removeListenerFromAnchors);
_app.innerHTML = html;
_app.querySelectorAll('a').forEach(addListenerToAnchors);
loadScript(_app);
const pageOnload = new CustomEvent('page-onload');
document.dispatchEvent(pageOnload);
routeDidChange();
});
}, delay);
};

First we select the main#app element and then we run the routeWillChange method that we expose later on to the router initiator function.
Next we want to use a setTimeout to give the client the time to do page transition animation or any sort of time consuming calculation.
Because this method also runs on window.onpopstate which is the event for the browser back button, we need to check if we should push the state or not.
Then we fetch the partial route from the API (by partial route I mean without the layout that we defined in app/pages/index.html ) as string, create and trigger the page-offload event, remove the listeners from the to-be-removed anchor tags and then parse the response from the API into main#app , then we add the anchor event listener to the new anchors, initiate the script tag that we got from the template and finally create and trigger the page-onload event to let the client know that the page is fully loaded and ready to use and call the routeDidChange method that we later expose to the client.

Now we need to wrap these methods into an initiator function and expose routeWillChange , routeDidChange and delay
We also need to add the onpopstate listener and trigger the initial route change and page-onload events:

const router = ({
routeWillChange = () => {},
routeDidChange = () => {},
delay = 0,
}) => {
// ...previously defined methods
window.onpopstate = () => {
onRouteChange(window.location.pathname, true);
};
const pageOnload = new CustomEvent('page-onload');
document.dispatchEvent(pageOnload);
routeDidChange();
})

This is what we’re gonna use in our main script which we call global.js but we also need to expose the mount and unmount of the pages to use them in different pages’ scripts. For this we’re gonna steal a nice API from React called useEffect, which runs the script we pass to it on page mount and runs the function it returns on page unmount. Let’s try to build it:

const routerTransition = (cb = () => () => {}) => {
const noop = () => {};
const cbReturn = cb() || noop;
let init = false;
const onload = () => {
if (init) {
cb();
} else {
init = true;
}
};
const offload = () => {
cbReturn();
document.removeEventListener('page-onload', onload);
document.removeEventListener('page-offload', offload);
};
document.addEventListener('page-onload', onload);
document.addEventListener('page-offload', offload);
};

We run the callback passed to the function initially to indicate that the page has been mounted and get the returned function (if there’s any).
We then create an onload and offload method to add to our page-onload and page-offload listeners that we previously created.

Now we have a complete client side route handler, can you believe how easy it was?

Server-side route handling

Now we need to handle the routing on the server side as well, so let’s dive right in:
View server.js

app.get('/static/styles/*', (req, res) =>
res.sendFile(
path.join(
`${__dirname}/../static/styles/${req.url.split('/').slice(-1)[0]}`,
),
),
);

For styles, data, scripts, images and others we simply point them to the right location in the static folder and return the respective file.

Now we need to handle the request for partial templates:

const getPartialHtml = (fileName, extendedPage = '') =>
new Promise(async (resolve, reject) => {
try {
const html = await readFile(
path.join(`${__dirname}/../static/pages${fileName}.html`),
);
partialSuccess(html, fileName, resolve, extendedPage);
} catch (err) {
if (!fileName.includes('/index')) {
try {
const data = await getPartialHtml(`${fileName}/index`);
resolve(data);
} catch (e) {
reject(e);
}
} else {
reject(err);
}
}
});

In getPartialHtml we get the fileName and the content (if applicable) to render inside the partial template, then we check if the fileName is the name of a file or a folder and return a promise with the value of the target file or an error.
When we found the target file, we need to fill in the template, parse the html and return it through the resolver of the promise:

const partialSuccess = async (html, fileName, callback, extendedPage) => {
let data = {};
try {
data = require(path.join(
`${__dirname}/../static/data/${fileName.split('.')[0]}.js`,
));
} catch (e) {
data = {};
}
await Promise.all(
Object.keys(data).map(async key => {
if (typeof data[key] === 'function') {
data[key] = await data[key]();
}
}),
);
let script = null;
try {
script = await readFile(
path.join(`${__dirname}/../static/scripts/${fileName}.js`),
);
} catch (e) {}
let style = null;
try {
style = await readFile(
path.join(`${__dirname}/../static/styles/${fileName}.css`),
);
} catch (e) {}
const content = Mustache.render(
`<style>{{{style}}}</style>${html}<script>{{{script}}}</script>`,
{
...data,
script,
style,
content: extendedPage,
},
);
callback(minify(content, MINIFIER_OPTIONS));
};

We then load the data for our template, parse the async values and set them in the data object, then we load the script and the style, add it to our template and parse it using Mustache.render
Let’s have a look at how we’re gonna handle a request to /partial :

app.get('/partial', async (req, res) => {
try {
const data = await getPartialHtml(`/${LANDING_PAGE}`);
res.send(data);
} catch (err) {
res.status(404).send(err);
}
});

Now for the nested partial requests, it’s a bit more complicated:

app.get('/partial/*', async (req, res) => {
const fileName = req.url.replace('/partial', '').split('.')[0];
const paths = fileName.split('/').filter(a => a);
const data = await Promise.all(
paths.map(async (p, index) => {
try {
const route = Array(index)
.fill('')
.map((a, i) => paths[i])
.join('/');
return await getPartialHtml(`${route ? '/' : ''}${route}/${p}`);
} catch (e) {
return null;
}
}),
);
if (data.indexOf(null) > -1) {
res.status(404).send('Page not found!');
} else {
const html = data
.slice()
.reverse()
.reduce(
(acc, cur, index) =>
cur.includes('?{content}?') ||
cur.includes(`?{content-${paths.slice().reverse()[index - 1]}}?`)
? cur
.replace('?{content}?', acc)
.replace(
`?{content-${paths.slice().reverse()[index - 1]}}?`,
acc,
)
: acc || cur,
'',
);
res.send(html.replace(/\?{.*?}\?/g, ''));
}
});

What we do here is we get the route, split it by each chunk and load the html generated from the template, then replace the content in a reversed order (from bottom to top) and send the response.

The same goes for / and * routes except instead of res.send we parse it into the layout and then send the response back to the client.
Let’s have a look at renderHtml method which parses the content into the layout and sends the response back:

const renderHtml = async (content, res) => {
try {
const html = await readFile(
path.join(`${__dirname}/../static/pages/index.html`),
);
let data = {};
try {
data = require(path.join(`${__dirname}/../static/data/index.js`));
} catch (e) {
data = {};
}
await Promise.all(
Object.keys(data).map(async key => {
if (typeof data[key] === 'function') {
data[key] = await data[key]();
}
}),
);
const layout = Mustache.render(html, {
content,
...data,
...METAS,
});
res.send(minify(layout, MINIFIER_OPTIONS));
} catch (err) {
res.status(404).send('Page not found!');
}
};

Again we simply fetch the layout and its data then parse it using Mustache.render , minify it and send it back as the response.

There you have it, a complete single page application with server side and client side rendering without the use of any frameworks with only 1.2 KB Javascript overhead and that’s only the code we wrote for client side route handling.

Conclusion

Before I started this project I was always hesitant and to be perfectly honest, scared of doing it because of the huge libraries and frameworks with their gigantic communities, but as soon as I started it, I suddenly realised how simple and easy it is to start up a single page application from scratch and how fun it is to get back to the roots again. We sometimes forget that SPA is just a concept and can be implemented using a variety of technologies, methods and tools. It’s always a good idea to take a step back and see the whole picture and identify the needs and problems before we start a project. Let’s think outside the box for a change.

--

--

Amin Jafari

I am a front-end web developer working in the field for over 8 years. You can find me on the web by the name Amin52J or my website Amin52J.com