Framework-less Single Page Application
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.