8. Data management
8. Data management
Tijdens deze les bespreken we de verschillende manieren waarop data persistent gemaakt kan worden doorheen verschillende sessies. We bekijken zowel offline-first als online-first benaderingen.
Deze les bouwt voort op het lesvoorbeeld van vorige les.
Startbestanden
PersistenceProvider
Voor elke soort data (tabel) moeten minstens de basis CRUD-functionaliteiten aanwezig zijn. Daarom voorzien we een abstracte klasse die deze functionaliteiten beschrijft, aangezien de methodes een API moeten aanroepen of andere asynchrone operaties moeten uitvoeren, moeten deze methodes asynchroon zijn (of een Promise teruggeven). Vervolgens implementeren we de abstracte klasse in een concrete sub-klasse die de methodes implementeert via een in-memory, localStorage, IndexedDB, ... oplossing.
Aangezien de abstracte klasse met alle soorten data moet werken, gebruiken we een generische parameter. Verder moet elk record dat beheerd wordt door de PersistenceProvider een id hebben, anders kunnen we geen methodes schrijven die één record kan ophalen, updaten of verwijderen. Dit betekent dus dat de generische parameter ook aan deze voorwaarde moet voldoen.
Begrip: Generics
Generics maken het mogelijk om functies, klassen en interfaces te definiëren die werken met verschillende datatypes zonder dat je deze individueel moest vastleggen. Denk hier bijvoorbeeld aan de Array<T> klasse, deze kan werken met verschillende datatypes zoals string, number of boolean. De generic parameter T kan vervangen worden door eender welk datatype.
// De functie accepteerd een array van een bepaald datatype en
// retourneerd een array van hetzelfde datatype terug.
function doubleArray<T>(arr: T[]): T[] {
return [...arr, ...arr]
}
// Het type kan expliciet meegegeven worden tussen <>,
// dit is duidelijker maar niet altijd noodzakelijk.
doubleArray<number>([1, 2, 3]) // [1, 2, 3, 1, 2, 3]
// Het type meestal impliciet afgeleid worden van de waarde die je meegeeft.
// Aangezien dit een eenvoudige functie is, weet TypeScript dat
// het resultaat number[] (Array<number>) is.
doubleArray([1, 2, 3]) // [1, 2, 3, 1, 2, 3]Voordat we PersistenceProvider klasse kunnen implementeren, moeten we eerst een interface Persistable maken die enkel een id bevat. Deze interface kan dan als supertype gebruikt worden voor de generische parameter T in de PersistenceProvider klasse. Op deze manier garanderen we dat enkel objecten met een id gepersisteerd kunnen worden, het id is hier cruciaal, anders zou het onmogelijk zijn om een specifiek record op te halen, up te daten of te verwijderen.
Tenslotte gebruiken we de Omit utility om aan te geven dat de id parameter niet nodig is om een record te creëren. Op die manier kan je één model maken dat als basis kan dienen voor zowel de update (waar ID verplicht is) als voor de _create (waar ID niet meegegeven wordt, want deze wordt automatisch gegenereerd) methode.
export interface Persistable {
id: string
}
export abstract class PersistenceProvider<T extends Persistable> {
abstract create(data: Omit<T, 'id'>): Promise<T>
abstract get(id: string): Promise<T>
abstract getAll(): Promise<T[]>
abstract update(id: string, data: T): Promise<T>
abstract delete(id: string): Promise<void>
}MemoryPersistenceProvider
Als eerste implementatie voorzien we een oplossing die de CRUD-operaties uitvoert op een in-memory array, we vervangen de dataManager uit de vorige les dus door een MemoryPersistenceProvider klasse. Aangezien we alles in memory uitvoeren, voegen we ook een optionele parameter toe aan de constructor zodat we de verschillende methodes kunnen testen zonder telkens opnieuw data in te voegen.
Merk op dat de data property private is (de # prefix is een TypeScript feature die aangeeft dat de property), door deze property private te maken, garanderen we dat de data enkel gemanipuleerd kan worden via de methodes van de MemoryPersistenceProvider klasse en niet direct via de property. Zo verkleinen we de kans op bugs, als de data niet correct aangepast wordt, moeten we op één vaste plaats gaan zoeken naar het probleem in de plaats van overal waar de data property aangesproken wordt.
import {Persistable, PersistenceProvider} from './persistenceProvider.ts'
export class MemoryPersistenceProvider<T extends Persistable> extends PersistenceProvider<T> {
#data: T[] = []
constructor(initialData: T[] = []) {
super()
this.#data = initialData
}
async create(data: Omit<T, 'id'>): Promise<T> {
const newObject = {...data, id: window.crypto.randomUUID()} as T
this.#data.push(newObject)
return newObject
}
async delete(id: string): Promise<void> {
this.#data = this.#data.filter(x => x.id !== id)
}
async get(id: string): Promise<T> {
const item = this.#data.find(x => x.id === id)
if (!item) {
throw new Error(`No item found with the given id: ${id}`)
}
return item
}
async getAll(): Promise<T[]> {
return this.#data
}
async update(id: string, data: T): Promise<T> {
const index = this.#data.findIndex(x => x.id === id)
if (index === -1) {
throw new Error(`No item found with the given id: ${id}`)
}
// De data op index moet overschreven worden met de nieuwe data.
// Om te garanderen dat het ID correct blijft en niet overschreven kan worden door de
// ingestuurde data, maken we een kopie van de ingestuurde data en voegen we
// daarna het id uit de eerste parameter achteraan toe.
this.#data[index] = {...data, id: this.#data[index].id}
return this.#data[index]
}
}Reageren op aanpassingen in data
Alhoewel bovenstaande code de CRUD-operaties goed beschrijft, is er een probleem als we deze de verschillende persistence providers willen aanroepen in de Page klassen. Omdat de CRUD-operaties asynchroon zijn, moeten we wachten tot de data opgehaald is voordat we deze kunnen gebruiken.
Aangezien de render methode synchroon is kunnen we daar geen await gebruiken. Het is natuurlijk mogelijk om de methode aan te passen naar een asynchrone methode, maar dat zou betekenen dat het render process gepauzeerd wordt omdat we wachten op de data. Aangezien een asynchrone methode lang kan duren, kan dit een slechte gebruikerservaring geven omdat de UI (deels) onzichtbaar is. Daarbovenop zou het mogelijk zijn dat de gebruiker op het stukje dat al gerenderd is, klikt en dat er zo een nieuwe render getriggerd wordt. Dit kan tot heel rare bugs leiden omdat de verschillende oproepen aan de render methode in eender welke volgorde afgehandeld kunnen worden (we weten niet hoe lang een asynchroon process duurt), je zo dus UI krijgen waar bepaalde data niet in zit of juist verschillende keren in voorkomt.
Om zulke bugs te vermijden houden we de render methode synchroon en abonneren we ons in de pagina's op wijzigingen in de data van een persistence provider, we doen dit via het observer patroon.
Begrip: Observer patroon
Observer is een ontwerppatroon in de object-georiënteerde softwareontwikkeling. Het patroon maakt het mogelijk om data centraal te beheren en te synchroniseren met meerdere objecten zonder dat deze objecten constant moeten pollen voor updates.
Het centrale object (het "subject", hieronder de PersistenceProvider) houdt een lijst bij van "observers" (hieronder PageA en PageB). Een observer komt pas in de lijst te staan als deze zich registreert bij het subject (hieronder via de addObserver methode). Via deze methode geeft je een callback functie door die wordt aangeroepen als de data verandert, deze callback functie wordt dan opgeslagen in de lijst van observers in het centrale object (hieronder PersistenceProvider).
Wanneer de data verandert, roept het subject de notifyObservers methode aan, die op zijn beurt door de lijst van observers itereert en de callback functie aanroept met de nieuwe data.
We voegen dus twee arrays toe die de callback functies bevatten waarmee de observers verwittigd worden wanneer de data gewijzigd is. De eerste array bevat de observers die geïnteresseerd zijn in de volledige data, i.e. een array van type T[], de tweede array bevat de observers die geïnteresseerd zijn in een specifiek record, i.e. een object van type T.
Daarnaast voegen we ook functies toe waarmee de pagina's zich kunnen abonneren op wijzigingen in de data en twee functies waarmee we alle abonnees kunnen verwittigen wanneer de data gewijzigd is.
export interface Persistable {
id: string
}
export type ChangeObserver<T extends Persistable> = (data: T[]) => void
export type ItemChangeObserver<T extends Persistable> = (data: T | null) => void
export abstract class PersistenceProvider<T extends Persistable> {
protected observers: ChangeObserver<T>[] = []
protected itemObservers: Record<string, ItemChangeObserver<T>[]> = {}
addObserver(observer: ChangeObserver<T>): void {
this.observers.push(observer)
}
addItemObserver(id: string, observer: ItemChangeObserver<T>): void {
if (!this.itemObservers[id]) {
this.itemObservers[id] = []
}
this.itemObservers[id].push(observer)
}
protected notifyObservers(data: T[]) {
this.observers.forEach(observer => observer(data))
}
protected notifyItemObservers(id: string, data: T | null) {
this.itemObservers[id]?.forEach(observer => observer(data))
}
abstract create(data: Omit<T, 'id'>): Promise<T>
abstract get(id: string): Promise<T>
abstract getAll(): Promise<T[]>
abstract update(id: string, data: T): Promise<T>
abstract delete(id: string): Promise<void>
}Vervolgens moeten de correcte observers opgeroepen worden op het moment dat de data gewijzigd is.
import {Persistable, PersistenceProvider} from './persistenceProvider.ts'
export class MemoryPersistenceProvider<T extends Persistable> extends PersistenceProvider<T> {
#data: T[] = []
constructor(initialData: T[] = []) {
super()
this.#data = initialData
}
async create(data: Omit<T, 'id'>): Promise<T> {
const newObject = {...data, id: window.crypto.randomUUID()} as T
this.#data.push(newObject)
this.notifyObservers(this.#data)
return newObject
}
async delete(id: string): Promise<void> {
this.#data = this.#data.filter(x => x.id !== id)
this.notifyObservers(this.#data)
this.notifyItemObservers(id, null)
}
async get(id: string): Promise<T> {
const item = this.#data.find(x => x.id === id)
if (!item) {
throw new Error(`No item found with the given id: ${id}`)
}
this.notifyObservers(this.#data)
this.notifyItemObservers(id, item)
return item
}
async getAll(): Promise<T[]> {
this.notifyObservers(this.#data)
return this.#data
}
async update(id: string, data: T): Promise<T> {
const index = this.#data.findIndex(x => x.id === id)
if (index === -1) {
throw new Error(`No item found with the given id: ${id}`)
}
this.#data[index] = {...data, id: this.#data[index].id}
this.notifyObservers(this.#data)
this.notifyItemObservers(id, this.#data[index])
return this.#data[index]
}
}MemoryPersistence gebruiken
Nu dat de observers grotendeels afgewerkt zijn, kunnen we drie MemoryPersistenceProvider instanties aanmaken die de auteurs, series en boeken beheren. Vervolgens gebruiken we deze instanties in de Page klassen om de data op te halen en te renderen.
import {Book} from '../models/book.ts'
import {Author} from '../models/author.ts'
import {Series} from '../models/series.ts'
import {MemoryPersistenceProvider} from './memoryPersistenceProvider.ts'
export const bookPersistenceProvider = new MemoryPersistenceProvider<Book>([
{
id: '2f14bf86-2837-4b92-b329-a78ea71ca4ad',
series: {name: 'Hyperion Cantos', id: '0f31cbcc-bd4b-4ece-b169-2c931ed8e41b', number: 1},
publicationYear: 1989,
author: {
id: '4a1d3bd4-e35f-4301-af14-80a3aa0b0079',
name: 'Dan Simmons',
description: 'Dan Simmons (born April 4, 1948) is an American science fiction and horror writer. He is the author of the Hyperion Cantos and the Ilium/Olympos cycles, among other works that span the science fiction, horror, and fantasy genres, sometimes within a single novel. Simmons\'s genre-intermingling Song of Kali (1985) won the World Fantasy Award.',
profile: 'https://encrypted-tbn3.gstatic.com/images?q=tbn:ANd9GcSZ53vcGWYo-6Cpi5WFbS1ndlCtXGu8E-yj2VThSl6NTr7_dcqsJWqJv8wn2ORqUL-S4xjPhySmBuba5yWrkc4hxg',
},
type: 'print',
title: 'Hyperion',
},
])
export const authorPersistenceProvider = new MemoryPersistenceProvider<Author>([
{
id: '6f7753e0-134b-434f-90f4-214389dd5041',
name: 'Steven Erikson',
description: 'Steve Rune Lundin (born October 7, 1959), known by his pseudonym Steven Erikson, is a Canadian novelist who was educated and trained as both an archaeologist and anthropologist.',
profile: 'https://upload.wikimedia.org/wikipedia/commons/2/2d/Steven_Erikson_2016.jpg',
},
{
id: '4a1d3bd4-e35f-4301-af14-80a3aa0b0079',
name: 'Dan Simmons',
description: 'Dan Simmons (born April 4, 1948) is an American science fiction and horror writer. He is the author of the Hyperion Cantos and the Ilium/Olympos cycles, among other works that span the science fiction, horror, and fantasy genres, sometimes within a single novel. Simmons\'s genre-intermingling Song of Kali (1985) won the World Fantasy Award.',
profile: 'https://encrypted-tbn3.gstatic.com/images?q=tbn:ANd9GcSZ53vcGWYo-6Cpi5WFbS1ndlCtXGu8E-yj2VThSl6NTr7_dcqsJWqJv8wn2ORqUL-S4xjPhySmBuba5yWrkc4hxg',
},
{
id: 'b4cc061f-0743-449e-b0be-cce7055f1154',
name: 'Robin Hobb',
description: 'Margaret Astrid Lindholm Ogden (born March 5, 1952; née Lindholm), known by her pen names Robin Hobb and Megan Lindholm, is an American writer of speculative fiction. As Hobb, she is best known for her fantasy novels set in the Realm of the Elderlings.',
profile: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Robin_Hobb_by_Gage_Skidmore.jpg/1920px-Robin_Hobb_by_Gage_Skidmore.jpg',
},
])
export const seriesPersistenceProvider = new MemoryPersistenceProvider<Series>([
{name: 'The Malazan Book of the Fallen', id: 'b16cca4a-e13e-4854-bb34-7fac4fd31f02'},
{name: 'Hyperion Cantos', id: '0f31cbcc-bd4b-4ece-b169-2c931ed8e41b'},
{name: 'The Farseer Trilogy', id: '5269d1b5-19c0-432f-a797-c88fead0475f'},
])import {Page} from '../../router/page.ts'
import HTML from './authors.html?raw'
import {Author as AuthorComponent} from '../../components/author/author.ts'
import {authorPersistenceProvider} from '../../data/data.ts'
import {Author} from '../../models/author.ts'
export class AuthorsPage extends Page {
readonly #nameInput = this.body.querySelector<HTMLInputElement>('#authorName')!
readonly #authorForm = this.body.querySelector<HTMLFormElement>('#authorForm')!
readonly #authorProfile = this.body.querySelector<HTMLFormElement>('#authorProfile')!
readonly #authorDescription = this.body.querySelector<HTMLFormElement>('#authorDescription')!
readonly #authorList = this.body.querySelector<HTMLUListElement>('#authorList')!
readonly #modalClose = this.body.querySelector<HTMLButtonElement>('#close-btn')!
#authors: Author[] = []
constructor() {
super(HTML)
this.#authorForm.addEventListener('submit', async (evt) => {
evt.preventDefault()
await this.#createAuthor()
this.#authorForm.reset()
this.#modalClose.click()
})
authorPersistenceProvider.addObserver(data => {
this.#authors = data
this.render()
})
void authorPersistenceProvider.getAll()
}
render() {
super.render()
this.#authorList.innerHTML = ''
this.#authors.forEach(a => {
const author = new AuthorComponent()
author.setAttribute('name', a.name)
author.setAttribute('image', a.profile)
author.setAttribute('description', a.description)
this.#authorList.appendChild(author)
})
}
async #createAuthor(): Promise<void> {
const name = this.#nameInput.value
const profile = this.#authorProfile.value
const description = this.#authorDescription.value
await authorPersistenceProvider.create({
name,
profile,
description,
})
}
}import {Book, BookType} from '../../models/book.ts'
import {Page} from '../../router/page.ts'
import HTML from './library.html?raw'
import {authorPersistenceProvider, bookPersistenceProvider, seriesPersistenceProvider} from '../../data/data.ts'
import {Author} from '../../models/author.ts'
import {Series} from '../../models/series.ts'
export class LibraryPage extends Page {
readonly #bookList = this.body.querySelector<HTMLTableSectionElement>('#bookTableBody')!
readonly #bookForm = this.body.querySelector<HTMLFormElement>('#bookForm')!
readonly #titleInput = this.body.querySelector<HTMLInputElement>('input[name="title"]')!
readonly #yearInput = this.body.querySelector<HTMLInputElement>('input[name="year"]')!
readonly #seriesNumberInput = this.body.querySelector<HTMLInputElement>('input[name="seriesNumber"]')!
readonly #authorSelect = this.body.querySelector<HTMLInputElement>('select[name="author"]')!
readonly #typeSelect = this.body.querySelector<HTMLInputElement>('select[name="type"]')!
readonly #seriesSelect = this.body.querySelector<HTMLSelectElement>('select[name="series"]')!
#books: Book[] = []
#authors: Author[] = []
#series: Series[] = []
constructor() {
super(HTML)
this.#bookForm.addEventListener('submit', async evt => {
evt.preventDefault()
await this.#createBook()
this.#bookForm.reset()
void this.render()
})
bookPersistenceProvider.addObserver(books => {
this.#books = books
this.render()
})
authorPersistenceProvider.addObserver(authors => {
this.#authors = authors
this.render()
})
seriesPersistenceProvider.addObserver(series => {
this.#series = series
this.render()
})
void bookPersistenceProvider.getAll()
void authorPersistenceProvider.getAll()
void seriesPersistenceProvider.getAll()
}
render() {
super.render()
// Boeken tabel opvullen.
this.#bookList.innerHTML = ''
this.#books.map(b => this.#bookList.appendChild(this.#buildBookRow(b)))
// Series dropdown opvullen.
this.#seriesSelect.innerHTML = ''
this.#seriesSelect.appendChild(this.#createSelectOption('', '--Series--'))
this.#series.forEach(s => this.#seriesSelect.appendChild(this.#createSelectOption(s.id, s.name)))
// Author dropdown opvullen.
this.#authorSelect.innerHTML = ''
this.#authorSelect.appendChild(this.#createSelectOption('', '--Author--'))
this.#authors.forEach(a => this.#authorSelect.appendChild(this.#createSelectOption(a.id, a.name)))
}
#buildBookRow(book: Book): HTMLTableRowElement {
const bookRow = document.createElement('tr')
bookRow.appendChild(this.#createTableCell(book.title))
bookRow.appendChild(this.#createTableCell(book.publicationYear.toString()))
bookRow.appendChild(this.#createTableCell(book.author.name))
bookRow.appendChild(this.#createTableCell(book.type))
bookRow.appendChild(this.#createTableCell(book.series ? `${book.series?.name} (${book.series?.number})` : ''))
const deleteButton = document.createElement('button')
deleteButton.innerHTML = 'Delete'
deleteButton.addEventListener('click', async () => {
await bookPersistenceProvider.delete(book.id)
this.render()
})
bookRow.appendChild(this.#createTableCell(deleteButton))
return bookRow
}
#createTableCell(content: string | HTMLElement): HTMLTableCellElement {
const cell = document.createElement('td')
if (typeof content === 'string') {
cell.innerHTML = content
} else {
cell.appendChild(content)
}
return cell
}
#createSelectOption(value: string, label: string): HTMLOptionElement {
const option = document.createElement('option')
option.value = value
option.innerText = label
return option
}
async #createBook() {
const series = this.#series.find(s => s.id === this.#seriesSelect.value)!
const author = this.#authors.find(a => a.id === this.#authorSelect.value)!
await bookPersistenceProvider.create({
title: this.#titleInput.value,
series: this.#seriesSelect.value === '' ? undefined : {
...series,
number: Number(this.#seriesNumberInput.value),
},
type: this.#typeSelect.value as BookType,
author,
publicationYear: Number(this.#yearInput.value),
})
}
}import {Page} from '../../router/page.ts'
import HTML from './series.html?raw'
import {seriesPersistenceProvider} from '../../data/data.ts'
import {Series as SeriesComponent} from '../../components/series/series.ts'
import {Series} from '../../models/series.ts'
export class SeriesPage extends Page {
readonly #seriesList = this.body.querySelector<HTMLUListElement>('#seriesList')!
readonly #modalClose = this.body.querySelector<HTMLButtonElement>('#close-btn')!
readonly #nameInput = this.body.querySelector<HTMLInputElement>('#seriesName')!
readonly #seriesForm = this.body.querySelector<HTMLFormElement>('#seriesForm')!
#series: Series[] = []
constructor() {
super(HTML)
this.#seriesForm.addEventListener('submit', async (evt) => {
evt.preventDefault()
await this.#createSeries()
this.#seriesForm.reset()
this.#modalClose.click()
this.render()
})
seriesPersistenceProvider.addObserver(series => {
this.#series = series
this.render()
})
void seriesPersistenceProvider.getAll()
}
render() {
super.render()
this.#seriesList.innerText = ''
this.#series.forEach(s => {
const series = new SeriesComponent()
series.setAttribute('name', s.name)
series.addEventListener('seriesDeleted', async () => {
await seriesPersistenceProvider.delete(s.id)
this.render()
})
this.#seriesList.appendChild(series)
})
}
async #createSeries(): Promise<void> {
const name = this.#nameInput.value
await seriesPersistenceProvider.create({
name,
})
}
}Observers opruimen
Alles werkt nu, zoals onderstaande video demonstreert. Er is echter nog één belangrijk probleem, wanneer we wisselen tussen pagina's, blijven de observers bestaan. Dit betekent dat de observers verschillende keren opgeroepen worden voor één wijziging, wat leidt tot een trage website omdat we voor elke wijziging verschillende renders uitvoeren. Ook dit is gedemonstreerd in onderstaande video, hiervoor is een log statement toegevoegd aan de observer in de auteurspagina.
Het is duidelijk dat het aantal keer dat de observer opgeroepen wordt toeneemt naarmate we meer tussen pagina's wisselen. Om dit probleem op te lossen moeten de observers opgeruimd worden wanneer we de pagina verlaten. We implementeren dit door een Unsubscribe functie terug te geven als iemand zich abonneert op wijzigingen in de data. De functies kunnen bewaard worden per pagina, vervolgens roepen we al deze functies op op het moment dat we van pagina wisselen.
Hieronder wordt enkel de inhoud van de AuthorPage getoond, maar de andere pagina's zijn analoog.
export interface Persistable {
id: string
}
export type ChangeObserver<T extends Persistable> = (data: T[]) => void
export type ItemChangeObserver<T extends Persistable> = (data: T | null) => void
export type Unsubscribe = () => void
export abstract class PersistenceProvider<T extends Persistable> {
protected observers: ChangeObserver<T>[] = []
protected itemObservers: Record<string, ItemChangeObserver<T>[]> = {}
addObserver(observer: ChangeObserver<T>): Unsubscribe {
this.observers.push(observer)
return () => {
this.observers = this.observers.filter(x => x !== observer)
}
}
addItemObserver(id: string, observer: ItemChangeObserver<T>): Unsubscribe {
if (!this.itemObservers[id]) {
this.itemObservers[id] = []
}
this.itemObservers[id].push(observer)
return () => {
this.itemObservers[id] = this.itemObservers[id].filter(x => x !== observer)
}
}
protected notifyObservers(data: T[]) {
this.observers.forEach((observer) => observer(data));
}
protected notifyItemObservers(id: string, data: T | null) {
this.itemObservers[id]?.forEach((observer) => observer(data));
}
abstract create(data: Omit<T, 'id'>): Promise<T>
abstract get(id: string): Promise<T>
abstract getAll(): Promise<T[]>
abstract update(id: string, data: T): Promise<T>
abstract delete(id: string): Promise<void>
}import {Unsubscribe} from '../data/persistenceProvider.ts'
export abstract class Page {
protected readonly body: HTMLDivElement
protected unsubscribe: Unsubscribe[] = []
static readonly #root = document.querySelector<HTMLDivElement>('#app')!
protected constructor(body: string) {
this.body = document.createElement('div')
this.body.innerHTML = body
}
render() {
Page.#root.innerHTML = ''
Page.#root.appendChild(this.body)
}
cleanup() {
this.unsubscribe.forEach(x => x())
this.unsubscribe = []
}
}import {Page} from './page.ts'
type ConcretePage = new () => Page
type RouteMap = Record<string, ConcretePage>
export class Router {
readonly #pages: RouteMap
#activePage: Page | null = null
constructor(pages: RouteMap) {
this.#pages = pages
const pathName = window.location.pathname
this.navigate(this.#pages[pathName] ? pathName : '/')
}
navigate(path: string) {
this.#activePage?.cleanup()
// Toen de inhoud van de pagina waarnaar genavigeerd werd.
this.#activePage = new this.#pages[path]()
this.#activePage.render()
// Zorg dat links werken.
this.#setupRouter()
}
#setupRouter() {
document.querySelectorAll('[data-link]')?.forEach(link => {
const path = link.getAttribute('data-link')!
link.addEventListener('click', (evt) => {
evt.preventDefault()
window.history.pushState(null, '', `${window.location.origin}${path}`)
this.navigate(path)
})
})
}
}export class AuthorsPage extends Page {
readonly #nameInput = this.body.querySelector<HTMLInputElement>('#authorName')!
readonly #authorForm = this.body.querySelector<HTMLFormElement>('#authorForm')!
readonly #authorProfile = this.body.querySelector<HTMLFormElement>('#authorProfile')!
readonly #authorDescription = this.body.querySelector<HTMLFormElement>('#authorDescription')!
readonly #authorList = this.body.querySelector<HTMLUListElement>('#authorList')!
readonly #modalClose = this.body.querySelector<HTMLButtonElement>('#close-btn')!
#authors: Author[] = []
constructor() {
super(HTML)
this.#authorForm.addEventListener('submit', async (evt) => {
evt.preventDefault()
await this.#createAuthor()
this.#authorForm.reset()
this.#modalClose.click()
})
this.unsubscribe.push(authorPersistenceProvider.addObserver(data => {
console.log('IN AUTHOR OBSERVER')
this.#authors = data
this.render()
}))
void authorPersistenceProvider.getAll()
}
render() {
super.render()
this.#authorList.innerHTML = ''
this.#authors.forEach(a => {
const author = new AuthorComponent()
author.setAttribute('name', a.name)
author.setAttribute('image', a.profile)
author.setAttribute('description', a.description)
this.#authorList.appendChild(author)
})
}
async #createAuthor(): Promise<void> {
const name = this.#nameInput.value
const profile = this.#authorProfile.value
const description = this.#authorDescription.value
await authorPersistenceProvider.create({
name,
profile,
description,
})
}
}Na deze aanpassing is het probleem opgelost, de observer wordt nu slechts één keer opgeroepen als we tussen pagina's wisselen.
Custom elements optimaliseren
Vorige les hebben we custom events gebruikt om events uit een custom element uit te sturen wanneer een serie verwijderd werd. Aangezien er nu observers gebruikt worden in de PersistenceProvider, kunnen we het custom element van de serie aanpassen zodat de PersistenceProvider rechtstreeks aangesproken wordt in plaats van een custom event uit te sturen. Vervolgens worden alle observers verwittigd, de SeriesPage zal dus opnieuw renderen en de verwijderde serie verbergen.
export class Series extends CustomElement {
static observedAttributes = ["name", "id"]
readonly #name = this.componentBody.querySelector<HTMLDivElement>('#seriesTitle')!
readonly #deleteButton = this.componentBody.querySelector<HTMLButtonElement>('#deleteBtn')!
constructor() {
super(HTML)
this.#deleteButton.addEventListener('click', (evt) => {
evt.preventDefault()
// Attributes worden properties in de klasse.
void seriesPersistenceProvider.delete(this.id)
})
}
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
switch (name) {
case 'name':
this.#name.innerText = newValue
break
}
}
}export class SeriesPage extends Page {
readonly #seriesList = this.body.querySelector<HTMLUListElement>('#seriesList')!
readonly #modalClose = this.body.querySelector<HTMLButtonElement>('#close-btn')!
readonly #nameInput = this.body.querySelector<HTMLInputElement>('#seriesName')!
readonly #seriesForm = this.body.querySelector<HTMLFormElement>('#seriesForm')!
#series: Series[] = []
constructor() {
super(HTML)
this.#seriesForm.addEventListener('submit', async (evt) => {
evt.preventDefault()
await this.#createSeries()
this.#seriesForm.reset()
this.#modalClose.click()
this.render()
})
this.unsubscribe.push(seriesPersistenceProvider.addObserver(series => {
this.#series = series
this.render()
}))
void seriesPersistenceProvider.getAll()
}
render() {
super.render()
this.#seriesList.innerText = ''
this.#series.forEach(s => {
const series = new SeriesComponent()
series.setAttribute('name', s.name)
series.setAttribute('id', s.id)
this.#seriesList.appendChild(series)
})
}
async #createSeries(): Promise<void> {
const name = this.#nameInput.value
await seriesPersistenceProvider.create({
name,
})
}
}WebStorage
Begrip: Web Storage
De Web Storage API biedt een manier om gegevens op te slaan in de browser van de gebruiker.
Web Storage is bedoeld om eenvoudige string gegevens op te slaan die je als geheel wilt ophalen en die eenvoudig reproduceerbaar zijn. De hoeveelheid data die via Web Storage kan worden opgeslagen, is beperkt tot ongeveer 5 MB per origin (protocol (http/https) + domain + poort), verder kan de browser deze gegevens automatisch wissen als er een tekort aan opslagruimte is.
Web Storage wordt opgedeeld in twee verschillende opslagmethoden: localStorage en sessionStorage. LocalStorage is persistent doorheen verschillende sessies, terwijl sessionStorage alleen geldig is voor de huidige sessie. Dit betekent dat gegevens die in localStorage zijn opgeslagen, beschikbaar blijven, zelfs als de gebruiker het tabblad of de browser sluit. SessionStorage daarentegen is alleen beschikbaar zolang het tabblad niet afgesloten wordt. Als localStorage gebruikt wordt in incognito-modus, is de opslag ook tijdelijk en wordt deze gewist zodra het venster gesloten wordt, de data is dus langer beschikbaar dan sessionsStorage, maar niet zo lang als localStorage in een normaal venster.
Data die in localStorage bewaard is, blijft gegarandeerd bestaan tot dat (a) de gebruiker de browsergeschiedenis wist of (b) de maximale opslag voor de origin bereikt is of (c) de totale maximale opslag voor de Storage API bereikt is (tussen de 10 en 60% van de totale schrijfruimte, afhankelijk van de browser).
// Analoge API voor sessionStorage
window.localStorage.setItem('key', 'value')
window.localStorage.getItem('key')
window.localStorage.removeItem('key')
window.localStorage.clear()Via localStorage kunnen we een nieuwe persistentie provider maken die de data opslaat in de browser.
Om de nieuwe provider te gebruiken moet de MemoryPersistenceProvider vervangen worden door de LocalStoragePersistenceProvider in de data file.
import {Persistable, PersistenceProvider} from './persistenceProvider.ts'
export class LocalStoragePersistenceProvider<T extends Persistable> extends PersistenceProvider<T> {
readonly key: string
#data: T[] = []
constructor(key: string, initialData: T[] = []) {
super()
this.key = key
const localData = localStorage.getItem(key)
this.#data = localData ? JSON.parse(localData) : initialData
}
async create(data: Omit<T, 'id'>): Promise<T> {
const newObject = {...data, id: window.crypto.randomUUID()} as T
this.#data.push(newObject)
localStorage.setItem(this.key, JSON.stringify(this.#data))
this.notifyObservers(this.#data)
return newObject
}
async delete(id: string): Promise<void> {
this.#data = this.#data.filter(x => x.id !== id)
localStorage.setItem(this.key, JSON.stringify(this.#data))
this.notifyObservers(this.#data)
this.notifyItemObservers(id, null)
}
async get(id: string): Promise<T> {
const item = this.#data.find(x => x.id === id)
if (!item) {
throw new Error(`No item found with the given id: ${id}`)
}
this.notifyItemObservers(id, item)
return item
}
async getAll(): Promise<T[]> {
this.notifyObservers(this.#data)
return this.#data
}
async update(id: string, data: T): Promise<T> {
const index = this.#data.findIndex(x => x.id === id)
if (index === -1) {
throw new Error(`No item found with the given id: ${id}`)
}
this.#data[index] = {...this.#data[index], ...data, id: this.#data[index].id}
localStorage.setItem(this.key, JSON.stringify(this.#data))
this.notifyObservers(this.#data)
this.notifyItemObservers(id, this.#data[index])
return this.#data[index]
}
}import {Book} from '../models/book.ts'
import {Author} from '../models/author.ts'
import {Series} from '../models/series.ts'
import {LocalStoragePersistenceProvider} from './localStoragePersistenceProvider.ts'
export const bookPersistenceProvider = new LocalStoragePersistenceProvider<Book>('books', [
{
id: '2f14bf86-2837-4b92-b329-a78ea71ca4ad',
series: {name: 'Hyperion Cantos', id: '0f31cbcc-bd4b-4ece-b169-2c931ed8e41b', number: 1},
publicationYear: 1989,
author: {
id: '4a1d3bd4-e35f-4301-af14-80a3aa0b0079',
name: 'Dan Simmons',
description: 'Dan Simmons (born April 4, 1948) is an American science fiction and horror writer. He is the author of the Hyperion Cantos and the Ilium/Olympos cycles, among other works that span the science fiction, horror, and fantasy genres, sometimes within a single novel. Simmons\'s genre-intermingling Song of Kali (1985) won the World Fantasy Award.',
profile: 'https://encrypted-tbn3.gstatic.com/images?q=tbn:ANd9GcSZ53vcGWYo-6Cpi5WFbS1ndlCtXGu8E-yj2VThSl6NTr7_dcqsJWqJv8wn2ORqUL-S4xjPhySmBuba5yWrkc4hxg',
},
type: 'print',
title: 'Hyperion',
},
])
export const authorPersistenceProvider = new LocalStoragePersistenceProvider<Author>('authors', [
{
id: '6f7753e0-134b-434f-90f4-214389dd5041',
name: 'Steven Erikson',
description: 'Steve Rune Lundin (born October 7, 1959), known by his pseudonym Steven Erikson, is a Canadian novelist who was educated and trained as both an archaeologist and anthropologist.',
profile: 'https://upload.wikimedia.org/wikipedia/commons/2/2d/Steven_Erikson_2016.jpg',
},
{
id: '4a1d3bd4-e35f-4301-af14-80a3aa0b0079',
name: 'Dan Simmons',
description: 'Dan Simmons (born April 4, 1948) is an American science fiction and horror writer. He is the author of the Hyperion Cantos and the Ilium/Olympos cycles, among other works that span the science fiction, horror, and fantasy genres, sometimes within a single novel. Simmons\'s genre-intermingling Song of Kali (1985) won the World Fantasy Award.',
profile: 'https://encrypted-tbn3.gstatic.com/images?q=tbn:ANd9GcSZ53vcGWYo-6Cpi5WFbS1ndlCtXGu8E-yj2VThSl6NTr7_dcqsJWqJv8wn2ORqUL-S4xjPhySmBuba5yWrkc4hxg',
},
{
id: 'b4cc061f-0743-449e-b0be-cce7055f1154',
name: 'Robin Hobb',
description: 'Margaret Astrid Lindholm Ogden (born March 5, 1952; née Lindholm), known by her pen names Robin Hobb and Megan Lindholm, is an American writer of speculative fiction. As Hobb, she is best known for her fantasy novels set in the Realm of the Elderlings.',
profile: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Robin_Hobb_by_Gage_Skidmore.jpg/1920px-Robin_Hobb_by_Gage_Skidmore.jpg',
},
])
export const seriesPersistenceProvider = new LocalStoragePersistenceProvider<Series>('series', [
{name: 'The Malazan Book of the Fallen', id: 'b16cca4a-e13e-4854-bb34-7fac4fd31f02'},
{name: 'Hyperion Cantos', id: '0f31cbcc-bd4b-4ece-b169-2c931ed8e41b'},
{name: 'The Farseer Trilogy', id: '5269d1b5-19c0-432f-a797-c88fead0475f'},
])