I never really liked frameworks, maybe in the start of my career, because it made life simpler, at first, and then it made life harder. And for a long time I have been aware of the digital footprint that me, as a developer, has left behind, and the responsibilities that I have as a member of the global community. So, after some tinkering, and much thanks to other developers that has coded before me, I finally made a working, minimal footprint, vanilla JavaScript Single Page Application setup.
Do you really need a framework for this?
If you follow this guide, you will have your own Vanilla JavaScript SPA up and running in no time!
Note
Even though I say JavaScript here, in this example I have used TypeScript. It is not required, so feel free to strip out the typings if you want.
Minimal adjustments required to switch to a framework
Some sort of equivalent to useEffect and useState like in React
Prerequisites
Knowledge of:
JavaScript
npm
NodeJS
git
Tools:
terminal
editor
web browser
Setup
TL;DR
If you do not like long reading, you can check out the repository for this setup here
First we need to set up a repository. Head on over to https://github.com and create a new repository. Call it whatever you want, and clone it into your workspace. For the sake of this guide, we are referring to the repository as vanilla-js-spa;
shell-session
$ cd ~/Workspace
$ git clone git@github.com:<your username>/vanilla-js-spa.github.io.git
Cloning into vanilla-js-spa.github.io
Initialize npm, change the stuff you want with the interactive tool:
shell-session
$Β npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.
See `npm help init` for definitive documentation on these fields
and exactly what they do.
Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.
Press ^C at any time to quit.
package name:
After you have done that, you will have a directory something like this:
After you've done that, install the required dependencies. First the dependencies required to build our application, then the dependency required for our SPA to work optimally:
shell-session
$ npm i -D @ironkinoko/rollup-plugin-styles @rollup/plugin-commonjs @rollup/plugin-node-resolve cssnano postcss postcss-cli rollup rollup-plugin-dts rollup-plugin-node-externals rollup-plugin-typescript2 stylus ts-node tsconfig-paths tslib typescript
added 295 packages, and audited 296 packages in 7s
found 0 vulnerabilities
$ npm i -S diff-dom
added 1 package, and audited 2 packages in 438ms
found 0 vulnerabilities
$ struct
π¦ vanilla-js-spa.github.io
βββ π node_modules
βββ π .gitignore
βββ π package-lock.json
βββ π package.json
βββ π README.md
Note
If you want, this is a great step to add linting stuff like eslint and prettier
npm scripts
Open up your package.json, and update the scripts property to something like this:
To be able to process *.styl files and produced bundled JavaScript, we are using rollup. Start creating your rollup.config.js:
Note
I use Stylus for my styling, you can choose sass/scss or less if you prefer that. Just remember to install the correct dependencies, and use the correct configuration files
Here you can put any JavaScript you want, that is prebuilt. For example a custom Prism build.
Important
Remember earlier, from the npm scripts, that we copy over the assets folder into dist? Well, we need at least one file for the copying of js to work. So if you haven't added any *.js file, add a dummy file:
Go into the src/assets/js directory and create a dummy.js file:
In the css folder, you can put any css file you want, for example a custom styling for prismjs, your custom tailwindcss or any other library you would use.
Warning
These files will not be processed
Styles
Then go to the styles folder to create a index.styl file, for all your styles.
This is just a helper to reduce circular dependencies, you can move or remove this if you want.
typescript
import App from '../../../app';
import { RouteDetails } from '../types';
import { init } from '../';
export const bootstrap = async (routeDetails: RouteDetails) => {
await init(App, routeDetails);
};
src/lib/spa/utils/dom-content-loaded.ts
typescript
import { NavigateToEvent, EventType } from '../types';
import { router } from '../router';
import { bootstrap } from './bootstrap';
import { eventMatches } from './event-matches';
import { navigateTo } from './navigate-to';
// When DOM is loaded
export const DOMContentLoaded = async () => {
// If any navigation is fired through a custom event
document.addEventListener('navigateTo', (e: NavigateToEvent) => {
const { to } = e.detail;
navigateTo(to);
});
// If a user clicks a link that should change the popstate, instead of hard routing
document.body.addEventListener(
'click',
async (e: EventType<HTMLElement>) => {
const el = eventMatches(e, '[data-link]') as HTMLAnchorElement;
if (el) {
e.preventDefault();
await navigateTo(el.href);
}
}
);
// Get current route
const routeDetails = router();
// Reinitialisze the SPA
await bootstrap(routeDetails);
};
And we need to update src/lib/spa/types/index.ts with the types:
typescript
β¦
/**
* Represents the event object for an element.
* @template T - Type of the element used as a target.
*/
export type EventType<T> = Event & {
target: T & {
files?: FileList | null;
id: string;
parentElement: Element | null;
};
currentTarget: HTMLElement & {
documentElement: HTMLElement;
};
};
/**
* Represents the keyboard event object for an element.
* @template T - Type of the element used as a target.
*/
export type KeyboardEventType<T> = KeyboardEvent & {
target: T & {
id: string;
};
currentTarget: HTMLElement & {
documentElement: HTMLElement;
};
};
export type NavigateToEvent = Event & {
detail: {
to: string;
};
};
src/lib/spa/utils/event-after-app-render.ts
typescript
export const eventAfterAppRender = () => {
// Preserve focus state on render
if (
document.activeElement &&
!document.activeElement.isEqualNode(globalThis.activeElement)
) {
const { id } = globalThis.activeElement;
if (id) {
const elementToFocus = document.getElementById(id);
if (elementToFocus) {
elementToFocus.focus();
}
}
}
};
import { EventType } from '../types';
/**
* @param {Event} event The event.
* @param {string} selector The selector.
* @returns {Element}
* The closest ancestor of the event target (or the event target itself) which matches the selectors given in parameter.
*/
export const eventMatches = (
event: EventType<HTMLElement>,
selector: string
): HTMLElement | undefined => {
// <svg> in IE does not have `Element#msMatchesSelector()` (that should be copied to `Element#matches()` by a polyfill).
// Also a weird behavior is seen in IE where DOM tree seems broken when `event.target` is on <svg>.
// Therefore this function simply returns `undefined` when `event.target` is on <svg>.
const { target, currentTarget } = event;
if (typeof target.matches === 'function') {
if (target.matches(selector)) {
// If event target itself matches the given selector, return it
return target;
}
if (target.matches(`${selector} *`)) {
const closest: HTMLElement | null = target.closest(selector);
if (
closest &&
(currentTarget.nodeType === Node.DOCUMENT_NODE
? currentTarget.documentElement
: currentTarget
).contains(closest)
) {
return closest;
}
}
}
return undefined;
};
update-nodes.ts is the most important file in the SPA, since it is doing what we love most about framework SPAs: It only updates the nodes that has changed!
This is also the only dependency this SPA uses in production.
typescript
import { stringToHTML } from './string-to-html';
import { DiffDOM } from 'diff-dom';
/**
* A wrapper to be able to update DOM nodes, using the 3rd party library `diff-dom`.
* This compares the old DOM nodes with the new DOM nodes and applies any changes.
* It is the same modus operandi as the inner working of a modern-day JS framework, e.g., React.
*
* The `updateNodes` function takes two parameters: `root` and `html`.
* The root parameter is an `HTMLElement` where the DOM changes
* will be applied, and the `html` parameter is a string representing
* the new DOM nodes to be applied.
*
* The function uses the third-party library `diff-dom` to compare
* the old DOM nodes with the new DOM nodes and apply any changes,
* simulating the behavior of modern JS frameworks like React.
* The `updateNodes` function does not return anything (returns `void`).
*
*
*
* **NOTE:** Does not detect text changes. See [fiduswriter/diffDOM#advanced-merging-of-text-node-changes](https://github.com/fiduswriter/diffDOM#advanced-merging-of-text-node-changes)
*
*
*
* @see [diffDOM](https://github.com/fiduswriter/diffDOM)
* @param {HTMLElement} root - The root HTMLElement where the DOM changes will be applied.
* @param {string} html - The HTML string representing the new DOM nodes to be applied.
* @returns {void} - This function does not return anything (returns void).
*
* @example
* ```ts
* const rootElement = document.getElementById('app');
* const newHTML = App();
* // Updates only the changed DOM elements
* updateNodes(rootElement, newHTML);
* ```
*/
export const updateNodes = (root: HTMLElement, html: string): void => {
const currentDOM = stringToHTML(root.innerHTML);
const newDOM = stringToHTML(html);
const dd = new DiffDOM();
const diff = dd.diff(currentDOM, newDOM);
dd.apply(root, diff);
};
src/lib/spa/utils/string-to-html.ts
typescript
/**
* Convert a template string into HTML DOM nodes.
*
* The `stringToHTML` function takes a parameter `str`, which is
* the template string to be converted into HTML.
* It uses the `DOMParser` to parse the template string into HTML DOM nodes and
* then returns the `body` of the parsed document as a `Node`.
*
* @param {string} str - The template string to be converted into HTML.
* @returns {Element} - The template HTML represented as DOM nodes.
*/
export const stringToHTML = (str: string): Element => {
const parser = new DOMParser();
const doc = parser.parseFromString(str, 'text/html');
return doc.body;
};
src/lib/spa/utils/set-title.ts
typescript
/**
* Sets the title of the document.
*
* @param {string} title - The title to set for the document.
*
* @example
* ```ts
* // Sets the document title to "My Page Title"
* setTitle('My Page Title');
* ```
*/
export const setTitle = (title: string): void => {
document.title = title;
};
src/lib/spa/utils/wait-for.ts
typescript
/**
* Delays execution for the specified time.
*
* @param {number} ms - The time to wait in milliseconds.
* @returns {Promise<void>} A Promise that resolves after the specified time.
*
* @example
* ```ts
* // Wait for 2 seconds
* await waitFor(2000);
* ```
*/
export const waitFor = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
Putting it together
src/main.ts
Open up src/main.ts and update it to look something like this:
typescript
// Global events
import { DOMContentLoaded } from './lib/spa/utils/dom-content-loaded';
import { eventAfterAppRender } from './lib/spa/utils/event-after-app-render';
import { popstate } from './lib/spa/utils/popstate';
// Styles
import './styles/index.styl';
// Events that happens on URL change in our SPA
window.addEventListener('popstate', popstate);
// Events that happens after DOM is loaded
document.addEventListener('DOMContentLoaded', DOMContentLoaded);
// Events that happens after eveyr App render
document.addEventListener('eventAfterAppRender', eventAfterAppRender);
export const StartPage = async () => {
return `<div>
<h1>
Hello world
</h1>
<p>
Here is the link to <a href="/about" data-link="/about">The about page</a>
</p>
</div>`;
};
Important
Notice the usage of the data-link attribute? This is to make sure we are using the built in method of navigating in the SPA, so we can use pushState.
src/pages/AboutPage/index.ts
typescript
export const AboutPage = async () => {
return `<div>
<h1>
About
</h1>
<p>
Here is the link to <a href="/" data-link="/">The home page</a>
</p>
</div>`;
};
And you are now ready to start creating your content and components in a full fledged Vanilla JavaScript Single Page Application, with no frameworks!
About the author
Hi! My name is Alexander, and I am a creative frontender, specializing
in UX, accessibility, universal design, frontend-architecture, node and
design systems. I am passionate with open source projects and love to
dabble with new emerging technologies related to frontend. With over
26 years of
frontend experience, I have earned the right to be called a veteran. I
am a lover of life, technologist at heart. If I am not coding, I am
cooking and I love whisky and cigars. Oh, and coffee, I LOVE coffee!
I am also an avid speaker on several topics! Check
out some of the things I speak about,
and contact me if you are interested in having me at your next event!