Photo by Joaquim Campa
Solution 2: Single Page Applications
The main feature of an SPA is its router implementation: so that you can keep one page and never reload.
Let's make an SPA for our FruitLovers store. Famous SPA frameworks include React, Vue.js and Angular. However I'm really down to keep our examples as close to web standards as possible so we'll be using Web Components, helped by a router called Vaadin Router.
If you're not familiar with Web Components, this introductory guide from MDN Web Docs is quite nice:
https://developer.mozilla.org/en-us/docs/Web/Web_Components
With this example we will enter the realm of JavaScript and client scripting for the first time. Our main and only page will look like this now:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FruitLovers - Home</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>🍋 FruitLovers</h1>
<p>
If life gives you lemons...
</p>
</header>
<main id="app">
Loading...
</main>
<footer>
FruitLovers © 2021
</footer>
<script src="app.js" type="module"></script>
</body>
</html>
Now the magic will happen at app.js
, and the <main id="app"></main>
element is where our router will be attached. We'll see that later on.
First, the data, unchanged, just as we had it before:
[
{
"name": "lemon",
"title": "Lemons, our classic",
"price": "2€",
"image": "lemon.jpg"
},
{
"name": "strawberry",
"title": "Strawberries, less sugar than lemons!",
"price": "3€",
"image": "strawberry.jpg"
}
]
Now let's define a couple of helper functions to help us with the fruits list:
// This returns a <li> element based on a fruit object
function getFruitItemHTML({ name, title }) {
return `<li><a href="/products/${name}.html">${title}</a></li>`
}
// This returns a <ul> element based on a fruits list array
export function getFruitsListHTML(fruitsList) {
return `<ul>${fruitsList.map(getFruitItemHTML).join('')}</ul>`
}
Vaadin Router is specifically designed with Web Components in mind, where each route is managed by a single Web Component. As we've seen from the beginning, we have two types of pages, home and product pages, so we will only need two Web Components.
Making use of our helper function by importing it, let's define the Web Component for our home:
import { getFruitsListHTML } from '../lib/getFruitsListHTML.js'
export function defineFlHome(fruits) {
customElements.define('fl-home',
class extends HTMLElement {
connectedCallback() {
document.title = 'FruitLovers - Home'
this.innerHTML = `
<h2>
Fruits you can buy from us
</h2>
${getFruitsListHTML(fruits)}
`
}
}
);
}
It's basically a header and a list of our products. In a similar fashion, let's define the Web Component for our product:
import { getFruitsListHTML } from '../lib/getFruitsListHTML.js'
export function defineFlProduct(fruits){
customElements.define('fl-product',
class extends HTMLElement {
connectedCallback() {
const requestedProductName = this.location.params.product
const restOfFruits = fruits.filter(({name}) => name !== requestedProductName)
const requestedProduct = fruits.find(({ name }) => name === requestedProductName)
document.title = `FruitLovers - ${requestedProduct.title}`
this.innerHTML = `
<a href="/index.html">← Home</a>
<h2>
${requestedProduct.title}
</h2>
<p>
Today's price is: ${requestedProduct.price}}
</p>
<img width="300" src="${requestedProduct.image}">
<h2>
Other fruits you can buy from us:
</h2>
${getFruitsListHTML(restOfFruits)}
`
}
}
)
}
This gets a bit more complex since we need a modified list of fruits, taking out the actual fruit we're seeing. this.location.params
is provided by the router in this case (See #accessing Route Parameters in the Vaadin Router docs).
Lastly, our router at app.js
will look like this:
import { Router } from 'https://unpkg.com/@vaadin/router'
import { defineFlHome } from './components/fl-home.js'
import { defineFlProduct } from './components/fl-product.js'
fetch('/data.json')
.then(_ => _.json())
.then(fruits => {
defineFlHome(fruits)
defineFlProduct(fruits)
})
const appContainer = document.getElementById('app')
appContainer.innerHTML = ''
const router = new Router(appContainer)
router.setRoutes([
{ path: '/(index.html)?', component: 'fl-home' },
{ path: '/products/:product.html', component: 'fl-product'},
]);
Note how the process to fetch the data is asynchronous, in this implementation: only after loading the fruits we define the UI of each page/component through its Web Component definition.
Also note how we've implemented a very basic loading state. Remember initially the home page has this as its main content:
<main id="app">
Loading...
</main>
So the user will see this up until the router loads the actual content.
The final result loads pages instantly, just as promised, voilá!
OK, so far we've seen pages been produced at servers with Server Side Rendering in Part I. As we've seen SSR involves great complexity at the server level.
In Part II we've seen Single Page Applications. Here the pages are managed by a router running in the client's browser. In SPAs the meaning of a "page" gets diluted in... routes? SPAs are great when the content is specific to the visitor: a bank, a social network... anywhere where you need to sign in and authenticate!
What if you just want a website with "real" pages like in our very basic first example served by a static server? Amongst the list of reasons to prefer this is SEO (Search Engine Optimisation). Also static servers are cheaper... or even free!
Head on to part IV...
Top comments (0)