Build A Single Page Application SPA Site With Vanillajs
Build A Single Page Application SPA Site With Vanillajs
Build A Single Page Application SPA Site With Vanillajs
js
blog.jeremylikness.com/blog/build-a-spa-site-with-vanillajs
You are viewing a limited version of this blog. To enable experiences like comments,
opt-in to our privacy and cookie policy.
The Project
I implemented a Single Page Application (SPA) app based completely on pure
JavaScript (“Vanilla.js”). It includes routing (you can bookmark and navigate pages),
databinding, reusable web components and uses JavaScript’s native module
functionality. You can run and install the application (it is a Progressive Web App or
PWA) here:
https://jlik.me/vanilla-js
JeremyLikness/vanillajs-deck
If you open index.html you’ll notice a script is included with a special type of
“module”:
The module simply imports and activates web components from several other modules.
1/14
Organized Code with Modules
Native JavaScript modules are like ordinary JavaScript files with a few key differences.
They should be loaded with the type="module" modifier. Some developers prefer to
use the .mjs suffix to distinguish them from other JavaScript source, but that is not
required. Modules are unique in a few ways:
document.addEventListener("DOMContentLoaded", app);
Taking a step back, the overall structure or hierarchy of the application looks like this:
app.js
-- navigator.js
-- slideLoader.js
.. slide.js ⤵
-- slide.js
-- dataBinding.js
-- observable.js
-- router.js
-- animator.js
-- controls.js
.. navigator.js ⤴
-- keyhandler.js
.. navigator.js ⤴
2/14
This post will explore the module from the bottom-up, starting with modules that don’t
have dependencies and working our way up to the navigator.js web component.
It’s important to note this does not provide security: malicious scripts can still be
executed; it only serves to provide a databinding scope. Building for production would
require a much more involved process to parse out only “acceptable” expressions to avoid
security exploits.
The observable and computed methods are simply helpers to create new instances
of the related classes. They are used in the slides to set up databinding expressions. This
is something easier “seen than said” so I’ll provide an end-to-end example shortly.
3/14
bindValue(input, observable) {
const initialValue = observable.value;
input.value = initialValue;
observable.subscribe(() => input.value = observable.value);
let converter = value => value;
if (typeof initialValue === "number") {
converter = num => isNaN(num = parseFloat(num)) ? 0 : num;
}
input.onkeyup = () => {
observable.value = converter(input.value);
};
}
It is called from a bindObservables method that finds any elements with a data-bind
attribute. Note again this code is simplified because it assumes the elements are input
elements and does not do any validation.
bindObservables(elem, context) {
const dataBinding = elem.querySelectorAll("[data-bind]");
dataBinding.forEach(elem => {
this.bindValue(elem,
context[elem.getAttribute("data-bind")]);
});
}
The bindLists method is a little more complicated. It assumes it will iterate a (non-
observable) list. First, any elements with a repeat attribute are found. The value is
assumed to be a list reference and is iterated to produce a list of child elements. A
regular expression is used to replace binding statements {{item.x}} with the actual
value using executeInContext .
At this stage it makes sense to take a step back and see the bigger picture. You can run
the data-binding example here.
<label for="first">
<div>Number:</div>
<input type="text" id="first" data-bind="n1"/>
</label>
const n1 = this.observable(2);
this.n1 = n1;
The context exists on the slide: slide.ctx = {} so when the script is evaluated, it
becomes slide.ctx = { n1: Observable(2) } . The binding is then set up between the
input field and the observable. In the case of the list, each list item is evaluated based on
the databinding template to grab the corresponding value. What’s missing here is the
“context” that exists on the slide. Let’s look at the slide and sideLoader modules next.
4/14
Hosting and Loading Slides as “Pages”
The Slide class in slide.js is a simple class to hold the information that represents a
“slide” in the app. It has a _text property that is read from the actual slide. For
example, here is the raw text of 001-title.html.
The most important property is the _html property. This is a div element that wraps
the content of the slide. The slide contents are assigned to the innerHTML property to
create an active DOM node that can be easily swapped in and out as slides are
navigated. This code in the constructor sets up the HTML DOM:
this._html = document.createElement('div');
this._html.innerHTML = text;
If there is a <script> tag in the slide, it is parsed in the context of the slide. The
databinding helper is called to parse all attributes and render the associated lists and
create two-way bindings between input elements and the observable data.
This sets up the slide in a “born ready” mode just waiting to appear. The slideLoader.js
module is what loads the slides. It assumes they exist in a slides subdirectory with an
.html suffix. This code reads the slide and creates a new instance of the Slide class.
The main function takes the first slide, then iterates all slides by reading the nextSlide
property. To avoid getting caught in an infinite loop, a cycle object keeps track of
5/14
slides that are already loaded and stops loading when there is a duplicate or no more
slides to parse.
The loader is used by the navigator.js module that will be explored later.
The constructor uses a “phantom DOM node” (a div element that is never rendered) to
set up a custom routechanged event.
this._eventSource = document.createElement("div");
this._routeChanged = new CustomEvent("routechanged", {
bubbles: true,
cancelable: false
});
this._route = null;
It then listens for browser navigation (the popstate event) and if the route (slide) has
changed, it updates the route and raises the custom routechanged event.
window.addEventListener("popstate", () => {
if (this.getRoute() !== this._route) {
this._route = this.getRoute();
this._eventSource.dispatchEvent(this._routeChanged);
}
});
6/14
Other modules use the router to set the route when the slide is changed, or to show the
correct slide when the route has changed (i.e. the user navigated to a bookmark or used
the forward/backward buttons).
@keyframes slide-left {
from {
margin-left: 0vw;
}
to {
margin-left: -100vw;
}
}
@keyframes enter-right {
from {
margin-left: 100vw;
}
to {
margin-left: 0vw;
}
}
.anim-slide-left-begin {
animation-name: slide-left;
animation-timing-function: ease-in;
animation-duration: 0.5s;
}
.anim-slide-left-end {
animation-name: enter-right;
animation-timing-function: ease-out;
animation-duration: 0.3s;
}
7/14
3. A flag is set to indicate a transition is in process. This prevents additional
navigation during an existing transition event.
4. An event listener is attached to the HTML element that will fire when the
associated animation ends.
5. The animation “begin” class is added to the element. This fires the animation.
6. When the animation ends, the event listener is removed, transition flag is turned
off, and the “begin” class is removed from the element. The callback is fired.
The callback will inform the host the transition is complete. In this case, navigator.js
will pass a callback. The callback advances the slide, then calls endAnimation . The
code is like the start animation, with the exception it resets all properties when
complete.
endAnimation(host) {
this._transitioning = true;
const animationEnd = () => {
host.removeEventListener("animationend", animationEnd);
host.classList.remove(this._end);
this._transitioning = false;
this._begin = null;
this._end = null;
}
host.addEventListener("animationend", animationEnd, false);
host.classList.add(this._end);
}
The steps will be clearer when you see how the code is handled by the navigator module
that is covered next.
8/14
export class Navigator extends HTMLElement { }
The module exposes a registerDeck function to register the web component. I chose to
create a new HTML element <slide-deck/> so it is registered like this:
The constructor calls the parent constructor which is built into the browser to initialize
HTML elements. It then creates instances of the router and animator and gets the
current route. It exposes a custom slideschanged event, then listens to the router’s
routetchanged event and advances to the appropriate slide when it is fired.
super();
this._animator = new Animator();
this._router = new Router();
this._route = this._router.getRoute();
this.slidesChangedEvent = new CustomEvent("slideschanged", {
bubbles: true,
cancelable: false
});
this._router.eventSource.addEventListener("routechanged", () => {
if (this._route !== this._router.getRoute()) {
this._route = this._router.getRoute();
if (this._route) {
const slide = parseInt(this._route) - 1;
this.jumpTo(slide);
}
}
});
To load the slides, a custom start attribute is defined. The main index.html sets up
the web component like this:
Note the element has innerHTML like any other HTMLElement , so the HTML is
rendered until it is replaced. To parse the attribute requires two steps. First, the
attribute must be observed. By convention, this is done with a static property
observedAttributes :
Next, a callback is implemented that is called whenever the attributes change (including
the first time they are parsed and set). This callback is used to get the start attribute
value and load the slides, then show the appropriate slide based on whether it was
invoked with a route.
9/14
async attributeChangedCallback(attrName, oldVal, newVal) {
if (attrName === "start") {
if (oldVal !== newVal) {
this._slides = await loadSlides(newVal);
this._route = this._router.getRoute();
var slide = 0;
if (this._route) {
slide = parseInt(this._route) - 1;
}
this.jumpTo(slide);
this._title = document.querySelectorAll("title")[0];
}
}
}
The remaining properties and methods deal with the current slide, total slide count, and
navigation. For example, hasPrevious will return true for everything but the first
slide. hasNext is a bit more involved. For things like revealing cards or lists one item at
a time, a class named appear can be applied. It hides the element but when the slides
are “advanced” and an element exists with that class, it is removed. This results in that
element appearing. The check looks to see if the class exists on any elements first, then
checks to see if the index is on the last slide.
get hasNext() {
const host = this.querySelector("div");
if (host) {
const appear = host.querySelectorAll(".appear");
if (appear && appear.length) {
return true;
}
}
return this._currentIndex < (this.totalSlides - 1);
}
The jumpTo method navigates to a new slide. It ignores the request if a transition is
taking place. Otherwise, it clears the contents of the parent container and attaches the
new slide. It updates the page title and raises the slideschanged event. If the jump
occurs at the end of a transition, it kicks off the ending animation.
10/14
jumpTo(slideIdx) {
if (this._animator.transitioning) {
return;
}
if (slideIdx >= 0 && slideIdx < this.totalSlides) {
this._currentIndex = slideIdx;
this.innerHTML = '';
this.appendChild(this.currentSlide.html);
this._router.setRoute((slideIdx + 1).toString());
this._route = this._router.getRoute();
document.title = `${this.currentIndex + 1}/${this.totalSlides}: ${this.currentSlide.title}`;
this.dispatchEvent(this.slidesChangedEvent);
if (this._animator.animationReady) {
this._animator.endAnimation(this.querySelector("div"));
}
}
}
The next function is responsible for the ordinary flow from one slide to the next. If
there is an element with the appear class, it will simply remove the class to make it
appear. Otherwise, it checks to see if there is a subsequent slide. If the slide has an
animation, it kicks off the begin animation with a callback to jump to the next slide
when the animation is complete (the jump will run the end animation). If there is no
transition, it jumps directly to the slide.
next() {
if (this.checkForAppears()) {
this.dispatchEvent(this.slidesChangedEvent);
return;
}
if (this.hasNext) {
if (this.currentSlide.transition !== null) {
this._animator.beginAnimation(
this.currentSlide.transition,
this.querySelector("div"),
() => this.jumpTo(this.currentIndex + 1));
}
else {
this.jumpTo(this.currentIndex + 1);
}
}
}
This web component hosts the slide deck. There are two more components that work
with it to control the slides: a key press handler for keyboard navigation, and a set of
controls that can be clicked or tapped.
Keyboard Support
The keyhandler.js module is another web component defined as <key-handler/> .
11/14
export const registerKeyHandler =
() => customElements.define('key-handler', KeyHandler);
<key-handler deck="main"></key-handler>
It has one attribute named deck that points to the id of a navigator.js instance.
When it is set, it saves a reference to the deck. It then listens for right arrow (code 39) or
the space bar (code 32) to advance the deck, or the left arrow (code 37) to move to the
previous slide.
The code is purposefully simplified. It assumes the id is set correctly and doesn’t check
to see if the element is found and is an instance of a <slide-deck/> . It may also trigger
from inside an input box which is not an ideal user experience.
By plugging into the web component lifecycle method connectedCallback , the module
will dynamically load the template for the controls and wire in event listeners after the
parent element is inserted into the DOM.
12/14
async connectedCallback() {
const response = await fetch("./templates/controls.html");
const template = await response.text();
this.innerHTML = "";
const host = document.createElement("div");
host.innerHTML = template;
this.appendChild(host);
this._controlRef = {
first: document.getElementById("ctrlFirst"),
prev: document.getElementById("ctrlPrevious"),
next: document.getElementById("ctrlNext"),
last: document.getElementById("ctrlLast"),
pos: document.getElementById("position")
};
this._controlRef.first.addEventListener("click",
() => this._deck.jumpTo(0));
this._controlRef.prev.addEventListener("click",
() => this._deck.previous());
this._controlRef.next.addEventListener("click",
() => this._deck.next());
this._controlRef.last.addEventListener("click",
() => this._deck.jumpTo(this._deck.totalSlides - 1));
this.refreshState();
}
Notice the buttons simply call the existing methods exposed by the navigator.js
module. The module is referenced when the deck attribute is set. The code saves the
reference and listens to the slideschanged event.
Finally, refreshState is called upon initialization and whenever the slides change. It
determines which buttons to enable or disable based on what slide is being shown and
updates the x of y text as well.
13/14
refreshState() {
if (this._controlRef == null) {
return;
}
const next = this._deck.hasNext;
const prev = this._deck.hasPrevious;
this._controlRef.first.disabled = !prev;
this._controlRef.prev.disabled = !prev;
this._controlRef.next.disabled = !next;
this._controlRef.last.disabled =
this._deck.currentIndex === (this._deck.totalSlides - 1);
this._controlRef.pos.innerText =
`${this._deck.currentIndex + 1} / ${this._deck.totalSlides}`;
}
Because the control is a web component, a second instance could easily be placed at the
top of the page to provide more options for navigation if so desired.
Conclusion
The intent of this project is to show what is possible with purely modern JavaScript.
Frameworks still have their place, but it is important to understand what’s possible with
native capabilities to write code that is portable and maintainable (for example, a class
is a class in any framework). Mastering JavaScript can make it easier for you to
troubleshoot issues and provide better comprehension of features (for example, seeing
how to implement databinding may improve your understanding of how to use it in a
framework).
Regards,
14/14